Poll Results
A poll result readout — one bar per option with largest-remainder percentages that always total exactly 100%, tie-aware leader badges, a your-vote marker, and a ballot view that hides the counts until the visitor votes.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/poll-results.jsonPrompt
Build a React + TypeScript + Tailwind "PollResults" component with zod and
lucide-react.
Contract
- A zod schema in a sibling contract file is the single source of truth:
`pollOptionSchema` = { id, label (min 1 char), votes (non-negative integer) },
`pollResultsStatusSchema` = "loading" | "empty" | "error" | "ready", and
`pollResultsSchema` = { status, items, question?, myVoteId? }.
- `votes` is the RAW tally, never a pre-computed percentage. The shares have to
be apportioned across all options together for the printed column to total
100%, and a data layer that hands over rounded percentages has already thrown
away the information needed to do that. The total is the sum of the tallies —
there is no separate total field that could drift out of sync.
- `myVoteId` travels with the poll because it is a per-viewer field of the same
payload: the tallies are public, "which one is mine" is not.
- Props: items; status; question?; myVoteId? (default null); mode?: "results" |
"ballot" (default "results"); onVote?(id); onRetry?; decimals? (clamped 0–2,
default 0); locale? (default "en-US"); skeletonRows? (clamped 1–24, default
4); emptyState?; className. Affordances are handler-gated: no onRetry means
the error branch has no button, no onVote means the ballot has no submit.
Behavior
- Percentages use largest-remainder (Hamilton) apportionment over integer units
of 10^-decimals of a percent: give every option its floor, then hand the
leftover units to the largest fractional parts. Rounding each share on its own
loses or invents a unit — three options at one third each print 33 / 33 / 33
and the column reads 99. Remainder ties go to the bigger tally first, then to
the earlier option, so the same data always prints the same column. Verify
with three equal options: the printed numbers must sum to exactly 100 at every
supported precision.
- Bar geometry uses the raw ratio votes / total, NOT the rounded percentage, so
two options with the same tally always draw the same bar even when the
apportionment hands the leftover unit to one of them. A 0.6% share draws a
0.6% bar — no minimum bar width, because a floor would make the picture lie
about the ratio; the count column is what makes small shares readable.
- Zero total is a first-class case and is NOT the empty state: the options
exist, nobody has voted. Every bar is empty and each share prints an em dash,
because "0%" claims someone voted and this option got none of it. Nothing is
marked as leading. ("empty" means the poll has no options at all.)
- The leader badge lands on EVERY option that reaches the top tally, never on
the first one only: one winner reads "Leading" (crown), two or more tied read
"Tied" (equals sign). With no votes at all, nobody leads.
- Status is a redundant encoding, never colour: this palette is a single hue, so
"leading", "tied" and "your vote" are icon + word badges, and the bar's fill
weight (solid / 65% / 30%) only reinforces what the badges already say.
- mode="ballot" hides every count behind a real radiogroup: option buttons carry
role="radio" + aria-checked, the group is one tab stop (roving tabindex on the
checked option, or the first one when nothing is picked), and Arrow keys /
Home / End move and select. The pending pick is local state seeded from
myVoteId, so a half-made choice never round-trips; only Vote calls onVote. The
submit uses aria-disabled rather than the native attribute — a natively
disabled button is unfocusable, so a keyboard user cannot even find it — and
clicking it with nothing picked moves focus to the first option instead of
doing nothing at all.
- Four first-class branches on status: loading → skeleton rows in the real row
geometry (label bar + track) plus one sr-only role="status"; empty → a "no
options" slot replaceable via emptyState; error → message + "Try again" when
onRetry is passed; ready → question + rows (or the ballot).
- Nothing is capped or clipped: every option renders, long labels wrap
(`break-words`, `min-w-0`), and no row has a fixed height. A twelve-option
poll renders twelve rows.
Rendering & styling
- Semantic tokens only: bg-muted (tracks, skeletons), bg-primary at 100/75/50%
(your vote / leader / rest), text-muted-foreground (counts, hints), border for
the badges and the ballot options, text-destructive for the error icon,
focus-visible:ring-2 ring-ring on every control. cn() merges the consumer
className into the root. Do not reach for --chart-1..5 to tell options apart:
in a monochrome ladder they carry no categorical meaning.
- 50% is the FLOOR for the weakest fill, not a taste call: the bar is the
graphic that carries the value, so it has to clear WCAG 1.4.11's 3:1 against
its own track. Measured on this palette, /30 gives 1.80:1 (light) and 1.87:1
(dark) — invisible-adjacent; /50 gives 3.13:1 and 3.78:1. Re-measure if you
restyle the track.
- Every numeral is `tabular-nums`, otherwise a column of percentages jitters
sideways as the digits change width.
- Accessibility: the results list is a real ul with role="list" named by the
question (aria-labelledby, falling back to aria-label). Each row's visual half
is aria-hidden and paired with ONE sr-only sentence — "Label: 412 votes, 43%
of the vote, your vote, leading." — because a bar plus three loose numerals is
read as "43", an unnamed graphic, "412". The question is a plain <p> with an
id, not a heading, so it cannot fight the host page's outline.
- All formatting goes through Intl with an explicit locale — never
Intl.*(undefined), which desyncs SSR from the visitor's locale — and the
constructors are wrapped so an unknown tag falls back to en-US instead of
throwing during render.
- Only the mount grow (scaleX, from a hoisted <style> keyframe) and the width /
colour transitions are animated, all carrying motion-reduce:*-none; with
motion off the bar is still drawn at its final width.
Customization levers
- Ordering: rows render in the order given. Sort by votes upstream for a ranked
readout, or keep ballot order so nothing jumps position after a vote.
- Precision: `decimals` 0–2. The apportionment runs at the same precision, so
the column totals 100 at any of them; 1 is the sweet spot for polls with more
than ~10 options.
- Bar scale: the fill is a share of the total. Dividing by the top tally instead
makes small shares legible but the bar stops reading as a share of the whole —
say which one you mean in the row's sr-only sentence.
- Multi-choice polls: widen `myVoteId` to `myVoteIds: string[]`, swap the
ballot's radiogroup for a checkbox group (role changes, so keep the arrow-key
handler out of it), and remember the tallies then sum past the total.
- Chrome: `question` can be dropped when the surrounding card already asks it,
`emptyState` replaces the whole zero-options block, `skeletonRows` should
match the option count you usually render, and the track height (h-2), row gap
(gap-3) and badge copy are one-line edits.
- Live polls: the width transition already animates a changing tally, so
re-rendering with new counts from a socket needs no extra work.Concepts
- Largest-remainder apportionment — every option gets the floor of its exact share, then the leftover units go to the biggest fractional parts. It is the difference between a column that reads 99% and one that reads 100%; rounding each row on its own can never fix itself, because each row is missing the information about all the others.
- Raw ratio for the picture, apportioned number for the label — the bar is drawn from
votes / totaland the label from the apportioned units. Driving the bar off the rounded number would make two options with identical tallies draw visibly different bars, which is a worse lie than a 0.5% width error. - Zero total is not empty — "nobody has voted yet" and "this option got no votes" are different facts, so a poll with no votes prints an em dash per row instead of a wall of 0%. The empty status means something else again: the poll has no options at all.
- Tie-aware leading — the badge is computed from the top tally, not from the first row, so a tie marks every option that reaches it and the wording flips from "Leading" to "Tied". Marking only the first row is the classic bug this rule exists to prevent.
- Redundant status encoding — leading, tied and your-vote are icon + word badges, not colours. The default palette is a single-hue lightness ladder, so any design that leans on hue to separate options is unreadable the moment the theme changes.
- Ballot then results — the same component before and after voting: the ballot is a real radiogroup with the counts withheld, and revealing the results is a prop change, so the card does not jump size or lose its place when the tallies arrive.
Git Graph
A commit-DAG viewer — topological rows, reusable swimlanes for branches and merges, copyable hashes, and four data states.
Resource Usage
A quota panel for heterogeneous resources — per-unit formatting, unlimited rows that draw no bar, rescaled over-quota tracks, collapsible groups and usage sorting.