{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "live-poll",
  "type": "registry:component",
  "title": "Live Poll",
  "description": "Write a poll right in the thread, then watch it fill: bars spring to their share as votes land, your pick stays marked, and closing it locks in the winner.",
  "categories": [
    "component",
    "Together"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/button.json"
  ],
  "files": [
    {
      "path": "registry/devl/live-poll.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { CheckIcon, LockIcon, PlusIcon, TrophyIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type FormEvent, useEffect, useId, useRef, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nfunction useReducedMotionAfterMount() {\n  const prefers = useReducedMotion();\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  return mounted && !!prefers;\n}\n\nexport interface PollOption {\n  id: string;\n  label: string;\n  votes: number;\n}\n\nexport interface Poll {\n  question: string;\n  options: PollOption[];\n  author?: string;\n  closed?: boolean;\n}\n\nexport interface LivePollProps {\n  defaultPoll?: Poll;\n  defaultVote?: string | null;\n  simulateCrowd?: boolean;\n  you?: string;\n  onVote?: (optionId: string) => void;\n  onClose?: (poll: Poll) => void;\n  className?: string;\n}\n\nconst MAX_OPTIONS = 5;\nconst ease = [0.23, 1, 0.32, 1] as const;\nlet uid = 0;\nconst nextId = () => `o${++uid}`;\n\nfunction Rolling({ value, suffix = \"\", reduce }: { value: number; suffix?: string; reduce: boolean | null }) {\n  return (\n    <span className=\"relative inline-flex overflow-hidden tabular-nums\">\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          animate={{ y: 0, opacity: 1 }}\n          exit={reduce ? { opacity: 0 } : { y: -8, opacity: 0 }}\n          initial={reduce ? { opacity: 0 } : { y: 8, opacity: 0 }}\n          key={value}\n          transition={{ duration: 0.18, ease }}\n        >\n          {value}\n          {suffix}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nexport function LivePoll({\n  defaultPoll,\n  defaultVote = null,\n  simulateCrowd = false,\n  you = \"You\",\n  onVote,\n  onClose,\n  className,\n}: LivePollProps) {\n  const reduce = useReducedMotionAfterMount();\n  const [poll, setPoll] = useState<Poll | null>(defaultPoll ?? null);\n  const [vote, setVote] = useState<string | null>(defaultVote);\n  const [pulse, setPulse] = useState<string | null>(null);\n  const previous = useRef<{ poll: Poll; vote: string | null } | null>(null);\n\n  useEffect(() => {\n    if (!simulateCrowd || !poll || poll.closed) return;\n    let timer: ReturnType<typeof setTimeout>;\n    const tick = () => {\n      setPoll((p) => {\n        if (!p || p.closed) return p;\n        const weights = p.options.map((o) => o.votes + 2);\n        let r = Math.random() * weights.reduce((a, b) => a + b, 0);\n        const idx = weights.findIndex((w) => (r -= w) < 0);\n        const target = p.options[Math.max(0, idx)];\n        setPulse(target.id);\n        return { ...p, options: p.options.map((o) => (o.id === target.id ? { ...o, votes: o.votes + 1 } : o)) };\n      });\n      timer = setTimeout(tick, 1400 + Math.random() * 1800);\n    };\n    timer = setTimeout(tick, 1600);\n    return () => clearTimeout(timer);\n  }, [simulateCrowd, poll?.closed, poll === null]);\n\n  const castVote = (id: string) => {\n    if (!poll || poll.closed || id === vote) return;\n    setPoll({\n      ...poll,\n      options: poll.options.map((o) =>\n        o.id === id ? { ...o, votes: o.votes + 1 } : o.id === vote ? { ...o, votes: Math.max(0, o.votes - 1) } : o,\n      ),\n    });\n    setVote(id);\n    setPulse(null);\n    onVote?.(id);\n  };\n\n  const close = () => {\n    if (!poll) return;\n    const done = { ...poll, closed: true };\n    setPoll(done);\n    onClose?.(done);\n  };\n\n  return (\n    <section\n      aria-label={poll ? `Poll: ${poll.question}` : \"New poll\"}\n      className={cn(\"w-full max-w-[400px] overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-xs/5\", className)}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {poll ? (\n          <motion.div\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, transition: { duration: 0.1 } }}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}\n            key=\"poll\"\n            transition={{ duration: 0.22, ease }}\n          >\n            <PollView\n              onClose={close}\n              onNew={() => {\n                previous.current = { poll, vote };\n                setPoll(null);\n                setVote(null);\n              }}\n              onVote={castVote}\n              poll={poll}\n              pulse={pulse}\n              reduce={reduce}\n              vote={vote}\n              you={you}\n            />\n          </motion.div>\n        ) : (\n          <motion.div\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, transition: { duration: 0.1 } }}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}\n            key=\"compose\"\n            transition={{ duration: 0.22, ease }}\n          >\n            <Composer\n              onCancel={\n                previous.current\n                  ? () => {\n                      const prev = previous.current;\n                      if (!prev) return;\n                      setPoll(prev.poll);\n                      setVote(prev.vote);\n                    }\n                  : undefined\n              }\n              onPost={(question, labels) => {\n                setVote(null);\n                setPoll({ question, author: you, options: labels.map((label) => ({ id: nextId(), label, votes: 0 })) });\n              }}\n              reduce={reduce}\n            />\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </section>\n  );\n}\n\nfunction PollView({\n  poll,\n  vote,\n  pulse,\n  you,\n  reduce,\n  onVote,\n  onClose,\n  onNew,\n}: {\n  poll: Poll;\n  vote: string | null;\n  pulse: string | null;\n  you: string;\n  reduce: boolean | null;\n  onVote: (id: string) => void;\n  onClose: () => void;\n  onNew: () => void;\n}) {\n  const total = poll.options.reduce((n, o) => n + o.votes, 0);\n  const showResults = vote !== null || !!poll.closed;\n  const top = Math.max(...poll.options.map((o) => o.votes));\n  const mine = poll.author === you;\n  const groupId = useId();\n\n  return (\n    <div>\n      <header className=\"flex items-center gap-2 px-4 pt-4 text-muted-foreground text-xs\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {poll.closed ? (\n            <motion.span\n              animate={{ opacity: 1, scale: 1 }}\n              className=\"flex h-5 items-center gap-1 rounded-full bg-muted px-2 font-medium text-foreground\"\n              initial={{ opacity: 0, scale: 0.9 }}\n              key=\"closed\"\n              transition={{ duration: 0.18, ease }}\n            >\n              <LockIcon className=\"size-3\" />\n              Final results\n            </motion.span>\n          ) : (\n            <motion.span\n              className=\"flex h-5 items-center gap-1.5 rounded-full bg-success/10 px-2 font-medium text-success-foreground\"\n              exit={{ opacity: 0, scale: 0.9 }}\n              key=\"live\"\n            >\n              <span className=\"relative flex size-1.5\">\n                {!reduce && <span className=\"absolute inset-0 animate-ping rounded-full bg-success opacity-60 [animation-duration:1.8s]\" />}\n                <span className=\"relative size-1.5 rounded-full bg-success\" />\n              </span>\n              Live\n            </motion.span>\n          )}\n        </AnimatePresence>\n        <span>{poll.author ? `${poll.author} asked` : \"Poll\"}</span>\n        <span className=\"ms-auto\" aria-live=\"polite\">\n          <Rolling reduce={reduce} value={total} /> {total === 1 ? \"vote\" : \"votes\"}\n        </span>\n      </header>\n\n      <h3 className=\"text-balance px-4 pt-2.5 pb-3 font-medium text-base leading-snug tracking-tight\" id={groupId}>\n        {poll.question}\n      </h3>\n\n      <div aria-labelledby={groupId} className=\"space-y-1.5 px-3\" role=\"radiogroup\">\n        {poll.options.map((o) => {\n          const pct = total ? Math.round((o.votes / total) * 100) : 0;\n          const chosen = vote === o.id;\n          const winner = poll.closed && o.votes === top && top > 0;\n          return (\n            <button\n              aria-checked={chosen}\n              className={cn(\n                \"group/opt relative flex h-11 w-full items-center gap-2.5 overflow-hidden rounded-xl border px-3 text-left text-sm outline-none transition-[border-color,scale] duration-150 focus-visible:ring-2 focus-visible:ring-ring\",\n                poll.closed ? \"cursor-default\" : \"cursor-pointer active:scale-[0.99]\",\n                chosen ? \"border-info/40\" : winner ? \"border-success/40\" : \"border-border\",\n                !poll.closed && !chosen && \"[@media(hover:hover)]:hover:border-foreground/20\",\n              )}\n              disabled={poll.closed}\n              key={o.id}\n              onClick={() => onVote(o.id)}\n              role=\"radio\"\n              type=\"button\"\n            >\n              <motion.span\n                animate={{ clipPath: `inset(0 ${showResults ? 100 - pct : 100}% 0 0 round 13px)` }}\n                aria-hidden\n                className={cn(\n                  \"absolute inset-0 transition-colors duration-300\",\n                  chosen ? \"bg-info/14\" : winner ? \"bg-success/14\" : \"bg-muted\",\n                )}\n                initial={false}\n                transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 220, damping: 28 }}\n              />\n              {pulse === o.id && showResults && !poll.closed && !reduce && (\n                <motion.span\n                  animate={{ opacity: [0, 0.6, 0] }}\n                  aria-hidden\n                  className=\"absolute inset-0 bg-foreground/5\"\n                  key={`${o.id}-${o.votes}`}\n                  transition={{ duration: 0.6, ease: \"easeOut\" }}\n                />\n              )}\n              <span\n                className={cn(\n                  \"relative flex size-4 shrink-0 items-center justify-center rounded-full border transition-colors duration-150\",\n                  chosen ? \"border-info bg-info text-white\" : \"border-foreground/25\",\n                  poll.closed && !chosen && (winner ? \"border-transparent text-success-foreground\" : \"opacity-0\"),\n                )}\n              >\n                {winner && !chosen && <TrophyIcon aria-hidden className=\"size-3.5\" />}\n                <AnimatePresence>\n                  {chosen && (\n                    <motion.span\n                      animate={{ scale: 1, opacity: 1 }}\n                      exit={{ scale: 0.5, opacity: 0 }}\n                      initial={reduce ? false : { scale: 0.5, opacity: 0 }}\n                      transition={{ type: \"spring\", stiffness: 600, damping: 30 }}\n                    >\n                      <CheckIcon className=\"size-2.5\" strokeWidth={3.5} />\n                    </motion.span>\n                  )}\n                </AnimatePresence>\n              </span>\n              <span className={cn(\"relative min-w-0 flex-1 truncate\", (chosen || winner) && \"font-medium\")}>{o.label}\n                {winner && <span className=\"sr-only\">, winner</span>}\n              </span>\n              <AnimatePresence initial={false}>\n                {showResults && (\n                  <motion.span\n                    animate={{ opacity: 1, x: 0 }}\n                    className={cn(\"relative text-xs tabular-nums\", chosen || winner ? \"text-foreground\" : \"text-muted-foreground\")}\n                    initial={reduce ? { opacity: 0 } : { opacity: 0, x: 4 }}\n                    transition={{ duration: 0.2, ease, delay: 0.08 }}\n                  >\n                    <Rolling reduce={reduce} suffix=\"%\" value={pct} />\n                  </motion.span>\n                )}\n              </AnimatePresence>\n            </button>\n          );\n        })}\n      </div>\n\n      <footer className=\"mt-3 flex min-h-12 items-center justify-between gap-2 border-t px-4 py-2.5 text-muted-foreground text-xs\">\n        <span>\n          {poll.closed\n            ? \"Voting has ended\"\n            : vote\n              ? \"You can change your vote until it closes\"\n              : \"Vote to see the results\"}\n        </span>\n        {poll.closed ? (\n          <Button onClick={onNew} size=\"sm\" variant=\"ghost\">\n            <PlusIcon />\n            New poll\n          </Button>\n        ) : (\n          mine && (\n            <Button onClick={onClose} size=\"sm\" variant=\"outline\">\n              <LockIcon />\n              Close poll\n            </Button>\n          )\n        )}\n      </footer>\n    </div>\n  );\n}\n\nfunction Composer({\n  onPost,\n  onCancel,\n  reduce,\n}: {\n  onPost: (question: string, options: string[]) => void;\n  onCancel?: () => void;\n  reduce: boolean | null;\n}) {\n  const qId = useId();\n  const [question, setQuestion] = useState(\"\");\n  const [options, setOptions] = useState(() => [\n    { id: nextId(), label: \"\" },\n    { id: nextId(), label: \"\" },\n  ]);\n  const refs = useRef(new Map<string, HTMLInputElement>());\n  const focusNext = useRef<string | null>(null);\n\n  useEffect(() => {\n    if (!focusNext.current) return;\n    refs.current.get(focusNext.current)?.focus();\n    focusNext.current = null;\n  });\n\n  const filled = options.map((o) => o.label.trim()).filter(Boolean);\n  const ready = question.trim().length > 0 && filled.length >= 2;\n\n  const add = () => {\n    if (options.length >= MAX_OPTIONS) return;\n    const id = nextId();\n    focusNext.current = id;\n    setOptions((os) => [...os, { id, label: \"\" }]);\n  };\n\n  const submit = (e: FormEvent) => {\n    e.preventDefault();\n    if (ready) onPost(question.trim(), filled);\n  };\n\n  return (\n    <form className=\"p-4\" onSubmit={submit}>\n      <div className=\"flex items-center justify-between\">\n        <h3 className=\"font-medium text-sm\">New poll</h3>\n        <span className=\"text-muted-foreground text-xs tabular-nums\">\n          {options.length}/{MAX_OPTIONS} options\n        </span>\n      </div>\n      <label className=\"sr-only\" htmlFor={qId}>\n        Question\n      </label>\n      <input\n        autoComplete=\"off\"\n        className=\"mt-3 h-10 w-full rounded-xl border bg-background px-3 font-medium text-sm outline-none transition-shadow placeholder:font-normal placeholder:text-muted-foreground/72 focus:border-ring focus:ring-[3px] focus:ring-ring/24 dark:bg-input/32\"\n        id={qId}\n        onChange={(e) => setQuestion(e.target.value)}\n        placeholder=\"Ask a question…\"\n        value={question}\n      />\n      <ol className=\"mt-2 space-y-1.5\">\n        <AnimatePresence initial={false}>\n          {options.map((o, i) => (\n            <motion.li\n              animate={{ opacity: 1, height: \"auto\" }}\n              className=\"overflow-hidden\"\n              exit={{ opacity: 0, height: 0 }}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n              key={o.id}\n              transition={{ type: \"spring\", stiffness: 520, damping: 40, opacity: { duration: 0.14 } }}\n            >\n              <div className=\"flex items-center gap-2 pb-px\">\n                <span className=\"w-4 shrink-0 text-center text-muted-foreground text-xs tabular-nums\">{i + 1}</span>\n                <input\n                  aria-label={`Option ${i + 1}`}\n                  autoComplete=\"off\"\n                  className=\"h-9 min-w-0 flex-1 rounded-lg border bg-background px-3 text-sm outline-none transition-shadow placeholder:text-muted-foreground/72 focus:border-ring focus:ring-[3px] focus:ring-ring/24 dark:bg-input/32\"\n                  onChange={(e) => setOptions((os) => os.map((x) => (x.id === o.id ? { ...x, label: e.target.value } : x)))}\n                  onKeyDown={(e) => {\n                    if (e.key === \"Enter\" && i === options.length - 1 && o.label.trim()) {\n                      e.preventDefault();\n                      add();\n                    }\n                  }}\n                  placeholder={i === 0 ? \"First choice\" : i === 1 ? \"Second choice\" : \"Another choice\"}\n                  ref={(el) => {\n                    if (el) refs.current.set(o.id, el);\n                    else refs.current.delete(o.id);\n                  }}\n                  value={o.label}\n                />\n                <button\n                  aria-label={`Remove option ${i + 1}`}\n                  className=\"flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-30\"\n                  disabled={options.length <= 2}\n                  onClick={() => setOptions((os) => os.filter((x) => x.id !== o.id))}\n                  type=\"button\"\n                >\n                  <XIcon className=\"size-3.5\" />\n                </button>\n              </div>\n            </motion.li>\n          ))}\n        </AnimatePresence>\n      </ol>\n      <div className=\"mt-3 flex items-center gap-2\">\n        <Button disabled={options.length >= MAX_OPTIONS} onClick={add} size=\"sm\" variant=\"ghost\">\n          <PlusIcon />\n          Add option\n        </Button>\n        <span className=\"flex-1\" />\n        {onCancel && (\n          <Button onClick={onCancel} size=\"sm\" variant=\"ghost\">\n            Cancel\n          </Button>\n        )}\n        <Button disabled={!ready} size=\"sm\" type=\"submit\">\n          Post poll\n        </Button>\n      </div>\n    </form>\n  );\n}\n"
    }
  ]
}