Add a Poll with HTML, JavaScript, and PHP

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.

This example combines HTML form markup with CSS, JavaScript, and a PHP server. HTML alone cannot store votes or share results between visitors.

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.

If you only need a poll on an existing site, a hosted poll service can provide an embed. The custom approach below gives you control over the form and stored votes, but requires a PHP-enabled server and database code. It is a set of connected examples, not a complete polling service you can paste into a static HTML file.

How Do You Build an HTML Poll Form?

Build an HTML poll with a form, radio inputs sharing one name, labels, and a submit button. Group the choices in a fieldset and put the poll question in its legend.

<?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.

How Do You Style Poll Choices Accessibly?

Style HTML poll choices by keeping native radio inputs and applying CSS to their labels. Preserve keyboard focus, and make selected and focused states visible without relying 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.

How Do You Submit an HTML Poll with JavaScript?

Submit the HTML poll with its normal form action first, then add JavaScript to send the same values with fetch(). The form action remains the fallback when JavaScript is unavailable. You must implement the PHP endpoint below before either path can save votes.

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;
    }

    voteAccepted = true;
    renderPollResults(data.options);
    status.textContent = "Your vote was recorded.";
  } catch {
    status.textContent = "The vote result could not be confirmed. Check the results before trying again; your vote may already have been recorded.";
  } 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.

How Do You Display Poll Results Safely?

Display poll results by creating DOM elements and assigning each result label with textContent. Avoid inserting server-returned strings through innerHTML, which can interpret them as markup.

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.setAttribute("aria-label", String(option.label));
    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;
  }
}

How Does PHP Validate and Save a Poll Vote?

The PHP endpoint validates the CSRF token and selected choice, checks whether voting is open, and saves an accepted vote. The example uses PHP 8.1 or later and leaves pollIsOpen() and persistVoteAndLoadResults() for your application to implement.

<?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]);

Implement pollIsOpen() to read the poll's status and persistVoteAndLoadResults() to save a vote and return options containing label and percent. Use an atomic database update or a transaction so simultaneous votes are not lost.

A normal form submission receives a 303 redirect back to the poll page. That page must render the saved results and any error message; the example endpoint does not yet store a message for the redirect. The JavaScript path requests JSON and displays the response without loading another 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.

How Do You Prevent Duplicate Poll Votes?

Prevent duplicate poll votes on the server, with a disabled submit button as an extra convenience. A session flag can stop repeats from the same session in a casual poll, but cannot identify someone who returns with another session or device.

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.

Check the Poll Before Publishing

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.

Frequently Asked Questions

Can website poll results be cached?

Website poll results can be cached for a short period, such as 30 to 60 seconds, when a delayed tally is acceptable. Record each vote reliably first, then update or expire the cached totals.

Can a website poll include a write-in answer?

A website poll can include a write-in answer by pairing a radio choice with a text input. Accept the text only when that choice is selected, and validate its length and allowed content on the server.

Should website poll results appear before or after voting?

Show website poll results after voting when seeing the tally could influence a visitor's answer. A separate results view can still be useful once voting closes.

Should poll validation run in the browser or on the server?

Validate poll votes on the server, with browser validation added for quicker feedback. The server must check allowed choices, voting status, tokens, and duplicate-vote rules even when the browser has already accepted the form.

Can custom-styled HTML poll choices remain accessible?

Custom-styled HTML poll choices can remain accessible when they use real radio inputs and associated labels. Keep the inputs focusable, preserve keyboard selection, and show a visible focus indicator.

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]