Add a Poll to Your Website (HTML, CSS & JS)

Published Updated

An HTML poll begins as a normal form with one radio group and a submit button. That baseline works with keyboards, screen readers, and browsers where JavaScript fails or is disabled.

Think of the browser as the paper ballot and the server as the ballot box. The browser presents choices and carries the selected value, while the server decides whether the ballot is valid, whether voting is open, and whether the visitor can vote again.

Build the Semantic Poll Form

Start with a form that can submit without JavaScript. A fieldset groups the controls, and its legend provides the poll question.

<?php
session_start();
if (empty($_SESSION["csrf_token"])) {
    $_SESSION["csrf_token"] = bin2hex(random_bytes(32));
}
?>
<form class="poll-card" action="/api/polls/weekly-tool/votes.php" method="post">
  <fieldset>
    <legend>Which database are you using most this month?</legend>

    <label class="poll-option">
      <input type="radio" name="choice" value="mysql" required>
      <span>MySQL</span>
    </label>

    <label class="poll-option">
      <input type="radio" name="choice" value="postgresql">
      <span>PostgreSQL</span>
    </label>

    <label class="poll-option">
      <input type="radio" name="choice" value="sqlite">
      <span>SQLite</span>
    </label>
  </fieldset>

  <input
    type="hidden"
    name="csrf_token"
    value="<?= htmlspecialchars($_SESSION["csrf_token"], ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8") ?>"
  >
  <button type="submit">Vote</button>
  <p class="poll-status" data-poll-status aria-live="polite"></p>
</form>
<div class="poll-results" data-poll-results></div>

Every radio uses the same name, so the browser permits one checked choice. The required attribute activates built-in constraint validation, and wrapping each input in a label creates a larger click and tap target.

The hidden CSRF token is generated once for the visitor's session, escaped for its HTML attribute, and checked again by the endpoint. The dynamic-sites CSRF section shows the same flow as reusable functions.

Style the Choice States

Keep the native radio inputs available to assistive technology and style the surrounding labels. The selected and keyboard-focus states should remain visible without depending on color alone.

.poll-card {
  --poll-accent: #1e5aa8;
  --poll-border: #68778e;
  --poll-surface: #edf4ff;
  --poll-text: #13213c;
  max-width: 34rem;
  padding: 1rem;
  color: var(--poll-text);
  background: var(--poll-surface);
  border: 2px solid var(--poll-border);
  border-radius: 0.5rem;
}

.poll-card fieldset {
  display: grid;
  gap: 0.75rem;
  padding: 0;
  margin: 0;
  border: 0;
}

.poll-option {
  display: flex;
  gap: 0.65rem;
  align-items: center;
  padding: 0.75rem;
  background: #dceaff;
  border: 2px solid var(--poll-border);
  border-radius: 0.5rem;
  cursor: pointer;
}

.poll-option:has(input:checked) {
  border-color: var(--poll-accent);
  box-shadow: inset 0 0 0 2px var(--poll-accent);
}

.poll-option:has(input:focus-visible) {
  outline: 3px solid #9b4d00;
  outline-offset: 3px;
}

.poll-card button {
  margin-top: 1rem;
}

The :has() selector lets the label respond to its nested radio. Browsers without the selector still show the native checked and focused input, so the enhancement can fail without blocking the vote.

Submit the Form Progressively

The form already has a working server destination. JavaScript can now intercept the same submission, send FormData through fetch(), and leave the form action available as a fallback.

const form = document.querySelector(".poll-card");
const status = document.querySelector("[data-poll-status]");
const results = document.querySelector("[data-poll-results]");

form?.addEventListener("submit", async (event) => {
  event.preventDefault();

  if (!form.reportValidity()) {
    return;
  }

  const button = form.querySelector("button");
  let voteAccepted = false;
  button.disabled = true;
  status.textContent = "Sending your vote...";

  try {
    const response = await fetch(form.action, {
      method: "POST",
      body: new FormData(form),
      credentials: "same-origin",
      headers: { "Accept": "application/json" },
    });

    const data = await response.json();

    if (!response.ok) {
      status.textContent = data.error ?? "That vote could not be recorded.";
      return;
    }

    renderPollResults(data.options);
    voteAccepted = true;
    status.textContent = "Your vote was recorded.";
  } catch {
    status.textContent = "The server could not be reached. Try again.";
  } finally {
    button.disabled = voteAccepted;
  }
});

