Ranked-Choice Vote
Rank the options on your ballot, then step through an instant-runoff count as the lowest is eliminated each round.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/ranked-choice.json'use client'
import { useState } from 'react'
import './ranked-choice.css'
const OPTS = ['Lisbon', 'Kyoto', 'Oaxaca', 'Tbilisi']
const POOLS: Record<string, string[][]> = {
close: [['Kyoto', 'Lisbon'], ['Kyoto', 'Tbilisi'], ['Lisbon', 'Oaxaca'], ['Oaxaca', 'Lisbon'], ['Tbilisi', 'Oaxaca', 'Lisbon'], ['Kyoto', 'Lisbon'], ['Lisbon', 'Kyoto'], ['Oaxaca', 'Lisbon'], ['Tbilisi', 'Lisbon']],
clear: [['Kyoto'], ['Kyoto', 'Lisbon'], ['Kyoto', 'Oaxaca'], ['Lisbon', 'Kyoto'], ['Oaxaca', 'Kyoto'], ['Kyoto'], ['Tbilisi', 'Kyoto'], ['Lisbon', 'Oaxaca'], ['Kyoto', 'Tbilisi']],
}
function count(ballots: string[][], opts: string[]) {
const rounds: { tally: Record<string, number>; out: string | null; win: string | null }[] = []
let alive = [...opts]
for (let guard = 0; guard < opts.length; guard++) {
const tally: Record<string, number> = Object.fromEntries(alive.map((o) => [o, 0]))
for (const b of ballots) { const top = b.find((o) => alive.includes(o)); if (top) tally[top]++ }
const live = Object.values(tally).reduce((a, b) => a + b, 0)
const lead = alive.reduce((a, o) => (tally[o] > tally[a] ? o : a), alive[0])
if (tally[lead] * 2 > live || alive.length <= 2) { rounds.push({ tally, out: null, win: lead }); break }
const low = [...alive].reverse().reduce((a, o) => (tally[o] < tally[a] ? o : a), alive[alive.length - 1])
rounds.push({ tally, out: low, win: null }); alive = alive.filter((o) => o !== low)
}
return rounds
}
export interface RankedChoiceProps {
options?: string[]
ballots?: string[][]
fill?: 'dark' | 'light'
className?: string
}
/** Ballot ranking with an animated instant-runoff count. */
export function RankedChoice({ options = OPTS, ballots = POOLS.close, fill = 'dark', className }: RankedChoiceProps) {
const [rank, setRank] = useState<string[]>([])
const [round, setRound] = useState(-1)
const all = [...ballots, ...(rank.length ? [rank] : [])]
const rounds = count(all, options)
const tap = (o: string) => { setRound(-1); setRank(rank.includes(o) ? rank.filter((x) => x !== o) : [...rank, o]) }
const r = round >= 0 ? rounds[Math.min(round, rounds.length - 1)] : null
const outBefore = round >= 0 ? rounds.slice(0, round).map((x) => x.out) : []
const tot = all.length
return (
<div className={['sd-rcv', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-rcv__h">Offsite city<span>{round < 0 ? `${tot} ballots · rank yours` : `Round ${round + 1} of ${rounds.length}`}</span></div>
{round < 0 ? (
<div className="sd-rcv__b">{options.map((o) => { const i = rank.indexOf(o); return <button key={o} type="button" data-r={i >= 0 || undefined} onClick={() => tap(o)} aria-label={`${o}${i >= 0 ? `, ranked ${i + 1}` : ', unranked'}`}><i>{i >= 0 ? i + 1 : ''}</i>{o}</button> })}</div>
) : (
<div className="sd-rcv__r">{options.map((o) => { const gone = outBefore.includes(o); const v = r && !gone ? r.tally[o] ?? 0 : 0; return <div key={o} className="sd-rcv__row" data-out={gone || r?.out === o || undefined}><span>{o}</span><div><i style={{ width: `${(v / tot) * 100}%` }} /></div><b>{gone ? '' : v}</b></div> })}</div>
)}
<div className="sd-rcv__msg" aria-live="polite">{round < 0 ? (rank.length ? `Your ballot: ${rank.join(' > ')}` : 'Tap in order of preference. Majority needed to win.') : r?.win ? <><b>{r.win}</b> wins with {r.tally[r.win]} of {Object.values(r.tally).reduce((a, b) => a + b, 0)} live votes.</> : <><b>{r?.out}</b> is eliminated; its ballots move to their next choice.</>}</div>
<div className="sd-rcv__f">
<button type="button" onClick={() => (round < 0 ? setRank([]) : setRound(-1))}>{round < 0 ? 'Clear' : 'Edit ballot'}</button>
<button type="button" disabled={round >= rounds.length - 1} onClick={() => setRound(round + 1)}>{round < 0 ? 'Run count' : 'Next round'}</button>
</div>
</div>
)
}.sd-rcv { --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-rcv[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-rcv :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-rcv :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-rcv .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-rcv { width: min(100%, 330px); padding: 14px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 10px; }
.sd-rcv__h { font-size: 13px; font-weight: 600; display: flex; justify-content: space-between; align-items: baseline; }
.sd-rcv__h span { font-size: 11px; color: var(--mute); font-weight: 400; }
.sd-rcv__b { display: grid; gap: 5px; }
.sd-rcv__b button { display: grid; grid-template-columns: 26px minmax(0, 1fr); align-items: center; gap: 8px; height: 38px; padding: 0 8px; border-radius: 10px; text-align: left; box-shadow: inset 0 0 0 1px var(--line); font-size: 13px; }
.sd-rcv__b button i { display: grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; box-shadow: inset 0 0 0 1.5px var(--line); font-style: normal; font-size: 11px; font-weight: 700; transition: background-color 150ms; }
.sd-rcv__b button[data-r] i { background: var(--fg); color: var(--bg); box-shadow: none; }
.sd-rcv__r { display: grid; gap: 6px; }
.sd-rcv__row { display: grid; grid-template-columns: 70px minmax(0, 1fr) 22px; gap: 8px; align-items: center; font-size: 12px; }
.sd-rcv__row[data-out] { color: var(--mute); text-decoration: line-through; }
.sd-rcv__row > div { height: 14px; border-radius: 4px; background: var(--soft); position: relative; overflow: hidden; }
.sd-rcv__row > div i { position: absolute; inset: 0 auto 0 0; background: var(--fg); border-radius: 4px; transition: width 450ms cubic-bezier(0.3, 0.8, 0.3, 1); }
.sd-rcv__row[data-out] > div i { background: var(--mute); }
.sd-rcv__row b { text-align: right; font-variant-numeric: tabular-nums; }
.sd-rcv__maj { position: relative; height: 0; }
.sd-rcv__msg { font-size: 12px; color: var(--mute); min-height: 32px; }
.sd-rcv__msg b { color: var(--fg); }
.sd-rcv__f { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.sd-rcv__f button { height: 40px; border-radius: 11px; font-size: 13px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); }
.sd-rcv__f button:last-child { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
@media (prefers-reduced-motion: reduce) { .sd-rcv *, .sd-rcv *::before, .sd-rcv *::after { animation: none !important; transition: none !important; } }Overview
Tap options in order of preference to rank them; tap again to unrank. Your ballot joins a pool of others for the count.
Each round shows first-choice tallies; the last-place option is eliminated and its votes move to each voter’s next choice until one has a majority.
When to use
- Team decisions, community polls, explaining voting systems.
When not to use
- Binding elections: use audited software.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/ranked-choice.json$
npx shadcn@latest add https://sundaeui.dev/r/ranked-choice.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/ranked-choice.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/ranked-choice.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 ranked-choice.tsx and ranked-choice.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 { RankedChoice } from '@/components/sundae/ranked-choice'
<RankedChoice />Props
| Prop | Type | Default |
|---|---|---|
optionsOptions on the ballot. | string[] | — |
ballotsOther ballots in the pool. | string[][] | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Ballot options are buttons that announce their rank.
- Each count round is announced with the eliminated option.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-rcv; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Ranked-Choice Vote free?
- Yes. Ranked-Choice Vote is free for personal and commercial projects, with full source on this page.
- How do I install Ranked-Choice Vote?
- Run npx shadcn@latest add https://sundaeui.dev/r/ranked-choice.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Ranked-Choice Vote support keyboard and screen readers?
- Ballot options are buttons that announce their rank. Each count round is announced with the eliminated option.
- Does Ranked-Choice Vote respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Poll Card
Components · RefinedRoadmap Votes
Components · RefinedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined Avatar Stack
Components · Refined- Pro
Notification Center
Components · Refined