Poll Card
A poll that reveals results after you vote: bars grow to their share, your pick is ticked, the leader is bold.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/poll-card.json'use client'
import { useState } from 'react'
import './poll-card.css'
export interface PollOption { id: string; label: string; votes: number }
export interface PollCardProps {
question: string
options: PollOption[]
onVote?: (id: string) => void
closesIn?: string
alwaysShowResults?: boolean
fill?: 'dark' | 'light'
className?: string
}
/** Single-choice poll with animated results. */
export function PollCard({ question, options, onVote, closesIn = '2 days left', alwaysShowResults = false, fill = 'dark', className }: PollCardProps) {
const [mine, setMine] = useState<string | null>(null)
const show = Boolean(mine) || alwaysShowResults
const counts = options.map((o) => o.votes + (o.id === mine ? 1 : 0))
const total = counts.reduce((a, b) => a + b, 0) || 1
const lead = Math.max(...counts)
return (
<div className={['sd-pol', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-pol__q" id="sd-pol-q">{question}</div>
<div className="sd-pol__opts" role={show ? 'list' : 'radiogroup'} aria-labelledby="sd-pol-q">
{options.map((o, i) => {
const p = counts[i] / total
return (
<button
key={o.id}
type="button"
role={show ? 'listitem' : 'radio'}
aria-checked={show ? undefined : false}
disabled={Boolean(mine)}
className="sd-pol__o"
data-lead={(show && counts[i] === lead) || undefined}
aria-label={show ? `${o.label}, ${Math.round(p * 100)} percent${o.id === mine ? ', your vote' : ''}` : o.label}
onClick={() => { setMine(o.id); onVote?.(o.id) }}
style={{ '--p': show ? p : 0 } as React.CSSProperties}
>
<span className="sd-pol__bar" aria-hidden />
<span className="sd-pol__l">{o.label}</span>
{o.id === mine && <svg className="sd-pol__tick" viewBox="0 0 16 16" aria-hidden><path d="m3.5 8.5 3 3 6-7" /></svg>}
{show && <span className="sd-pol__pct" aria-hidden>{Math.round(p * 100)}%</span>}
</button>
)
})}
</div>
<div className="sd-pol__foot">
<span aria-live="polite">{total.toLocaleString()} votes · {closesIn}</span>
{mine && <button type="button" onClick={() => setMine(null)}>Change vote</button>}
</div>
</div>
)
}.sd-pol { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-pol[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-pol button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-pol :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-pol .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-pol { width: min(100%, 320px); padding: 16px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); font-variant-numeric: tabular-nums; }
.sd-pol__q { font-weight: 650; font-size: 15px; margin-bottom: 12px; }
.sd-pol__opts { display: grid; gap: 6px; }
.sd-pol__o { position: relative; display: flex; align-items: center; gap: 8px; height: 40px; padding: 0 12px; border-radius: 11px; overflow: hidden; font-size: 13.5px; text-align: left; box-shadow: inset 0 0 0 1px var(--line); transition: box-shadow 160ms, transform 120ms; }
.sd-pol__o:hover:not(:disabled) { box-shadow: inset 0 0 0 1px var(--fg); }
.sd-pol__o:active:not(:disabled) { transform: scale(0.98); }
.sd-pol__o:disabled { cursor: default; color: var(--fg); }
.sd-pol__bar { position: absolute; inset: 0; background: var(--soft); transform-origin: left; transform: scaleX(var(--p)); transition: transform 700ms cubic-bezier(0.2, 1, 0.3, 1); }
.sd-pol__o[data-lead] .sd-pol__bar { background: color-mix(in srgb, var(--fg) 18%, transparent); }
.sd-pol__o[data-lead] { font-weight: 650; }
.sd-pol__l, .sd-pol__pct, .sd-pol__tick { position: relative; }
.sd-pol__l { flex: 1; }
.sd-pol__pct { font-size: 13px; animation: sd-pol-in 400ms 200ms ease both; }
.sd-pol__tick { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; animation: sd-pol-pop 360ms cubic-bezier(0.3, 1.7, 0.4, 1); }
@keyframes sd-pol-in { from { opacity: 0; } }
@keyframes sd-pol-pop { from { transform: scale(0); } }
.sd-pol__foot { display: flex; justify-content: space-between; margin-top: 12px; font-size: 12px; color: var(--mute); }
.sd-pol__foot button { text-decoration: underline; text-underline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .sd-pol *, .sd-pol *::before, .sd-pol *::after { animation: none !important; transition: none !important; } }Overview
Before voting, options are plain buttons. After you pick, each one becomes a result bar that grows to its percentage with the count rolling up, and your choice keeps a tick.
Change vote is a small link, and the footer shows total votes and time left.
When to use
- Community posts, team check-ins, feedback in feeds.
When not to use
- Formal surveys with several questions.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/poll-card.json$
npx shadcn@latest add https://sundaeui.dev/r/poll-card.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/poll-card.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/poll-card.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy poll-card.tsx and poll-card.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { PollCard } from '@/components/sundae/poll-card'
<PollCard />Props
| Prop | Type | Default |
|---|---|---|
question*Poll question. | string | — |
options*Options with existing votes. | { id: string; label: string; votes: number }[] | — |
onVoteCalled on vote. | (id: string) => void | — |
closesInFooter text such as "2 days left". | string | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Before voting: a radiogroup. After: a list where each row reads “Option, 42 percent, your vote”.
- Results are announced once when they appear.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-pol; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Poll Card free?
- Yes. Poll Card is free for personal and commercial projects, with full source on this page.
- How do I install Poll Card?
- Run npx shadcn@latest add https://sundaeui.dev/r/poll-card.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Poll Card support keyboard and screen readers?
- Before voting: a radiogroup. After: a list where each row reads “Option, 42 percent, your vote”. Results are announced once when they appear.
- Does Poll Card respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Vote Arrows
Components · RefinedFollow Button
Components · RefinedMessage Thread
Components · RefinedShare Sheet
Components · Refined- Pro
Comment Thread
Components · Refined Ranked-Choice Vote
Components · Refined