{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"call-widget","type":"registry:component","title":"Call widget","description":"Accept turns into hang-up as the call controls unfold out of it, one by one.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/call-widget.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 { usePreviewPlay } from \"@/lib/preview-play\";\nimport { cn } from \"@/lib/utils\";\n\nexport type CallPhase = \"incoming\" | \"active\" | \"declined\" | \"ended\";\n\n// Accept is green the world over, so it's the one colour here that isn't a\n// theme token: a convention, not decoration.\nconst ANSWER = \"light-dark(oklch(0.62 0.16 150), oklch(0.66 0.16 150))\";\n\nconst MORPH = { type: \"spring\", duration: 0.45, bounce: 0.1 } as const;\nconst FADE = { duration: 0.2, ease: [0.23, 1, 0.32, 1] } as const;\nconst ICON_SWAP = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n// One button's width plus the gap, so a control can start tucked exactly\n// behind the one to its right.\nconst STEP = 52;\n\n// The ring spreads and fades like sound leaving a phone; the wiggle is a\n// short buzz with a long rest, so it nudges without nagging.\nconst CALL_CSS = `\n@keyframes call-ring { from { transform: scale(1); opacity: .6 } to { transform: scale(1.7); opacity: 0 } }\n@keyframes call-wiggle { 0%, 70%, 100% { transform: rotate(0) } 74% { transform: rotate(-14deg) } 78% { transform: rotate(12deg) } 82% { transform: rotate(-9deg) } 86% { transform: rotate(6deg) } 90% { transform: rotate(0) } }\n`;\n\nconst clock = (s: number) =>\n  `${String(Math.floor(s / 60)).padStart(2, \"0\")}:${String(s % 60).padStart(2, \"0\")}`;\n\nexport function CallWidget({\n  name,\n  avatar,\n  kind = \"video\",\n  phase,\n  onPhaseChange,\n  muted,\n  onMutedChange,\n  cameraOff,\n  onCameraOffChange,\n  sharing,\n  onSharingChange,\n  seconds,\n  className,\n}: {\n  name: string;\n  avatar: string;\n  kind?: \"video\" | \"voice\";\n  phase: CallPhase;\n  onPhaseChange: (phase: CallPhase) => void;\n  muted: boolean;\n  onMutedChange: (muted: boolean) => void;\n  cameraOff: boolean;\n  onCameraOffChange: (off: boolean) => void;\n  sharing: boolean;\n  onSharingChange: (sharing: boolean) => void;\n  // Time in the call, owned by whoever runs the call.\n  seconds: number;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const ringing = phase === \"incoming\";\n  const inCall = phase === \"active\";\n  const over = !ringing && !inCall;\n  const transition = reduceMotion ? { duration: 0 } : MORPH;\n\n  const subtitle = ringing\n    ? `Incoming ${kind} call`\n    : inCall\n      ? clock(seconds)\n      : phase === \"declined\"\n        ? \"Call declined\"\n        : `Call ended · ${clock(seconds)}`;\n\n  const toggles = [\n    { key: \"mic\", label: \"Mute\", on: muted, set: onMutedChange, icon: <MicIcon off={muted} /> },\n    ...(kind === \"video\"\n      ? [{ key: \"cam\", label: \"Turn camera off\", on: cameraOff, set: onCameraOffChange, icon: <VideoIcon off={cameraOff} /> }]\n      : []),\n    { key: \"share\", label: \"Share screen\", on: sharing, set: onSharingChange, icon: <ShareIcon /> },\n  ];\n\n  return (\n    <motion.div\n      layout\n      transition={transition}\n      style={{ borderRadius: 28 }}\n      className={cn(\n        \"flex w-fit max-w-full items-center gap-3 bg-background p-2 shadow-raised\",\n        className,\n      )}\n    >\n      <style>{CALL_CSS}</style>\n      {/* The caller stays in place through every phase; only what's around\n          them changes. */}\n      <motion.span\n        layout=\"position\"\n        transition={transition}\n        className=\"relative size-11 shrink-0\"\n      >\n        {ringing && !reduceMotion && (\n          <>\n            <span className=\"absolute inset-0 animate-[call-ring_2s_ease-out_infinite] rounded-full border border-foreground/25\" />\n            <span className=\"absolute inset-0 animate-[call-ring_2s_ease-out_1s_infinite] rounded-full border border-foreground/25\" />\n          </>\n        )}\n        {/* eslint-disable-next-line @next/next/no-img-element -- a tiny local SVG */}\n        <img\n          src={avatar}\n          alt=\"\"\n          className={cn(\n            \"relative size-11 rounded-full bg-[oklch(0.97_0_0)] transition-[opacity,filter] duration-200 ease-out\",\n            over && \"opacity-50 grayscale\",\n          )}\n        />\n      </motion.span>\n\n      <motion.span\n        layout=\"position\"\n        transition={transition}\n        className=\"flex min-w-0 flex-1 flex-col pr-1\"\n      >\n        <span className=\"truncate text-[15px] font-medium text-foreground\">\n          {name}\n        </span>\n        <span className=\"grid text-[13px] text-muted-foreground tabular-nums\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={ringing ? \"ring\" : inCall ? \"call\" : phase}\n              initial={{ opacity: 0, y: 3, filter: \"blur(3px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, filter: \"blur(2px)\", transition: { duration: 0.1 } }}\n              transition={FADE}\n              className=\"col-start-1 row-start-1 truncate whitespace-nowrap\"\n            >\n              {subtitle}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </motion.span>\n\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {ringing ? `${name} is calling` : subtitle}\n      </span>\n\n      <div className=\"ml-1 flex shrink-0 items-center gap-2\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {/* Decline bows out where it stands: it isn't the one you chose. */}\n          {ringing && (\n            <motion.button\n              key=\"decline\"\n              type=\"button\"\n              aria-label=\"Decline\"\n              onClick={() => onPhaseChange(\"declined\")}\n              initial={{ opacity: 0, scale: 0.6, filter: \"blur(4px)\" }}\n              animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, scale: 0.6, filter: \"blur(4px)\", transition: { duration: 0.15 } }}\n              transition={FADE}\n              className=\"grid size-11 touch-manipulation place-items-center rounded-full bg-destructive text-white outline-hidden transition-[scale,background-color] duration-150 ease-out select-none hover:bg-[color-mix(in_oklab,var(--destructive)_88%,white)] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96]\"\n            >\n              <PhoneIcon down />\n            </motion.button>\n          )}\n          {/* The controls unfold out of the End button: each starts tucked\n              behind it and slides out to its place, the nearest first. */}\n          {inCall &&\n            toggles.map((t, i) => {\n              const behind = (toggles.length - i) * STEP;\n              return (\n                <motion.button\n                  key={t.key}\n                  type=\"button\"\n                  aria-label={t.label}\n                  aria-pressed={t.on}\n                  onClick={() => t.set(!t.on)}\n                  initial={reduceMotion ? { opacity: 0 } : { x: behind, opacity: 0, scale: 0.8 }}\n                  animate={{ x: 0, opacity: 1, scale: 1 }}\n                  // Leaving, they fade where they stand: the pill is\n                  // shrinking at the same time, so sliding back would carry\n                  // them out past its edge as ghosts.\n                  exit={{\n                    opacity: 0,\n                    scale: reduceMotion ? 1 : 0.8,\n                    filter: \"blur(3px)\",\n                    transition: { duration: 0.12 },\n                  }}\n                  transition={{\n                    ...MORPH,\n                    delay: reduceMotion ? 0 : (toggles.length - 1 - i) * 0.05 + 0.08,\n                  }}\n                  // Switched on means something is held back (muted, camera\n                  // off), so it inverts: the odd one out is the one to notice.\n                  className={cn(\n                    \"grid size-11 touch-manipulation place-items-center rounded-full outline-hidden transition-[scale,background-color,color] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96]\",\n                    t.on\n                      ? \"bg-foreground text-background\"\n                      : \"bg-muted text-foreground hover:bg-foreground/10\",\n                  )}\n                >\n                  {t.icon}\n                </motion.button>\n              );\n            })}\n        </AnimatePresence>\n\n        {/* One button throughout: Accept becomes End becomes Call again. It\n            never leaves the spot you clicked, it only changes what it is:\n            green to red as the handset tips over to hang up, then quiet. */}\n        <motion.button\n          layout\n          transition={transition}\n          type=\"button\"\n          aria-label={ringing ? \"Accept\" : inCall ? \"End call\" : undefined}\n          onClick={() => onPhaseChange(ringing ? \"active\" : inCall ? \"ended\" : \"incoming\")}\n          style={{ borderRadius: 999 }}\n          className={cn(\n            \"group/call relative z-10 grid h-11 touch-manipulation place-items-center overflow-hidden bg-muted outline-hidden transition-[scale,color] duration-200 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96]\",\n            over ? \"px-4 text-foreground\" : \"w-11 text-white\",\n            inCall && \"w-14\",\n          )}\n        >\n          {/* Colours never cross-fade: half-transparent red over green reads\n              as brown, and over grey as pink. The red wipes in instead, a\n              circle filling out from the centre, and drains back into it on\n              hang-up. The green underneath waits until it's covered. */}\n          <span\n            aria-hidden\n            className={cn(\n              \"absolute inset-0 transition-opacity ease-out\",\n              ringing ? \"opacity-100 duration-200\" : \"opacity-0 delay-300 duration-0\",\n            )}\n            style={{ background: ANSWER }}\n          />\n          <span\n            aria-hidden\n            // Fills generously, drains fast: the red is gone before the next\n            // label arrives, so the two never share the button.\n            className={cn(\n              \"absolute inset-0 bg-destructive transition-[clip-path] motion-reduce:transition-none\",\n              inCall\n                ? \"duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]\"\n                : \"duration-150 ease-[cubic-bezier(0.4,0,1,1)]\",\n            )}\n            style={{\n              clipPath: inCall\n                ? \"circle(75% at 50% 50%)\"\n                : \"circle(0% at 50% 50%)\",\n            }}\n          />\n          {/* Hover lightens with a veil rather than a filter, which can\n              leave square corners on a button that is changing size. */}\n          <span\n            aria-hidden\n            className=\"absolute inset-0 bg-white/0 transition-[background-color] duration-150 ease-out group-hover/call:bg-white/12\"\n          />\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={over ? \"again\" : \"phone\"}\n              layout=\"position\"\n              initial={{ opacity: 0, scale: 0.25, filter: \"blur(4px)\" }}\n              animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, scale: 0.25, filter: \"blur(4px)\", transition: { duration: 0.12 } }}\n              // \"Call again\" waits for the red to drain first.\n              transition={{ ...ICON_SWAP, delay: over && !reduceMotion ? 0.14 : 0 }}\n              className=\"relative grid place-items-center\"\n            >\n              {over ? (\n                <span className=\"text-sm font-medium whitespace-nowrap\">Call again</span>\n              ) : (\n                <motion.span\n                  className=\"grid\"\n                  initial={false}\n                  // The handset rotates over rather than swapping icons, so\n                  // accepting reads as picking up and hanging up as putting\n                  // it down.\n                  animate={{ rotate: inCall ? 135 : 0 }}\n                  transition={reduceMotion ? { duration: 0 } : MORPH}\n                >\n                  <span\n                    className={cn(\n                      \"grid\",\n                      ringing && !reduceMotion && \"animate-[call-wiggle_2.4s_ease-in-out_infinite]\",\n                    )}\n                  >\n                    <PhoneIcon />\n                  </span>\n                </motion.span>\n              )}\n            </motion.span>\n          </AnimatePresence>\n        </motion.button>\n      </div>\n    </motion.div>\n  );\n}\n\nconst STROKE = {\n  viewBox: \"0 0 24 24\",\n  fill: \"none\",\n  stroke: \"currentColor\",\n  strokeWidth: 1.75,\n  strokeLinecap: \"round\",\n  strokeLinejoin: \"round\",\n  className: \"size-5\",\n  \"aria-hidden\": true,\n} as const;\n\n// A slash that draws across the icon as it switches off, and back.\nfunction Slash({ on }: { on: boolean }) {\n  const reduceMotion = useReducedMotion();\n  return (\n    <motion.path\n      d=\"M4 4l16 16\"\n      initial={false}\n      animate={{ pathLength: on ? 1 : 0, opacity: on ? 1 : 0 }}\n      transition={reduceMotion ? { duration: 0 } : ICON_SWAP}\n    />\n  );\n}\n\nfunction MicIcon({ off = false }: { off?: boolean }) {\n  return (\n    <svg {...STROKE}>\n      <rect x=\"9\" y=\"3\" width=\"6\" height=\"11\" rx=\"3\" />\n      <path d=\"M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21\" />\n      <Slash on={off} />\n    </svg>\n  );\n}\n\nfunction VideoIcon({ off = false }: { off?: boolean }) {\n  return (\n    <svg {...STROKE}>\n      <rect x=\"3\" y=\"6.5\" width=\"12.5\" height=\"11\" rx=\"2.5\" />\n      <path d=\"m15.5 10.5 5-3v9l-5-3\" />\n      <Slash on={off} />\n    </svg>\n  );\n}\n\nfunction ShareIcon() {\n  return (\n    <svg {...STROKE}>\n      <rect x=\"3\" y=\"4.5\" width=\"18\" height=\"12.5\" rx=\"2.5\" />\n      <path d=\"M8.5 21h7M12 13.5v-5M9.5 10.5 12 8l2.5 2.5\" />\n    </svg>\n  );\n}\n\nfunction PhoneIcon({ down = false }: { down?: boolean }) {\n  return (\n    <svg\n      {...STROKE}\n      // The same handset, tipped over to hang up.\n      style={down ? { transform: \"rotate(135deg)\" } : undefined}\n    >\n      <path d=\"M6.6 3.5h2.6l1.3 4.1-2 1.3a11 11 0 0 0 6.6 6.6l1.3-2 4.1 1.3v2.6a2 2 0 0 1-2.2 2A16.5 16.5 0 0 1 4.6 5.7a2 2 0 0 1 2-2.2Z\" />\n    </svg>\n  );\n}\n\nexport default function CallWidgetDemo() {\n  const play = usePreviewPlay();\n  const [phase, setPhase] = useState<CallPhase>(\"incoming\");\n  const [muted, setMuted] = useState(false);\n  const [cameraOff, setCameraOff] = useState(false);\n  const [sharing, setSharing] = useState(false);\n  const [seconds, setSeconds] = useState(0);\n  const started = useRef(0);\n\n  // The call clock runs only while a call is live, and only a tick a second.\n  useEffect(() => {\n    if (phase !== \"active\") return;\n    const id = setInterval(\n      () => setSeconds(Math.floor((performance.now() - started.current) / 1000)),\n      1000,\n    );\n    return () => clearInterval(id);\n  }, [phase]);\n\n  const change = (next: CallPhase) => {\n    if (next === \"active\") {\n      started.current = performance.now();\n      setSeconds(0);\n      setMuted(false);\n      setCameraOff(false);\n      setSharing(false);\n    }\n    setPhase(next);\n  };\n\n  // In an index card, hovering answers, mutes for a moment and hangs up.\n  const changeRef = useRef(change);\n  useEffect(() => {\n    changeRef.current = change;\n  });\n  useEffect(() => {\n    if (play !== true) return;\n    const steps: [() => void, number][] = [\n      [() => changeRef.current(\"active\"), 700],\n      [() => setMuted(true), 1500],\n      [() => setMuted(false), 900],\n      [() => changeRef.current(\"ended\"), 1300],\n      [() => changeRef.current(\"incoming\"), 1400],\n    ];\n    let i = 0;\n    let timer: ReturnType<typeof setTimeout>;\n    const next = () => {\n      const [run, wait] = steps[i % steps.length];\n      i++;\n      timer = setTimeout(() => {\n        run();\n        next();\n      }, wait);\n    };\n    next();\n    return () => {\n      clearTimeout(timer);\n      changeRef.current(\"incoming\");\n    };\n  }, [play]);\n\n  return (\n    // Centred in every phase; the widget grows evenly both ways as the\n    // controls unfold, so the button you pressed only drifts half the width.\n    <div className=\"flex w-[460px] max-w-full justify-center\">\n      <CallWidget\n        name=\"Maya Chen\"\n        avatar=\"/avatars/cara.svg\"\n        phase={phase}\n        onPhaseChange={change}\n        muted={muted}\n        onMutedChange={setMuted}\n        cameraOff={cameraOff}\n        onCameraOffChange={setCameraOff}\n        sharing={sharing}\n        onSharingChange={setSharing}\n        seconds={seconds}\n      />\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/call-widget"}