Skip to content
txbrendan/ui

Choice Poll

beta

A question with options people pick one of. After a vote, it shows each option's share as a bar with a percent and a count.

Preview

open

Which day works best for planning?
Pick one, then press Vote.
20 votes

voted

Which day works best for planning?
  • Monday12 votes60%
  • WednesdayYour vote6 votes30%
  • Friday2 votes10%
20 votes

closed

Name for the new project?
Voting ended on Friday.
  • CometsYour vote31 votes53%
  • Falcons18 votes31%
  • Wolves9 votes16%
Closed58 votes

disabled

Which day works best for planning?
Only the team can vote.
20 votes

neutral

Snack for the next meeting?
  • FruitYour vote8 votes62%
  • Pretzels5 votes38%
13 votes

brand

Snack for the next meeting?
  • FruitYour vote8 votes62%
  • Pretzels5 votes38%
13 votes

danger

Snack for the next meeting?
  • FruitYour vote8 votes62%
  • Pretzels5 votes38%
13 votes

success

Snack for the next meeting?
  • FruitYour vote8 votes62%
  • Pretzels5 votes38%
13 votes

warning

Snack for the next meeting?
  • FruitYour vote8 votes62%
  • Pretzels5 votes38%
13 votes

de-DE, open

Welcher Tag passt für die Planung?
1.214 Stimmen

de-DE, voted

Welcher Tag passt für die Planung?
  • Montag1.203 Stimmen99 %
  • MittwochDeine Stimme1 Stimme0 %
  • Freitag10 Stimmen1 %
1.214 Stimmen

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/choice-poll

Use it for

  • Asking a group one question with a few fixed answers, like which day suits a meeting.
  • Pass every option with its votes so far. The poll works out each share and the total.
  • It has three states. Open: people pick one option and press Vote. Voted: it shows the results. Closed: voting has ended and it shows the final results.
  • Return a promise from onVote to save the vote. The results show at once, with savingText on this person's choice, until the promise settles. If it rejects, the choices come back with the pick still made, and errorText says the vote did not save.
  • While a vote saves, the choices are gone, so no one can start a second vote. The root has data-pending until the save settles.
  • Leave vote out to let the poll keep its own state. It then counts the vote it takes, and defaultVote marks an earlier vote.
  • Pass vote to keep the vote in your own state. Set it to null until this person has voted. Once the save succeeds, and before your onVote promise resolves, set vote to their choice and add it to the votes you pass.
  • For another language, pass voteText, yourVoteText, savingText, errorText, closedText, requiredText, votesText, and locale.

Avoid it for

  • Questions where people can pick more than one answer. Use checkboxes instead.
  • Letting people change their vote. Once someone votes, the poll shows results and the choices are gone.
  • Ranking a long list of ideas that people add to. Use Feature Voting instead.
  • Answers that are a number on a scale, like 1 to 5. Use a slider or a rating.

Props

  • question

    The question. It names the choices and the results for screen readers.

    ReactNode

  • description

    Optional details under the question, like when the poll closes.

    ReactNode

  • options

    The answers, in the order to show them. value must be unique. votes is the count so far.

    { value: string; label: ReactNode; votes: number }[]

  • vote

    The option this person voted for, or null before they vote. Controlled. The poll shows the counts you pass as they are, plus a vote that is still saving.

    string | null

  • defaultVote

    An earlier vote by this person when you do not control vote. The poll shows results at once and does not count it again.

    string

  • onVote

    Called with the picked option when someone presses Vote. Return a promise, and the poll shows the vote at once with savingText until it settles. A rejection takes the vote back and shows errorText. When you control vote, set it and the counts only after the save succeeds, before your promise resolves.

    (value: string) => void | Promise<unknown>

  • closed

    Voting has ended. Shows the final results with a Closed badge.

    boolean · default false

  • disabled

    Blocks voting. The results are not affected.

    boolean · default false

  • tone

    Color of the picked choice, the Vote button, and the result bars.

    "neutral" | "brand" | "danger" | "success" | "warning" · default "neutral"

  • voteText

    The Vote button's text.

    string · default "Vote"

  • yourVoteText

    The badge on this person's choice in the results.

    string · default "Your vote"

  • savingText

    The badge on this person's choice while the vote saves. yourVoteText takes its place once it saves.

    string · default "Saving…"

  • errorText

    Shown above the Vote button when a vote does not save. Screen readers announce it.

    string · default "Could not save your vote. Try again."

  • closedText

    The badge on a closed poll.

    string · default "Closed"

  • requiredText

    Shown when someone presses Vote with nothing picked. Screen readers announce it.

    string · default "Pick an option to vote."

  • votesText

    How a count of votes reads in each plural form, with {count} for the number. The poll picks the form with Intl.PluralRules for locale. Polish, for example, needs one, few, many, and other.

    { zero?: string; one?: string; two?: string; few?: string; many?: string; other: string } · default { one: "{count} vote", other: "{count} votes" }

  • locale

    A language tag for the counts, the percents, and the plural forms, like 'de-DE'.

    string · default "en-US"

  • className

    Extra classes for the root. Later classes win, so className='gap-6' spaces the parts more.

    string