reportValidity() shows the browser's normal validation message before a network request starts. Disabling the button reduces accidental double clicks, while the server still owns duplicate detection because a visitor can bypass this script.

The live status region announces submission progress and the final result. Keep failure messages specific enough to guide the next action without exposing server internals.

Render Results with DOM Methods

Create result elements with DOM methods so labels remain text. This avoids placing server-returned strings into innerHTML.

function renderPollResults(options) {
  const list = document.createElement("ol");
  list.className = "poll-bars";

  for (const option of options) {
    const percent = Math.min(100, Math.max(0, Number(option.percent) || 0));
    const item = document.createElement("li");
    const label = document.createElement("span");
    const meter = document.createElement("meter");
    const value = document.createElement("span");

    label.textContent = String(option.label);
    meter.min = 0;
    meter.max = 100;
    meter.value = percent;
    meter.textContent = percent + "%";
    value.textContent = percent + "%";

    item.append(label, meter, value);
    list.append(item);
  }

  results.replaceChildren(list);
}

Clamp percentages before assigning them to meter. The server should calculate totals from stored votes, and the browser should treat the returned labels and numbers as untrusted input.

.poll-bars {
  display: grid;
  gap: 0.75rem;
  padding: 0;
  list-style: none;
}

.poll-bars li {
  display: grid;
  grid-template-columns: minmax(7rem, 1fr) minmax(8rem, 2fr) auto;
  gap: 0.75rem;
  align-items: center;
}

.poll-bars meter {
  width: 100%;
}

@media (max-width: 32rem) {
  .poll-bars li {
    grid-template-columns: 1fr;
  }
}

Keep the PHP Endpoint Small

The PHP boundary needs to validate the token and choice, reject a repeated session vote, call one application-owned persistence function, and return JSON. Database details can remain behind that function.

<?php

session_start();

$pollId = "weekly-tool";
$pollPage = "/polls/weekly-tool";
$allowedChoices = ["mysql", "postgresql", "sqlite"];
$choice = filter_input(INPUT_POST, "choice", FILTER_UNSAFE_RAW);
$token = filter_input(INPUT_POST, "csrf_token", FILTER_UNSAFE_RAW);
$sessionToken = $_SESSION["csrf_token"] ?? null;
$expectsJson = str_contains(
    $_SERVER["HTTP_ACCEPT"] ?? "",
    "application/json",
);

function rejectVote(
    int $status,
    string $message,
    bool $expectsJson,
    string $pollPage,
): never {
    if (! $expectsJson) {
        header("Location: {$pollPage}", true, 303);
        exit;
    }

    http_response_code($status);
    header("Content-Type: application/json; charset=utf-8");
    echo json_encode(["error" => $message]);
    exit;
}

function recordVoteAndLoadResults(
    string $pollId,
    string $choice,
    bool $expectsJson,
    string $pollPage,
): array {
    if (! pollIsOpen($pollId)) {
        rejectVote(409, "This poll is closed.", $expectsJson, $pollPage);
    }

    if (isset($_SESSION["poll_votes"][$pollId])) {
        rejectVote(409, "This session has already voted.", $expectsJson, $pollPage);
    }

    $options = persistVoteAndLoadResults($pollId, $choice);
    $_SESSION["poll_votes"][$pollId] = true;

    return $options;
}

if (
    ! is_string($sessionToken)
    || ! is_string($token)
    || ! hash_equals($sessionToken, $token)
) {
    rejectVote(403, "The form token is invalid.", $expectsJson, $pollPage);
}

if (!is_string($choice) || !in_array($choice, $allowedChoices, true)) {
    rejectVote(422, "Choose one of the available options.", $expectsJson, $pollPage);
}

