Poll Vote
Casting a vote fills every bar to its share and the percentages arrive behind them.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Poll Vote
*
* Before the vote a poll is a list of choices; after it, a chart. The
* option you picked fills first and the rest follow a beat later — the
* order is the acknowledgement, and it lands before any number does.
*
* Bars grow with scaleX from the leading edge, so the whole reveal
* stays on the compositor, and every label sits outside its bar: no
* element containing text is ever scaled.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the card reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `question`, `options`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type PollOption = {
id: string;
label: string;
/** Votes already cast for this option. */
votes: number;
};
export type PollVoteFillProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
question?: string;
options?: PollOption[];
/** Shown under the results. */
closesIn?: string;
/** Fires with the id of the chosen option. */
onVote?: (id: string) => void;
};
type VariantConfig = {
/** How long a bar takes to reach its share. */
fillSeconds: number;
/** Delay before the options you did not pick begin. */
followDelay: number;
/** px the percentage travels as it arrives. */
numberTravel: number;
};
// A tween, never a spring: a bar that overshoots its share is a bar
// showing a number that is not true, even for 120ms. Variants change
// pace and the size of the beat between the chosen bar and the rest.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Quick and level. For a poll embedded in a dense feed.
subtle: { fillSeconds: 0.4, followDelay: 0.05, numberTravel: 3 },
// A readable beat between your choice and the rest. All-purpose.
default: { fillSeconds: 0.55, followDelay: 0.1, numberTravel: 5 },
// A longer fill and a wider beat, for a poll that is the whole card.
playful: { fillSeconds: 0.7, followDelay: 0.16, numberTravel: 7 },
};
const ACCENT = "#7C7CF0";
const EASE = [0.22, 0.68, 0, 1] as const;
const OPTIONS: PollOption[] = [
{ id: "tue", label: "Tuesday morning", votes: 412 },
{ id: "wed", label: "Wednesday afternoon", votes: 268 },
{ id: "thu", label: "Thursday, any time", votes: 521 },
];
/** Theme-adaptive neutral: mixing the text color in scope with
* `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function PollVoteFill({
variant = "default",
question = "When should we run the team sync?",
options = OPTIONS,
closesIn = "2 days left",
onVote,
}: PollVoteFillProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [choice, setChoice] = useState<string | null>(null);
const totals = options.map(
(option) => option.votes + (choice === option.id ? 1 : 0)
);
const sum = totals.reduce((running, votes) => running + votes, 0);
const voted = choice !== null;
const cast = (id: string) => {
if (voted) return;
setChoice(id);
onVote?.(id);
};
return (
<div
style={{
width: 310,
padding: 18,
borderRadius: 16,
border: `1px solid ${tone(11)}`,
background: tone(4),
fontSize: 13.5,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 28,
height: 28,
borderRadius: "50%",
background: "#4AA3B8",
color: "#ffffff",
fontSize: 11,
fontWeight: 650,
}}
>
ID
</span>
<div style={{ fontSize: 12, opacity: 0.55 }}>Ines Duarte asked</div>
</div>
<div style={{ margin: "10px 0 14px", fontSize: 14.5, fontWeight: 600, lineHeight: 1.4 }}>
{question}
</div>
<div
role="group"
aria-label={question}
style={{ display: "flex", flexDirection: "column", gap: 8 }}
>
{options.map((option, index) => {
const share = voted ? totals[index] / sum : 0;
const chosen = choice === option.id;
const delay = !voted || reduceMotion ? 0 : chosen ? 0 : cfg.followDelay;
const percent = Math.round(share * 100);
return (
<button
key={option.id}
type="button"
onClick={() => cast(option.id)}
disabled={voted}
aria-pressed={chosen}
style={{
position: "relative",
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
padding: "11px 13px",
borderRadius: 11,
border: `1px solid ${chosen ? ACCENT : tone(13)}`,
background: "none",
color: "inherit",
fontFamily: "inherit",
fontSize: 13.5,
textAlign: "left",
cursor: voted ? "default" : "pointer",
overflow: "hidden",
}}
>
{/* The bar lives behind the label rather than around it,
so scaling it never touches a glyph. */}
<motion.span
aria-hidden
initial={{ scaleX: 0 }}
animate={{ scaleX: share }}
transition={{
duration: reduceMotion ? 0 : cfg.fillSeconds,
ease: EASE,
delay,
}}
style={{
position: "absolute",
inset: 0,
transformOrigin: "left center",
background: chosen
? `color-mix(in srgb, ${ACCENT} 32%, transparent)`
: tone(9),
}}
/>
<span style={{ position: "relative", flex: 1, fontWeight: chosen ? 600 : 400 }}>
{option.label}
</span>
{/* Both slots are reserved from the start, so revealing
them never reflows the label beside them. */}
<span
style={{
position: "relative",
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<motion.span
aria-hidden
initial={false}
animate={{ opacity: chosen ? 1 : 0, scale: chosen ? 1 : 0.7 }}
transition={{
duration: reduceMotion ? 0.12 : 0.22,
ease: "easeOut",
delay: chosen && !reduceMotion ? cfg.fillSeconds * 0.3 : 0,
}}
style={{
display: "grid",
placeItems: "center",
width: 15,
height: 15,
color: ACCENT,
}}
>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none">
<path
d="M3.2 8.3 6.4 11.5 12.8 4.6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</motion.span>
{/* The percentage is text: it fades and travels a few
pixels, and it never scales. */}
<motion.span
aria-hidden
initial={false}
animate={{
opacity: voted ? 0.85 : 0,
y: voted || reduceMotion ? 0 : cfg.numberTravel,
}}
transition={{
duration: reduceMotion ? 0.14 : 0.26,
ease: "easeOut",
delay: voted && !reduceMotion ? delay + cfg.fillSeconds * 0.45 : 0,
}}
style={{
minWidth: 30,
fontSize: 12.5,
fontWeight: 600,
fontVariantNumeric: "tabular-nums",
textAlign: "right",
}}
>
{percent}%
</motion.span>
</span>
</button>
);
})}
</div>
<div
role="status"
style={{ marginTop: 12, fontSize: 11.5, opacity: 0.5 }}
>
{voted
? `${sum.toLocaleString()} votes · ${closesIn}`
: `Pick one · ${closesIn}`}
</div>
</div>
);
}About this pattern
Before the vote a poll is a list of choices; after it, it is a chart. The transition has to do both jobs at once, so the option you picked fills first and the others follow a beat later — the order is the acknowledgement, and it lands before any number does. Bars grow with scaleX from the leading edge, which keeps the whole reveal on the compositor, and the labels sit outside the bar so nothing containing text is ever scaled.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Chat thread
The chosen option is acknowledged before the totals are read out.