Accessibility

  • Open, the choices are a radio group named by the question. Arrow keys move between choices and pick, and Space picks.
  • Each radio is named by its label through aria-labelledby, so the name is in the server HTML too.
  • Picking does not vote, so arrowing through the choices is safe. Vote is a separate button.
  • Pressing Vote with nothing picked shows requiredText in a role="alert" message and moves focus to the first choice.
  • After someone votes here, focus moves to the results list, because the Vote button is gone. If the save fails, the choices come back and focus moves to the Vote button. Focus does not move if it is already somewhere else.
  • Results are a list named by the question. Each item reads its label, then 'Saving…' or 'Your vote' on this person's choice, then the count and the percent. The bars are hidden from screen readers.
  • A vote shows at once, so there is no spinner and no aria-busy. A failed save shows errorText in a role="alert" message, and the Vote button's aria-describedby points to it.
  • disabled takes the Vote button out of the tab order. The disabled choices stay in it, marked aria-disabled.
  • Bars use the tone fill, which the theme tests hold to 3:1 on the page and on cards.
  • Each bar sits on bg-track, which the theme tests hold to 1.25:1 on the page and on cards, so the full width shows. The track stays quiet, so a warning bar in light mode is only about 2.3:1 against it. The text carries every number.
  • Results grow in after a vote over --motion-duration-slow, and changed numbers fade in. data-motion="off" makes both instant. Reduced motion shows the bars at full width at once and drops the slide, but keeps the fade.
  • Every word has a prop with an English default: voteText, yourVoteText, savingText, errorText, closedText, requiredText, and votesText. locale formats the numbers and picks each plural form.

Examples

A poll that keeps its own state

<ChoicePoll
  question="Which day works best for planning?"
  description="Pick one. Results show after you vote."
  options={[
    { value: "mon", label: "Monday", votes: 12 },
    { value: "wed", label: "Wednesday", votes: 6 },
    { value: "fri", label: "Friday", votes: 2 },
  ]}
/>

Save votes yourself

"use client";
import { useState } from "react";

const initial = [
  { value: "mon", label: "Monday", votes: 12 },
  { value: "wed", label: "Wednesday", votes: 6 },
  { value: "fri", label: "Friday", votes: 2 },
];

function PracticePoll() {
  const [options, setOptions] = useState(initial);
  const [vote, setVote] = useState<string | null>(null);

  // The poll shows the vote at once, marked as saving. If this throws, it takes the vote back and says so.
  async function save(value: string) {
    // Replace this with your own request. fetch does not throw on a server error, so check ok.
    const response = await fetch("/api/planning-poll", { method: "POST", body: value });
    if (!response.ok) throw new Error("The vote did not save.");
    // Set the vote and the counts only after the save succeeds.
    setOptions((all) => all.map((o) => (o.value === value ? { ...o, votes: o.votes + 1 } : o)));
    setVote(value);
  }

  return (
    <ChoicePoll
      question="Which day works best for planning?"
      options={options}
      vote={vote}
      onVote={save}
      tone="brand"
    />
  );
}

Final results

<ChoicePoll
  question="Name for the new project?"
  options={[
    { value: "comets", label: "Comets", votes: 31 },
    { value: "falcons", label: "Falcons", votes: 18 },
  ]}
  vote="comets"
  closed
/>

In another language

<ChoicePoll
  question="Welcher Tag passt für die Planung?"
  options={[
    { value: "mo", label: "Montag", votes: 1203 },
    { value: "mi", label: "Mittwoch", votes: 1 },
  ]}
  voteText="Abstimmen"
  yourVoteText="Deine Stimme"
  closedText="Beendet"
  requiredText="Wähle eine Antwort, um abzustimmen."
  votesText={{ one: "{count} Stimme", other: "{count} Stimmen" }}
  locale="de-DE"
/>