$options = recordVoteAndLoadResults(
    $pollId,
    $choice,
    $expectsJson,
    $pollPage,
);

if (! $expectsJson) {
    header("Location: {$pollPage}", true, 303);
    exit;
}

header("Content-Type: application/json; charset=utf-8");
echo json_encode(["options" => $options]);

recordVoteAndLoadResults() checks the poll state and duplicate-vote rule at the write boundary, then records the vote and loads results through one application-owned persistence operation. persistVoteAndLoadResults() should use a transaction where concurrent updates could lose votes.

A normal form submission does not send Accept: application/json, so the endpoint returns a 303 redirect to the server-rendered poll page. The enhanced path requests JSON and updates the existing page.

The endpoint validates the submitted value against the server's poll definition. Client-supplied labels, counts, poll status, and percentages never become the source of truth.

Prevent Duplicate Votes

A disabled button prevents an ordinary double click, and a session flag blocks a repeat from the same active session. Those controls are enough for a casual reader-preference poll where small manipulation has no real consequence.

Visitors can clear storage, start another session, or use another device. A poll tied to prizes, governance, access, or money needs authenticated accounts and a database uniqueness constraint on the voter and poll identifiers. Insert the vote through that constraint and translate a duplicate-key result into an HTTP 409 response.

The server remains the ballot box throughout the flow. Browser controls improve the experience, while server checks preserve the meaning of the result.

Common Pitfalls & Debugging

The Form Submits Without a Choice

Symptom: the endpoint receives an empty choice. Cause: the radios use different names, or the custom script skipped constraint validation. Fix: give every radio the same name, keep required on one group member, call reportValidity(), and validate again in PHP.

The Same Visitor Votes Twice

Symptom: two quick submissions create two stored votes. Cause: the UI disabled too late, or storage lacks an atomic uniqueness rule. Fix: disable during submission and enforce duplicate detection inside the server-side write.

Result Labels Create HTML

Symptom: a poll label changes page markup or runs injected code. Cause: the response was inserted through innerHTML. Fix: assign labels with textContent, clamp numeric values, and validate poll definitions when they are created.

Frequently Asked Questions

Can poll results be cached instead of recalculated each view?

Yes, for a short window such as thirty to sixty seconds. Exact real-time accuracy rarely matters for a casual poll, and caching avoids recomputing percentages for every visitor who loads the page.

Can a poll offer a write-in option beside fixed choices?

Yes, by pairing a radio option with a text input that only counts when that option is selected. The server still has to validate and constrain what a write-in answer may contain before storing it.

Should results appear before or after someone votes?

After, whenever the outcome could sway the vote. Showing the running tally first pulls answers toward whatever is already leading, which defeats the point of asking for an independent opinion.

Should poll validation run in the browser or server?

Run the same validation rules in both places. Browser validation gives ordinary visitors immediate feedback, while server validation protects stored results from altered requests. The server must allow only known choices and reject closed polls, invalid tokens, and duplicate votes.

Do the custom-styled poll choice buttons still work with keyboard navigation and screen readers?

They do when the styling is applied to real radio inputs and labels instead of replacing them with plain divs. Keep the native input focusable and keyboard-operable, use the associated label for the visible choice text, and reserve custom styling for appearance rather than removing semantics.

Next Steps

Build the form first, verify keyboard submission, then add the fetch enhancement and server validation. Test an empty choice, an invalid value, a repeated vote, and a server failure before publishing the poll.

Continue with the HTML guide for semantic structure, the CSS guide for form states, and the SQL programming guide when results need durable relational storage.

Sources

  1. [1]
    Web forms
    (developer.mozilla.org)
  2. [2]
    The fieldset element
    (developer.mozilla.org)
  3. [3]
    The radio input element
    (developer.mozilla.org)
  4. [4]
    Client-side form validation
    (developer.mozilla.org)
  5. [5]
    Using the Fetch API
    (developer.mozilla.org)
  6. [6]
  7. [7]
    PHP in_array
    (php.net)
  8. [8]
    PHP Sessions
    (php.net)
  9. [9]
  10. [10]