{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"live-indicator","type":"registry:component","title":"Live indicator","description":"Breathes while live, lets the viewer count drift, and blinks hollow while reconnecting.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/live-indicator.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n\n// Loops are keyframes by nature (nothing interrupts them), and CSS keeps\n// them off the main thread. Slow on purpose: a live dot should breathe,\n// not flash. The reconnect blink is a hard-ish pulse so it reads as trying.\nconst KEYFRAMES = `\n@keyframes live-indicator-breathe {\n  0%, 100% { opacity: 1; scale: 1; }\n  50% { opacity: 0.7; scale: 0.88; }\n}\n@keyframes live-indicator-sonar {\n  0% { opacity: 0.45; scale: 1; }\n  70%, 100% { opacity: 0; scale: 2.6; }\n}\n@keyframes live-indicator-blink {\n  0%, 100% { opacity: 1; }\n  50% { opacity: 0.25; }\n}\n`;\n\n// Locale independent, so the server and the browser print the same thing.\nfunction group(n: number) {\n  return String(n).replace(/\\B(?=(\\d{3})+(?!\\d))/g, \",\");\n}\n\n// Real audiences drift: mostly small steps, a slight lean toward growth,\n// and now and then a bigger jump when a link gets shared.\nfunction nextCount(n: number) {\n  const r = Math.random();\n  const size =\n    r < 0.08\n      ? 8 + Math.floor(Math.random() * 12)\n      : 1 + Math.floor(Math.random() * 4);\n  const sign = Math.random() < 0.56 ? 1 : -1;\n  return Math.max(1, n + sign * size);\n}\n\n/** Viewer count that wanders like a real stream's, paused while offscreen. */\nexport function useViewerCount(start: number, paused: boolean) {\n  const [count, setCount] = useState(start);\n  const [visible, setVisible] = useState(true);\n  const node = useRef<HTMLElement | null>(null);\n\n  useEffect(() => {\n    const el = node.current;\n    // A paused count has nothing to wake up for, so it doesn't watch.\n    if (paused || !el) return;\n    const observer = new IntersectionObserver(([entry]) =>\n      setVisible(entry.isIntersecting),\n    );\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [paused]);\n\n  useEffect(() => {\n    if (paused || !visible) return;\n    let timer: ReturnType<typeof setTimeout>;\n    // Irregular gaps: a count ticking on a metronome looks fake.\n    const tick = () => {\n      timer = setTimeout(\n        () => {\n          if (document.visibilityState === \"visible\") setCount(nextCount);\n          tick();\n        },\n        900 + Math.random() * 1800,\n      );\n    };\n    tick();\n    return () => clearTimeout(timer);\n  }, [paused, visible]);\n\n  return [count, node] as const;\n}\n\nexport function LiveIndicator({\n  viewers,\n  reconnecting = false,\n  label = \"Live\",\n  className,\n  ref,\n}: {\n  viewers: number;\n  reconnecting?: boolean;\n  label?: string;\n  className?: string;\n  ref?: React.Ref<HTMLSpanElement>;\n}) {\n  const reduceMotion = useReducedMotion();\n  const [prev, setPrev] = useState(viewers);\n  const [direction, setDirection] = useState(1);\n  if (prev !== viewers) {\n    setDirection(viewers > prev ? 1 : -1);\n    setPrev(viewers);\n  }\n  const custom = { direction, reduceMotion };\n  const chars = group(viewers).split(\"\");\n  const status = reconnecting ? \"Reconnecting\" : label;\n\n  return (\n    <span\n      ref={ref}\n      className={cn(\n        \"relative inline-flex h-7 items-center gap-2 rounded-full bg-muted pr-3 pl-2.5 text-[13px] font-medium text-foreground shadow-raised select-none\",\n        className,\n      )}\n    >\n      <style>{KEYFRAMES}</style>\n      {/* The accessible text never includes the ticking number, so screen\n          readers hear the state, not a stream of digits. */}\n      <span className=\"sr-only\">\n        {reconnecting\n          ? \"Reconnecting\"\n          : `${label}, about ${group(viewers)} watching`}\n      </span>\n\n      <span aria-hidden className=\"relative grid size-2 place-items-center\">\n        {/* Sonar: one soft ring every 2.4s, only while actually live. */}\n        {!reconnecting && !reduceMotion && (\n          <span className=\"absolute inset-0 rounded-full bg-destructive [animation:live-indicator-sonar_2.4s_cubic-bezier(0.23,1,0.32,1)_infinite]\" />\n        )}\n        <span\n          className={cn(\n            \"relative size-2 rounded-full border-[1.5px] transition-[background-color,border-color] duration-200 ease-out\",\n            reconnecting\n              ? \"border-muted-foreground bg-transparent motion-safe:[animation:live-indicator-blink_1s_ease-in-out_infinite]\"\n              : \"border-destructive bg-destructive motion-safe:[animation:live-indicator-breathe_2.4s_ease-in-out_infinite]\",\n          )}\n        />\n      </span>\n\n      <span aria-hidden className=\"grid\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={status}\n            className=\"col-start-1 row-start-1 whitespace-nowrap\"\n            initial={{\n              opacity: 0,\n              filter: reduceMotion ? \"blur(0px)\" : \"blur(4px)\",\n            }}\n            animate={{\n              opacity: 1,\n              filter: \"blur(0px)\",\n              transition: { duration: 0.22, ease: EASE_OUT },\n            }}\n            exit={{\n              opacity: 0,\n              filter: reduceMotion ? \"blur(0px)\" : \"blur(4px)\",\n              transition: { duration: 0.12, ease: EASE_OUT },\n            }}\n          >\n            {status}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n\n      <span aria-hidden className=\"h-3 w-px bg-border\" />\n\n      <span\n        aria-hidden\n        className={cn(\n          \"flex items-center gap-1 text-muted-foreground tabular-nums transition-opacity duration-200 ease-out\",\n          // A frozen count is stale, so it steps back while reconnecting.\n          reconnecting && \"opacity-50\",\n        )}\n      >\n        <svg\n          viewBox=\"0 0 16 16\"\n          className=\"size-3.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth={1.4}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        >\n          <path d=\"M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8Z\" />\n          <circle cx=\"8\" cy=\"8\" r=\"2\" />\n        </svg>\n        <span className=\"flex\">\n          {chars.map((char, i) => (\n            // Keyed by place from the right, so only the digits that\n            // change roll.\n            <span\n              key={chars.length - i}\n              className=\"inline-grid overflow-hidden\"\n            >\n              <AnimatePresence initial={false} custom={custom}>\n                <motion.span\n                  key={char}\n                  custom={custom}\n                  variants={ROLL}\n                  initial=\"enter\"\n                  animate=\"center\"\n                  exit=\"exit\"\n                  className=\"col-start-1 row-start-1\"\n                >\n                  {char}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n          ))}\n        </span>\n      </span>\n    </span>\n  );\n}\n\ntype Roll = { direction: number; reduceMotion: boolean | null };\n\n// Slower than a click's roll: nobody caused this change, so it should\n// drift past rather than snap.\nconst ROLL = {\n  enter: ({ direction, reduceMotion }: Roll) => ({\n    y: reduceMotion ? \"0%\" : `${direction * 100}%`,\n    opacity: 0,\n  }),\n  center: {\n    y: \"0%\",\n    opacity: 1,\n    transition: { duration: 0.3, ease: EASE_OUT },\n  },\n  exit: ({ direction, reduceMotion }: Roll) => ({\n    y: reduceMotion ? \"0%\" : `${direction * -100}%`,\n    opacity: 0,\n    transition: { duration: 0.22, ease: EASE_OUT },\n  }),\n};\n\n// The index card's hover show: the count drifts for a moment, the stream\n// drops, tries to reconnect, and comes back. Waits in ms.\nconst SHOW_DROP = 3200;\nconst SHOW_RESTORE = 2200;\n\nexport default function LiveIndicatorDemo() {\n  const [reconnecting, setReconnecting] = useState(false);\n  const play = usePreviewPlay();\n  // A resting index card keeps its CSS breath (off the main thread) but\n  // stops the count's JS timer until someone hovers.\n  const still = play === false;\n  const [viewers, ref] = useViewerCount(1284, reconnecting || still);\n\n  useEffect(() => {\n    if (play !== true) return;\n    let timer: ReturnType<typeof setTimeout>;\n    const cycle = () => {\n      timer = setTimeout(() => {\n        setReconnecting(true);\n        timer = setTimeout(() => {\n          setReconnecting(false);\n          cycle();\n        }, SHOW_RESTORE);\n      }, SHOW_DROP);\n    };\n    cycle();\n    return () => {\n      clearTimeout(timer);\n      setReconnecting(false);\n    };\n  }, [play]);\n\n  return (\n    <div className=\"relative flex flex-col items-center gap-5\">\n      <LiveIndicator ref={ref} viewers={viewers} reconnecting={reconnecting} />\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {reconnecting ? \"Connection lost, reconnecting\" : \"\"}\n      </span>\n      <button\n        type=\"button\"\n        aria-pressed={reconnecting}\n        onClick={() => setReconnecting((r) => !r)}\n        className=\"h-8 touch-manipulation rounded-full px-3 text-[13px] text-muted-foreground outline-hidden transition-[scale,color,background-color] duration-150 ease-out select-none hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-[color,background-color]\"\n      >\n        {/* Both labels share a cell so the button never changes width. */}\n        <span className=\"grid\">\n          <span\n            className={cn(\n              \"col-start-1 row-start-1\",\n              reconnecting && \"invisible\",\n            )}\n          >\n            Drop connection\n          </span>\n          <span\n            className={cn(\n              \"col-start-1 row-start-1\",\n              !reconnecting && \"invisible\",\n            )}\n          >\n            Restore connection\n          </span>\n        </span>\n      </button>\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"},{"path":"lib/preview-play.ts","type":"registry:lib","target":"lib/preview-play.ts","content":"\"use client\";\n\nimport { createContext, useContext } from \"react\";\n\n// Lets a demo know whether it's showing in an index card, and whether that\n// card is hovered or focused. Demos use it to act themselves out on hover:\n// a star button stars itself, a checkbox scribbles, and so on.\n//\n// null: not in a preview (the component's own page, or real use).\n// false: in a preview, at rest.\n// true: in a preview, being hovered or focused: play the show.\nexport const PreviewPlayContext = createContext<boolean | null>(null);\n\nexport function usePreviewPlay() {\n  return useContext(PreviewPlayContext);\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["feedback"],"docs":"From ui lab: https://lab.xevrion.dev/lab/live-indicator"}