{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dynamic-island",
  "type": "registry:ui",
  "title": "Dynamic Island",
  "description": "A live-activity pill that springs open into controls and morphs between activities.",
  "categories": [
    "notifications"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/dynamic-island.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type DynamicIslandProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Names the live activity. When it changes, the island morphs to the new content. */\n  activity?: string;\n  /** The resting pill: usually a leading glyph and a trailing value. Nothing renders while it is null. */\n  compact: React.ReactNode;\n  /** What pressing the island reveals: detail and controls. Leave it out and the island is display-only. */\n  detail?: React.ReactNode;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** The accessible name, e.g. \"Upload in progress\". Announced politely when the activity changes. */\n  label: string;\n  /** absolute sits at the top center of a positioned parent; fixed pins it to the viewport; static leaves it in flow. */\n  position?: \"absolute\" | \"fixed\" | \"static\";\n};\n\n// The island is always dark, in both themes, like the hardware it's named after.\n// It re-scopes the tokens to the dark set, so everything inside uses them as usual.\nexport function DynamicIsland({\n  activity = \"default\",\n  compact,\n  detail,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  label,\n  position = \"absolute\",\n  className,\n  ...rest\n}: DynamicIslandProps) {\n  const reduce = useReducedMotion();\n  const [openState, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n  const expandable = detail != null;\n  const open = openState && expandable && compact != null;\n  const id = useId();\n  const island = useRef<HTMLDivElement>(null);\n  const trigger = useRef<HTMLButtonElement>(null);\n  const panel = useRef<HTMLDivElement>(null);\n  const inner = useRef<HTMLDivElement>(null);\n  const [size, setSize] = useState<{ w: number; h: number } | null>(null);\n  const view = `${activity}:${open ? \"open\" : \"compact\"}`;\n  const present = compact != null;\n\n  // Announce a new activity once, not every time its label ticks (\"24:12\", \"24:11\"…).\n  const [spoken, setSpoken] = useState(present ? label : \"\");\n  const [lastActivity, setLastActivity] = useState(present ? activity : null);\n  const current = present ? activity : null;\n  if (current !== lastActivity) {\n    setLastActivity(current);\n    setSpoken(present ? label : \"\");\n  }\n\n  // Measure whatever is showing; the shell springs to it instead of snapping.\n  useLayoutEffect(() => {\n    const el = inner.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => setSize({ w: el.offsetWidth, h: el.offsetHeight }));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [view, present]);\n\n  // Opened: take focus so Escape and Tab work inside. Closed by keyboard: hand focus back to the pill.\n  const wasOpen = useRef(open);\n  useEffect(() => {\n    if (open && !wasOpen.current) panel.current?.focus({ preventScroll: true });\n    if (!open && wasOpen.current && island.current?.contains(document.activeElement)) trigger.current?.focus({ preventScroll: true });\n    wasOpen.current = open;\n  }, [open]);\n\n  // A press anywhere else puts it back.\n  useEffect(() => {\n    if (!open) return;\n    const onDown = (e: PointerEvent) => {\n      if (!island.current?.contains(e.target as Node)) setOpen(false);\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [open, setOpen]);\n\n  const radius = size ? (open ? 26 : size.h / 2) : 999;\n  const shell = reduce ? { duration: 0 } : open ? spring.bouncy : spring.snappy;\n\n  return (\n    <div\n      data-slot=\"dynamic-island\"\n      className={cn(\n        \"flex justify-center @container\",\n        position === \"static\" ? \"relative w-full\" : cn(\"pointer-events-none inset-x-3 top-3 z-(--z-popover)\", position),\n        className,\n      )}\n      {...rest}\n    >\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {spoken}\n      </span>\n      {/* An activity already running at page load is just there; only later arrivals animate in. */}\n      <AnimatePresence initial={false}>\n        {compact != null && (\n          <motion.div\n            key=\"island\"\n            ref={island}\n            data-theme=\"dark\"\n            data-state={open ? \"open\" : \"compact\"}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\", width: size?.w ?? \"auto\", height: size?.h ?? \"auto\", borderRadius: radius }}\n            exit={reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, scale: 0.6, filter: \"blur(4px)\", transition: { duration: 0.2, ease: ease.in } }}\n            transition={{ ...(reduce ? { duration: 0.15 } : spring.snappy), width: shell, height: shell, borderRadius: shell }}\n            onKeyDown={(e) => {\n              if (e.key === \"Escape\" && open) {\n                e.stopPropagation();\n                setOpen(false);\n              }\n            }}\n            className=\"pointer-events-auto relative max-w-full overflow-hidden border border-line-2 bg-page text-fg shadow-pop [color-scheme:dark]\"\n          >\n            <div ref={inner} className=\"w-max max-w-[calc(100cqw-2px)]\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.div\n                  key={view}\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.92, filter: \"blur(4px)\" }}\n                  animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\", transition: { duration: reduce ? 0.12 : 0.26, ease: ease.out, delay: reduce ? 0 : 0.04 } }}\n                  exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.94, filter: \"blur(4px)\", transition: { duration: 0.12, ease: ease.in } }}\n                >\n                  {open ? (\n                    <div\n                      ref={panel}\n                      id={id}\n                      role=\"group\"\n                      aria-label={label}\n                      tabIndex={-1}\n                      className=\"w-[340px] max-w-[calc(100cqw-2px)] p-4 outline-none\"\n                    >\n                      {detail}\n                    </div>\n                  ) : expandable ? (\n                    <motion.button\n                      ref={trigger}\n                      type=\"button\"\n                      aria-expanded={false}\n                      aria-label={label}\n                      onClick={() => setOpen(true)}\n                      whileTap={{ scale: reduce ? 1 : 0.96 }}\n                      transition={spring.snappy}\n                      className=\"flex h-9 min-w-[128px] items-center justify-between gap-3 px-2 text-left outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-3 focus-visible:outline-fg-3 rounded-full\"\n                    >\n                      {compact}\n                    </motion.button>\n                  ) : (\n                    <div role=\"img\" aria-label={label} className=\"flex h-9 min-w-[128px] items-center justify-between gap-3 px-2\">\n                      {compact}\n                    </div>\n                  )}\n                </motion.div>\n              </AnimatePresence>\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nexport type DynamicIslandRingProps = Omit<React.ComponentProps<\"svg\">, \"children\"> & {\n  /** 0 to 1. */\n  value: number;\n  size?: number;\n  /** A color class for the arc, e.g. text-success. Defaults to the foreground. */\n  tone?: string;\n  children?: React.ReactNode;\n};\n\n/** A small progress ring for the compact slots: a timer draining, an upload filling. */\nexport function DynamicIslandRing({ value, size = 20, tone = \"text-fg\", className, children, ...rest }: DynamicIslandRingProps) {\n  const reduce = useReducedMotion();\n  const r = (size - 3) / 2;\n  const c = 2 * Math.PI * r;\n  const v = Math.min(1, Math.max(0, value));\n  return (\n    <span className={cn(\"relative grid shrink-0 place-items-center\", className)} style={{ width: size, height: size }}>\n      <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className=\"-rotate-90\" aria-hidden {...rest}>\n        <circle cx={size / 2} cy={size / 2} r={r} fill=\"none\" stroke=\"currentColor\" strokeWidth={2} className=\"text-line-2\" />\n        <motion.circle\n          cx={size / 2}\n          cy={size / 2}\n          r={r}\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth={2}\n          strokeLinecap=\"round\"\n          strokeDasharray={c}\n          className={tone}\n          initial={false}\n          animate={{ strokeDashoffset: c * (1 - v) }}\n          transition={reduce ? { duration: 0 } : { duration: 0.5, ease: ease.out }}\n        />\n      </svg>\n      {children && <span className=\"absolute inset-0 grid place-items-center\">{children}</span>}\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/dynamic-island"
}
