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