{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"ring-pill","type":"registry:ui","title":"Ring Pill","description":"An incoming-call pill with pulsing rings that expands into accept and decline, then morphs into a live call timer.","dependencies":["motion","lucide-react"],"registryDependencies":[],"files":[{"path":"registry/ui/ring-pill.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { AnimatePresence, MotionConfig, motion } from \"motion/react\";\nimport { Phone, PhoneOff } from \"lucide-react\";\n\nexport interface RingPillProps {\n  /** Caller name. */\n  name?: string;\n  /** Line under the name while ringing. */\n  subtitle?: string;\n  /** Optional avatar image URL; initials are used otherwise. */\n  avatar?: string;\n  /** Accept button and live-call accent color. */\n  acceptColor?: string;\n  /** Decline / hang-up color. */\n  declineColor?: string;\n  /** Ms after the call ends before the chip rings again. 0 keeps the ended state. */\n  resetDelay?: number;\n  onAccept?: () => void;\n  onDecline?: () => void;\n  /** Called with the call length in seconds when hung up. */\n  onEnd?: (seconds: number) => void;\n  className?: string;\n}\n\ntype Phase = \"ringing\" | \"open\" | \"live\" | \"ended\" | \"declined\";\n\nconst css = `\n@keyframes call-chip-ring{from{transform:scale(1);opacity:.55}to{transform:scale(1.7);opacity:0}}\n@keyframes call-chip-wiggle{0%,55%,100%{transform:rotate(0)}60%,70%,80%,90%{transform:rotate(-16deg)}65%,75%,85%,95%{transform:rotate(16deg)}}\n@keyframes call-chip-bar{0%,100%{transform:scaleY(.35)}50%{transform:scaleY(1)}}\n@media (prefers-reduced-motion:reduce){.call-chip-anim{animation:none!important}}`;\n\nconst spring = { type: \"spring\", stiffness: 420, damping: 34 } as const;\nconst swap = {\n  initial: { opacity: 0, scale: 0.9, filter: \"blur(6px)\" },\n  animate: { opacity: 1, scale: 1, filter: \"blur(0px)\" },\n  exit: { opacity: 0, scale: 0.9, filter: \"blur(6px)\" },\n  transition: { duration: 0.22 },\n};\n\nconst fmt = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, \"0\")}`;\n\nexport function RingPill({\n  name = \"Maya Chen\",\n  subtitle = \"Incoming call\",\n  avatar,\n  acceptColor = \"#22c55e\",\n  declineColor = \"#ef4444\",\n  resetDelay = 2500,\n  onAccept,\n  onDecline,\n  onEnd,\n  className,\n}: RingPillProps) {\n  const [phase, setPhase] = useState<Phase>(\"ringing\");\n  const [secs, setSecs] = useState(0);\n  const acceptRef = useRef<HTMLButtonElement>(null);\n  const endRef = useRef<HTMLButtonElement>(null);\n  const chipRef = useRef<HTMLButtonElement>(null);\n  const hadFocus = useRef(false);\n\n  useEffect(() => {\n    if (phase !== \"live\") return;\n    const id = window.setInterval(() => setSecs((s) => s + 1), 1000);\n    return () => window.clearInterval(id);\n  }, [phase]);\n\n  useEffect(() => {\n    if ((phase !== \"ended\" && phase !== \"declined\") || !resetDelay) return;\n    const id = window.setTimeout(() => setPhase(\"ringing\"), resetDelay);\n    return () => window.clearTimeout(id);\n  }, [phase, resetDelay]);\n\n  // Keep keyboard focus inside the chip as it morphs between states.\n  useEffect(() => {\n    if (!hadFocus.current) return;\n    if (phase === \"open\") acceptRef.current?.focus();\n    if (phase === \"live\") endRef.current?.focus();\n    if (phase === \"ringing\") chipRef.current?.focus();\n  }, [phase]);\n\n  const initials = name\n    .split(/\\s+/)\n    .map((w) => w[0])\n    .join(\"\")\n    .slice(0, 2)\n    .toUpperCase();\n\n  const face = (size: number, rings: boolean) => (\n    <span className=\"relative grid shrink-0 place-items-center\" style={{ width: size, height: size }}>\n      {rings &&\n        [0, 0.7].map((d) => (\n          <span\n            key={d}\n            aria-hidden\n            className=\"call-chip-anim absolute inset-0 rounded-full\"\n            style={{ background: acceptColor, animation: `call-chip-ring 1.4s ${d}s cubic-bezier(.2,.6,.4,1) infinite` }}\n          />\n        ))}\n      <span\n        aria-hidden\n        className=\"relative grid size-full place-items-center overflow-hidden rounded-full bg-gradient-to-br from-violet-400 to-fuchsia-600 font-semibold text-white\"\n        style={{ fontSize: size * 0.36 }}\n      >\n        {avatar ? <span className=\"size-full bg-cover bg-center\" style={{ backgroundImage: `url(${avatar})` }} /> : initials}\n      </span>\n    </span>\n  );\n\n  const round = \"grid size-11 place-items-center rounded-full text-white outline-none transition-[scale,filter] duration-150 hover:brightness-110 active:scale-90 focus-visible:ring-[3px] focus-visible:ring-ring/70 focus-visible:ring-offset-2 focus-visible:ring-offset-card\";\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <style href=\"ring-pill\" precedence=\"default\">\n        {css}\n      </style>\n      <motion.div\n        layout\n        transition={spring}\n        role=\"group\"\n        aria-label={`Call from ${name}`}\n        onFocusCapture={() => (hadFocus.current = true)}\n        onBlurCapture={(e) => {\n          if (!e.currentTarget.contains(e.relatedTarget as Node | null)) hadFocus.current = false;\n        }}\n        className={`relative border border-border bg-card text-foreground shadow-[0_18px_50px_-18px_rgb(0_0_0/.7)] ${className ?? \"\"}`}\n        style={{ borderRadius: phase === \"open\" ? 28 : 999 }}\n      >\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {phase === \"ringing\" && (\n            <motion.button\n              key=\"ringing\"\n              ref={chipRef}\n              type=\"button\"\n              aria-expanded={false}\n              aria-label={`${subtitle} from ${name}. Show options`}\n              onClick={() => setPhase(\"open\")}\n              {...swap}\n              whileTap={{ scale: 0.96 }}\n              className=\"flex items-center gap-3 rounded-full py-1.5 pl-1.5 pr-4 outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/60\"\n            >\n              {face(32, true)}\n              <span className=\"text-sm font-medium\">{name}</span>\n              <Phone\n                aria-hidden\n                className=\"call-chip-anim size-4\"\n                style={{ color: acceptColor, animation: \"call-chip-wiggle 1.6s ease-in-out infinite\" }}\n              />\n            </motion.button>\n          )}\n\n          {phase === \"open\" && (\n            <motion.div key=\"open\" {...swap} className=\"flex w-[21rem] items-center gap-3 p-3\">\n              {face(48, true)}\n              <div className=\"min-w-0 flex-1\">\n                <p className=\"truncate font-semibold\">{name}</p>\n                <p className=\"truncate text-xs text-muted-foreground\">{subtitle}…</p>\n              </div>\n              <motion.button\n                type=\"button\"\n                aria-label=\"Decline\"\n                onClick={() => {\n                  setPhase(\"declined\");\n                  onDecline?.();\n                }}\n                initial={{ scale: 0, rotate: -90 }}\n                animate={{ scale: 1, rotate: 0 }}\n                transition={{ type: \"spring\", stiffness: 500, damping: 16, delay: 0.06 }}\n                className={round}\n                style={{ background: declineColor }}\n              >\n                <PhoneOff className=\"size-5\" />\n              </motion.button>\n              <motion.button\n                ref={acceptRef}\n                type=\"button\"\n                aria-label=\"Accept\"\n                onClick={() => {\n                  setSecs(0);\n                  setPhase(\"live\");\n                  onAccept?.();\n                }}\n                initial={{ scale: 0, rotate: 90 }}\n                animate={{ scale: 1, rotate: 0 }}\n                transition={{ type: \"spring\", stiffness: 500, damping: 16, delay: 0.12 }}\n                className={round}\n                style={{ background: acceptColor, boxShadow: `0 0 0 0 ${acceptColor}` }}\n              >\n                <Phone className=\"call-chip-anim size-5\" style={{ animation: \"call-chip-wiggle 1.6s ease-in-out infinite\" }} />\n              </motion.button>\n            </motion.div>\n          )}\n\n          {phase === \"live\" && (\n            <motion.div key=\"live\" {...swap} className=\"flex items-center gap-3 py-1.5 pl-1.5 pr-1.5\">\n              <span className=\"relative\">\n                {face(32, false)}\n                <span\n                  aria-hidden\n                  className=\"absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-card\"\n                  style={{ background: acceptColor }}\n                />\n              </span>\n              <span className=\"grid leading-tight\">\n                <span className=\"text-sm font-medium\">{name}</span>\n                <span className=\"text-xs tabular-nums\" style={{ color: acceptColor }} role=\"timer\" aria-label={`Call time ${fmt(secs)}`}>\n                  {fmt(secs)}\n                </span>\n              </span>\n              <span aria-hidden className=\"ml-2 flex h-4 items-center gap-[3px]\">\n                {[0.9, 0.6, 1.1, 0.75].map((d, i) => (\n                  <span\n                    key={i}\n                    className=\"call-chip-anim h-full w-[3px] origin-center rounded-full\"\n                    style={{ background: acceptColor, animation: `call-chip-bar ${d}s ${i * 0.12}s ease-in-out infinite` }}\n                  />\n                ))}\n              </span>\n              <motion.button\n                ref={endRef}\n                type=\"button\"\n                aria-label=\"Hang up\"\n                onClick={() => {\n                  setPhase(\"ended\");\n                  onEnd?.(secs);\n                }}\n                initial={{ scale: 0 }}\n                animate={{ scale: 1 }}\n                transition={{ type: \"spring\", stiffness: 500, damping: 16, delay: 0.1 }}\n                className={`${round} ml-1 size-9`}\n                style={{ background: declineColor }}\n              >\n                <PhoneOff className=\"size-4\" />\n              </motion.button>\n            </motion.div>\n          )}\n\n          {(phase === \"ended\" || phase === \"declined\") && (\n            <motion.div key=\"ended\" {...swap} className=\"flex items-center gap-2 px-4 py-2.5 text-sm\">\n              <PhoneOff aria-hidden className=\"size-4\" style={{ color: declineColor }} />\n              <span>{phase === \"ended\" ? `Call ended · ${fmt(secs)}` : \"Declined\"}</span>\n            </motion.div>\n          )}\n        </AnimatePresence>\n        <span className=\"sr-only\" aria-live=\"polite\">\n          {phase === \"live\" ? `Connected to ${name}` : phase === \"ended\" ? \"Call ended\" : phase === \"declined\" ? \"Call declined\" : \"\"}\n        </span>\n      </motion.div>\n    </MotionConfig>\n  );\n}\n"}]}