# Poll

> A poll whose bars race to their share: vote, and every option fills with light while the percentages roll and live votes keep nudging them.

Most polls swap the buttons for a static bar chart the moment you vote. Here the question and its options sit on a lit card as inset rows. Tap one and every row fills with light to its share, one after another, while the percentages roll up from zero; your pick is ringed with a drawn-in check and the leader is set in full weight. As other votes arrive the bars glide and the numbers roll, and changing your vote moves both rows at once. The bars answer your tap straight away and go back with the reason if your server says no. Under the card a few voters’ faces, the count and the time left. The votes are yours: options with counts and the visitor’s vote in, onVote out.

## Install

```bash
npx shadcn@latest add https://hairlineui.com/r/poll.json
```

Needs React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Docs and live preview: https://hairlineui.com/ui/poll
- Registry item (source): https://hairlineui.com/r/poll.json

## Usage

```tsx
import { Poll } from "@/components/poll";

<Poll
  question="What should we build next?"
  options={options}          // { id, label, votes }
  vote={myVote}
  onVote={(id) => api.vote(pollId, id)}
/>
```

## With your own API

Load the counts and the visitor’s vote, post the vote, and refresh; check again now and then (or over a socket) for live results.

```tsx
"use client";
import { useEffect, useState } from "react";
import { Poll, type PollOption } from "@/components/poll";

type State = { options: PollOption[]; vote: string | null };

export function RoadmapPoll({ id, initial }: { id: string; initial: State }) {
  const [poll, setPoll] = useState(initial);
  const load = async () => setPoll(await (await fetch(`/api/polls/${id}`)).json());

  useEffect(() => {
    const timer = setInterval(load, 10000);
    return () => clearInterval(timer);
  }, [id]);

  return (
    <Poll
      question="What should we build next?"
      options={poll.options}
      vote={poll.vote}
      onVote={async (option) => {
        const res = await fetch(`/api/polls/${id}/vote`, { method: "POST", body: JSON.stringify({ option }) });
        if (!res.ok) throw new Error((await res.json()).message);
        await load();
      }}
    />
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `question` | `string` | – | The question on the card. |
| `options` | `PollOption[]` | – | { id, label, votes }; new counts glide the bars. |
| `vote` | `string \| null` | – | The visitor’s vote from your data; with one, the results show. |
| `onVote` | `(optionId) => Promise<unknown>` | – | Record a vote or a changed one; throw an Error to put the bars back and show why. |
| `closesAt` | `Date \| string \| number` | – | Shows the time left; after it the results are final. |
| `voters` | `{ id, name, src? }[]` | – | A few recent voters, shown as faces with the count. |
| `allowChange / source / locale` | `boolean / string / string` | `true / "Poll" / browser` | Whether votes can change, who’s asking, and number formatting. |

## States

- **ask**: The lit card with the source, the vote count and time left, the question, and the options as inset rows with an empty ring.
- **reveal**: Tapping fills each row with light to its share (900ms, 70ms apart) and rolls its percent from 0; your row is ringed and a check folds in; the leader is in full weight.
- **live**: New counts glide the bars (700ms) and roll the numbers and the total.
- **change**: Change vote (it morphs to Keep my vote) makes the other rows tappable; picking one moves both bars at once.
- **error**: If onVote throws, the bars go back and the reason replaces the line under the card.
- **closed**: After closesAt the results show with “Final results” and no row can be tapped.

## What it handles

A poll is usually buttons that become a chart, with the vote count updated on refresh. This block keeps one surface from the question to live results.

- **Results that arrive**: Your vote fills every row to its share in turn and rolls the percentages up from zero.
- **Instant, then confirmed**: The bars move on the tap; if onVote throws they go back and the reason shows.
- **Live without jumps**: New counts from your data glide the bars and roll the numbers, never redraw them.
- **Change your mind**: With allowChange, Change vote lets you tap another option and both rows move at once.
- **A clock**: closesAt shows the days or hours left, then Final results and no more voting.
- **Who voted**: A few voters’ faces and “Iris, Theo and 1,281 others” under the card.

## Replaces

vote buttons, a results chart, a vote counter, a change-vote flow, a voters list

## Where it fits

- **Communities**: Polls in a forum, Discord-style community or member area.
- **Roadmap voting**: Letting customers vote on what gets built next.
- **Social posts**: A poll attached to a post or a story.
- **Events and classes**: Quick questions during a live session.

## Notes

- Before voting the options are a radio group of “Vote for …” buttons; after, each is named with its share and whether it’s your vote.
- Your vote is announced from a status region; live changes aren’t announced, so a busy poll never chatters.
- The fill is a scaled layer behind the text, so labels never move and the bars stay composited.
- Its light comes from your theme: the card mixes --primary-foreground into --primary, the rows are inset 1px edges and the fill is the same light at two strengths. No drop shadows, glow or blur.
- Built from Number roll and Text morph, which the CLI installs with it. No animation library; with reduced motion the bars and numbers change in place.

License: MIT (https://hairlineui.com/license#components)
