# Choice Poll

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

- Name: `choice-poll`
- Status: beta
- Categories: forms, voting
- File: the install writes `choice-poll.tsx` to your `ui` alias, `@/components/ui` by default.
- npm packages: `@base-ui/react`. The install adds them.
- Other registry items: `@txbrendan/motion`, `@txbrendan/button`, `@txbrendan/badge`. The install adds them.
- Gallery: https://txbrendan.dev/components/choice-poll
- Registry item: https://txbrendan.dev/r/choice-poll.json

## Install

1. Add the registry to `components.json`. Do this first, once:

   ```bash
   npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
   ```

   The command writes this entry. You can also add it by hand:

   ```json
   {
     "registries": {
       "@txbrendan": "https://txbrendan.dev/r/{name}.json"
     }
   }
   ```

   Without this step, the shadcn CLI looks up `@txbrendan` in a public list that txbrendan/ui does not control.

2. Add the theme once per app. It replaces your color tokens with ones checked for contrast:

   ```bash
   npx shadcn@latest add @txbrendan/theme
   ```

3. Add the component:

   ```bash
   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`: `ReactNode`. The question. It names the choices and the results for screen readers.
- `description`: `ReactNode`. Optional details under the question, like when the poll closes.
- `options`: `{ value: string; label: ReactNode; votes: number }[]`. The answers, in the order to show them. value must be unique. votes is the count so far.
- `vote`: `string | null`. 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.
- `defaultVote`: `string`. An earlier vote by this person when you do not control vote. The poll shows results at once and does not count it again.
- `onVote`: `(value: string) => void | Promise<unknown>`. 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.
- `closed`: `boolean`. Default `false`. Voting has ended. Shows the final results with a Closed badge.
- `disabled`: `boolean`. Default `false`. Blocks voting. The results are not affected.
- `tone`: `"neutral" | "brand" | "danger" | "success" | "warning"`. Default `"neutral"`. Color of the picked choice, the Vote button, and the result bars.
- `voteText`: `string`. Default `"Vote"`. The Vote button's text.
- `yourVoteText`: `string`. Default `"Your vote"`. The badge on this person's choice in the results.
- `savingText`: `string`. Default `"Saving…"`. The badge on this person's choice while the vote saves. yourVoteText takes its place once it saves.
- `errorText`: `string`. Default `"Could not save your vote. Try again."`. Shown above the Vote button when a vote does not save. Screen readers announce it.
- `closedText`: `string`. Default `"Closed"`. The badge on a closed poll.
- `requiredText`: `string`. Default `"Pick an option to vote."`. Shown when someone presses Vote with nothing picked. Screen readers announce it.
- `votesText`: `{ zero?: string; one?: string; two?: string; few?: string; many?: string; other: string }`. Default `{ one: "{count} vote", other: "{count} votes" }`. 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.
- `locale`: `string`. Default `"en-US"`. A language tag for the counts, the percents, and the plural forms, like 'de-DE'.
- `className`: `string`. Extra classes for the root. Later classes win, so className='gap-6' spaces the parts more.

## 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

```tsx
<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

```tsx
"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

```tsx
<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

```tsx
<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"
/>
```
