Components

Getting started

Blocks13

Components154

More

A poll whose bars race to their share: vote, and every option fills with light while the percentages roll and live votes keep nudging them.

Used forCommunitiesRoadmap votingSocial postsEvents and classes

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/poll.json

Templates

Need the whole page?

43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 43 templates

Where it’s used

  • Communities

    Polls in a forum, Discord-style community or member area.

  • Roadmap voting

    Letting customers vote on what gets built next.

  • Social posts

    A poll attached to a post or a story.

  • Events and classes

    Quick questions during a live session.

What it handles for you

A poll is usually buttons that become a chart, with the vote count updated on refresh. This block keeps one surface from the question to live results.

Instead of wiring upvote buttonsa results charta vote countera change-vote flowa voters list
  1. Results that arrive

    Your vote fills every row to its share in turn and rolls the percentages up from zero.

  2. Instant, then confirmed

    The bars move on the tap; if onVote throws they go back and the reason shows.

  3. Live without jumps

    New counts from your data glide the bars and roll the numbers, never redraw them.

  4. Change your mind

    With allowChange, Change vote lets you tap another option and both rows move at once.

  5. A clock

    closesAt shows the days or hours left, then Final results and no more voting.

  6. Who voted

    A few voters’ faces and “Iris, Theo and 1,281 others” under the card.

Overview

Most polls swap the buttons for a static bar chart the moment you vote. Here the question and its options sit on a lit card as inset rows. Tap one and every row fills with light to its share, one after another, while the percentages roll up from zero; your pick is ringed with a drawn-in check and the leader is set in full weight. As other votes arrive the bars glide and the numbers roll, and changing your vote moves both rows at once. The bars answer your tap straight away and go back with the reason if your server says no. Under the card a few voters’ faces, the count and the time left. The votes are yours: options with counts and the visitor’s vote in, onVote out.

States

ask
The lit card with the source, the vote count and time left, the question, and the options as inset rows with an empty ring.
reveal
Tapping fills each row with light to its share (900ms, 70ms apart) and rolls its percent from 0; your row is ringed and a check folds in; the leader is in full weight.
live
New counts glide the bars (700ms) and roll the numbers and the total.
change
Change vote (it morphs to Keep my vote) makes the other rows tappable; picking one moves both bars at once.
error
If onVote throws, the bars go back and the reason replaces the line under the card.
closed
After closesAt the results show with “Final results” and no row can be tapped.

Usage

usage.tsx
import { Poll } from "@/components/poll";<Poll  question="What should we build next?"  options={options}          // { id, label, votes }  vote={myVote}  onVote={(id) => api.vote(pollId, id)}/>

Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.

Works with

With your own API

Load the counts and the visitor’s vote, post the vote, and refresh; check again now and then (or over a socket) for live results.

example.tsx
"use client";import { useEffect, useState } from "react";import { Poll, type PollOption } from "@/components/poll";type State = { options: PollOption[]; vote: string | null };export function RoadmapPoll({ id, initial }: { id: string; initial: State }) {  const [poll, setPoll] = useState(initial);  const load = async () => setPoll(await (await fetch(`/api/polls/${id}`)).json());  useEffect(() => {    const timer = setInterval(load, 10000);    return () => clearInterval(timer);  }, [id]);  return (    <Poll      question="What should we build next?"      options={poll.options}      vote={poll.vote}      onVote={async (option) => {        const res = await fetch(`/api/polls/${id}/vote`, { method: "POST", body: JSON.stringify({ option }) });        if (!res.ok) throw new Error((await res.json()).message);        await load();      }}    />  );}

Props

PropTypeDescription
questionstringThe question on the card.
optionsPollOption[]{ id, label, votes }; new counts glide the bars.
votestring | nullThe visitor’s vote from your data; with one, the results show.
onVote(optionId) => Promise<unknown>Record a vote or a changed one; throw an Error to put the bars back and show why.
closesAtDate | string | numberShows the time left; after it the results are final.
voters{ id, name, src? }[]A few recent voters, shown as faces with the count.
allowChange / source / localeboolean / string / stringdefault true / "Poll" / browserWhether votes can change, who’s asking, and number formatting.

