Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, Check, Crown, Equal, ListChecks } from "lucide-react"
import { cn } from "@/lib/utils"
import type { PollOption, PollResultsStatus } from "./poll-results.contract"

/**
 * Bars grow in from a hoisted <style> (React 19 dedupes by href) instead of a
 * Tailwind keyframe config edit. Decorative only — `motion-reduce:animate-none`
 * drops it and the bar is still drawn at its final width.
 */
const KEYFRAMES = `@keyframes zpr-grow{from{transform:scaleX(0)}to{transform:scaleX(1)}}`

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/poll-results.json

Prompt

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 / total and 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.

On This Page