Accessibility and motion

  • Before voting the options are a radio group of “Vote for …” buttons; after, each is named with its share and whether it’s your vote.
  • Your vote is announced from a status region; live changes aren’t announced, so a busy poll never chatters.
  • The fill is a scaled layer behind the text, so labels never move and the bars stay composited.
  • Its light comes from your theme: the card mixes --primary-foreground into --primary, the rows are inset 1px edges and the fill is the same light at two strengths. No drop shadows, glow or blur.
  • Built from Number roll and Text morph, which the CLI installs with it. No animation library; with reduced motion the bars and numbers change in place.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Number roll, Text morph.

Source

components/poll.tsx
"use client";import * as React from "react";import { Check, Vote } from "lucide-react";import { NumberRoll } from "./number-roll";import { TextMorph } from "./text-morph";import { EASE_THROW, INTERACTION_FOCUS, INTERACTION_PRESS, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * POLL: the bars race to their share * *   ask       the lit card holds the question and its options as *             inset rows; tap one to vote *   reveal    every row fills with light to its share, one after *             another, while the percentages roll up from zero; *             your pick is ringed with a check and the leader's *             share is set in full weight *   live      as votes come in the bars glide and the numbers roll; *             change your vote and both rows move at once *   closed    after closesAt the results are final * * The votes are yours: pass options with their counts and the * visitor's vote from your database, and record it in onVote. * The bars move on your vote straight away; throw an Error and * they go back and say why. * ───────────────────────────────────────────────────────── */export interface PollOption {  id: string;  label: string;  votes: number;}export interface PollVoter {  id: string;  name: string;  src?: string;}export interface PollProps {  question: string;  options: PollOption[];  /** The visitor's vote, if they've voted. */  vote?: string | null;  /** Record a vote (or a changed one); throw an Error to say why not. */  onVote: (optionId: string) => Promise<unknown>;  /** After this the results are final. */  closesAt?: Date | string | number;  /** A few recent voters, shown as faces. */  voters?: PollVoter[];  /** Let people change their vote. */  allowChange?: boolean;  /** Who's asking, e.g. "Relay community". */  source?: string;  locale?: string;  className?: string;}const EASE = "cubic-bezier(0.16,1,0.3,1)";const HOUR = 3_600_000;const NO_VOTERS: PollVoter[] = [];const subscribeNothing = () => () => {};const useHydrated = () => React.useSyncExternalStore(subscribeNothing, () => true, () => false);const mix = (color: string, amount: number, into = "transparent") => `color-mix(in oklab, ${color} ${amount}%, ${into})`;const LIT: React.CSSProperties = {  backgroundColor: "var(--primary)",  backgroundImage: `radial-gradient(120% 80% at 0% 0%, ${mix("var(--primary-foreground)", 22)} 0%, transparent 55%), linear-gradient(175deg, transparent 40%, ${mix("black", 24)} 100%)`,  boxShadow: `inset 0 1px 0 ${mix("var(--primary-foreground)", 36)}, inset 0 0 0 1px ${mix("var(--primary-foreground)", 12)}`,};const CARD: React.CSSProperties = {  backgroundImage: `linear-gradient(180deg, var(--card), ${mix("var(--card)", 95, "var(--foreground)")})`,  boxShadow: `0 0 0 1px var(--border), inset 0 1px 0 ${mix("var(--background)", 85)}`,};const ROW: React.CSSProperties = { background: mix("black", 14), boxShadow: `inset 0 1px 1px ${mix("black", 12)}, inset 0 0 0 1px ${mix("var(--primary-foreground)", 10)}` };function tint(seed: string): React.CSSProperties {  let h = 0;  for (const c of seed) h = (h * 31 + c.charCodeAt(0)) >>> 0;  const chart = `var(--chart-${(h % 5) + 1}, var(--primary))`;  return { background: mix(chart, 20, "var(--card)"), color: mix(chart, 75, "var(--foreground)") };}const initials = (name: string) =>  name    .split(/\s+/)    .filter(Boolean)    .slice(0, 2)    .map((w) => w[0]?.toUpperCase())    .join("");export function Poll({ question, options, vote: voteProp, onVote, closesAt, voters = NO_VOTERS, allowChange = true, source, locale, className = "" }: PollProps) {  const reduced = useInteractionReducedMotion();  const hydrated = useHydrated();  const baseId = React.useId();  const [now, setNow] = React.useState<number | null>(null);  // The vote on screen: yours as soon as you tap, until your server agrees (or refuses).  const [mine, setMine] = React.useState<{ id: string | null; pending: boolean } | null>(null);  const [error, setError] = React.useState<string | null>(null);  const [changing, setChanging] = React.useState(false);  const [said, setSaid] = React.useState("");  React.useEffect(() => {    const tick = () => setNow(Date.now());    const frame = requestAnimationFrame(tick);    const timer = window.setInterval(tick, 60_000);    return () => {      cancelAnimationFrame(frame);      window.clearInterval(timer);    };  }, []);  const server = voteProp ?? null;  const vote = mine ? mine.id : server;  const closes = closesAt !== undefined ? new Date(closesAt).getTime() : null;  const closed = closes !== null && now !== null && now >= closes;  const showing = vote !== null || closed;  // The first reveal races the bars in one after another; later changes all move at once.  const firstReveal = server === null && mine !== null;  // Counts with your vote moved in straight away, so the bars answer the tap.  const counts = options.map((o) => o.votes + (mine && mine.id !== server ? (o.id === mine.id ? 1 : 0) - (o.id === server ? 1 : 0) : 0));  const total = counts.reduce((a, b) => a + b, 0);  const top = Math.max(...counts);  const left = closes === null || now === null ? null : closes - now;  const timeLeft = closed ? "Final results" : left === null ? "" : left > 48 * HOUR ? `${Math.round(left / (24 * HOUR))} days left` : left > HOUR ? `${Math.round(left / HOUR)} hours left` : "Closing soon";  const cast = async (id: string) => {    if (closed || (mine?.pending ?? false)) return;    if (vote === id && !changing) return;    setChanging(false);    setError(null);    setMine({ id, pending: true });    try {      await onVote(id);      setMine({ id, pending: false });      setSaid(`Voted for ${options.find((o) => o.id === id)?.label}.`);    } catch (e) {      setMine(null);      setError(e instanceof Error && e.message ? e.message : "Couldn’t record your vote. Try again.");    }  };  // Your vote reached your data: hand the display back to it.  if (mine && !mine.pending && server === mine.id) setMine(null);  const fmt = new Intl.NumberFormat(locale ?? (hydrated ? undefined : "en-US"));  const shown = voters.slice(0, 3);  const others = Math.max(0, total - shown.length);  return (    <section aria-labelledby={`${baseId}-q`} className={`w-full max-w-[400px] rounded-[28px] p-2 text-card-foreground ${className}`} style={CARD}>      {/* Its natural width where the parent sizes to its content, without forcing it on narrow screens. */}      <div aria-hidden="true" data-natural-width className="grid h-0" style={{ gridTemplateColumns: "minmax(0, 400px)" }} />      <div className="@container w-full">        {/* The one lit surface: the question and its options, which fill to their share. */}        <div className="rounded-[22px] p-4 text-primary-foreground" style={LIT}>          <div className="flex items-center justify-between gap-3 text-[12px] text-primary-foreground/70">            <span className="inline-flex min-w-0 items-center gap-1.5 truncate">              <Vote aria-hidden="true" className="size-3.5 shrink-0" />              {source ?? "Poll"}            </span>            <span className="shrink-0 tabular-nums">              <NumberRoll value={total} locales={locale} duration={700} /> {total === 1 ? "vote" : "votes"}              {timeLeft ? ` · ${timeLeft}` : ""}            </span>          </div>          <h2 id={`${baseId}-q`} className="mt-2 text-[17px] font-medium leading-snug tracking-[-0.015em]">            {question}          </h2>          <ul className="mt-3 space-y-1.5" role={showing ? "list" : "radiogroup"} aria-labelledby={`${baseId}-q`}>            {options.map((o, i) => {              const share = total ? counts[i] / total : 0;              const pct = Math.round(share * 100);              const yours = vote === o.id;              const leads = showing && counts[i] === top && top > 0;              return (                <li key={o.id}>                  <button                    type="button"                    role={showing ? undefined : "radio"}                    aria-checked={showing ? undefined : false}                    aria-label={showing ? `${o.label}, ${pct}%${yours ? ", your vote" : changing ? ". Change your vote to this" : ""}` : `Vote for ${o.label}`}                    disabled={closed || (mine?.pending ?? false) || (showing && (!changing || yours))}                    onClick={() => void cast(o.id)}                    className={`relative flex h-11 w-full cursor-pointer items-center gap-2 overflow-clip rounded-[14px] px-3.5 text-left text-[14px] disabled:cursor-default ${INTERACTION_PRESS} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-foreground/70 ${!showing || (changing && !yours) ? "hover:brightness-110" : ""}`}                    style={{ ...ROW, boxShadow: yours && showing ? `inset 0 0 0 1.5px var(--primary-foreground)` : ROW.boxShadow, transition: reduced ? "none" : `box-shadow 320ms ${EASE_THROW}` }}                  >                    {/* The share, as light filling the row. */}                    <span                      aria-hidden="true"                      className="absolute inset-y-0 left-0 w-full origin-left"                      style={{                        background: mix("var(--primary-foreground)", yours ? 34 : 18),                        transform: `scaleX(${showing ? share : 0})`,                        transition: reduced ? "none" : `transform ${firstReveal ? 900 : 700}ms ${EASE} ${firstReveal ? i * 70 : 0}ms, background-color 300ms`,                      }}                    />                    <span className={`relative min-w-0 flex-1 truncate ${leads ? "font-medium" : ""}`}>{o.label}</span>                    <span className="relative flex shrink-0 items-center gap-1.5 tabular-nums">                      <span                        className="grid"                        style={{ gridTemplateColumns: yours ? "1fr" : "0fr", transition: reduced ? "none" : `grid-template-columns 320ms ${EASE}` }}                      >                        <span className="min-w-0 overflow-hidden">                          <Check aria-hidden="true" className="size-4" strokeWidth={2.5} />                        </span>                      </span>                      <span className={leads ? "font-medium" : "text-primary-foreground/85"} style={{ opacity: showing ? 1 : 0, transition: "opacity 300ms" }}>                        <NumberRoll value={showing ? pct : 0} duration={900} />%                      </span>                      {!showing && <span aria-hidden="true" className="absolute right-0 size-[18px] rounded-full" style={{ boxShadow: `inset 0 0 0 1.5px ${mix("var(--primary-foreground)", 55)}` }} />}                    </span>                  </button>                </li>              );            })}          </ul>        </div>        {/* Flat: who voted, and changing your mind. */}        <div className="flex min-h-11 items-center justify-between gap-3 px-3 pb-1 pt-2">          <span className="flex min-w-0 items-center gap-2">            {shown.length > 0 && (              <span aria-hidden="true" className="flex shrink-0 -space-x-1.5">                {shown.map((v) => (                  <span key={v.id} className="relative flex size-6 items-center justify-center overflow-hidden rounded-full text-[9.5px] font-medium ring-2 ring-card" style={tint(v.name)}>                    {initials(v.name)}                    {v.src && (                      // eslint-disable-next-line @next/next/no-img-element                      <img src={v.src} alt="" className="absolute inset-0 size-full object-cover" />                    )}                  </span>                ))}              </span>            )}            <span className="truncate text-[12.5px] text-muted-foreground">              {error ? (                <span role="alert" className="text-red-600 dark:text-red-400">                  {error}                </span>              ) : shown.length ? (                `${shown.map((v) => v.name.split(" ")[0]).join(", ")}${others ? ` and ${fmt.format(others)} others` : ""}`              ) : changing ? (                "Tap another option to move your vote"              ) : showing ? (                "Results update as votes come in"              ) : (                "Vote to see the results"              )}            </span>          </span>          {allowChange && !closed && vote !== null && (            <button              type="button"              onClick={() => setChanging((c) => !c)}              disabled={mine?.pending ?? false}              className={`h-8 shrink-0 cursor-pointer rounded-full px-3 text-[12.5px] text-foreground hover:bg-accent ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}            >              <TextMorph>{changing ? "Keep my vote" : "Change vote"}</TextMorph>            </button>          )}        </div>        <span className="sr-only" role="status">          {said}        </span>      </div>    </section>  );}export default Poll;
components/ui-interaction-motion.ts
'use client';import * as React from 'react';export const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';export const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';/** Thrown things (indicators, thumbs): a slight overshoot on transform only. */export const EASE_THROW = 'cubic-bezier(0.34, 1.36, 0.64, 1)';/** * The press every button primitive shares: a 3% give that eases in and settles back. * Tailwind v4's scale-* writes the `scale` property, so `scale` has to be in the list * or the press snaps. */export const INTERACTION_PRESS =  'transition-[background-color,color,box-shadow,opacity,scale] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] active:scale-[0.97] motion-reduce:transition-none motion-reduce:active:scale-100';export const INTERACTION_FOCUS ='focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';const query = '(prefers-reduced-motion: reduce)';const subscribe = (notify: () => void) => {  const media = window.matchMedia(query);  media.addEventListener('change', notify);  return () => media.removeEventListener('change', notify);};export const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);export function useInteractionActivity<T extends Element>() {  const ref = React.useRef<T>(null);  const [visible, setVisible] = React.useState(false);  const [hidden, setHidden] = React.useState(false);  React.useEffect(() => {    const el = ref.current;    if (!el) return;    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));    observer.observe(el);    const visibility = () => setHidden(document.hidden);    visibility(); document.addEventListener('visibilitychange', visibility);    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };  }, []);  return { ref, active: visible && !hidden };}export function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };}/** Sample recent movement rather than averaging a long drag and losing its release velocity. */export type PointerSample = { x: number; time: number };export function samplePointer(samples: PointerSample[], x: number, time: number) {  samples.push({ x, time });  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();}export function releaseVelocity(samples: PointerSample[], now: number) {  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;  const first = samples[0], last = samples[samples.length - 1];  return (last.x - first.x) / Math.max(1, last.time - first.time);}/** Smooth Apple/rational rubber-band curve: continuous exponential-like resistance without linear kinks. */export function rubberBand(overshoot: number, dimension = 320, constant = 0.55): number {  const d = dimension || 320;  const abs = Math.abs(overshoot);  return Math.sign(overshoot) * (1 - 1 / ((abs * constant) / d + 1)) * d;}/** Projects resting travel from release velocity (velocity in px/ms; time in seconds). */export function projectInertia(distance: number, velocity: number, time = 0.12): number {  return distance + velocity * 1000 * time;}/** Smooths incoming release velocity toward a boundary so springs don't slam or clip into walls. */export function softenVelocity(velocity: number, distanceToTarget: number, factor = 10): number {  const maxAllowed = Math.abs(distanceToTarget) * factor;  return Math.sign(velocity) * Math.min(Math.abs(velocity), maxAllowed);}/** A damped spring sampled by WAAPI: tuned for organic lightness with a hint of life (3% breath). */export function springFrames(  from: number,  to: number,  velocity = 0,  duration = 480,  config: { omega?: number; damping?: number; steps?: number } = {},) {  const { omega = 16, damping = 0.74, steps = 36 } = config;  const damped = omega * Math.sqrt(Math.max(0.001, 1 - damping * damping));  const displacement = from - to;  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;  return Array.from({ length: steps + 1 }, (_, i) => {    const t = (i / steps) * (duration / 1000);    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));    return { value: i === steps ? to : value, offset: i / steps };  });}

Registry item: /r/poll.json