{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"dynamic-island","type":"registry:block","title":"Dynamic Island","description":"iOS-style island pill that morphs between live activity views with bouncy shell resize and blur crossfades.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/dynamic-island.tsx","type":"registry:component","target":"@components/motion/dynamic-island.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  createContext,\n  useContext,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype IslandContextValue = {\n  view: string | null;\n};\n\nconst IslandContext = createContext<IslandContextValue | null>(null);\n\n// Shell physics in Apple's duration/bounce form (per Emil Kowalski's island\n// lesson): one long perceptual glide with barely-there bounce, identical in\n// both directions. The shell animates real width/height (not transforms), so\n// slots are never scale-distorted.\nconst SHELL_SPRING = {\n  type: \"spring\",\n  duration: 0.8,\n  bounce: 0.2,\n} as const;\n\n// Content gets a touch more life than the shell.\nconst CONTENT_SPRING = {\n  type: \"spring\",\n  duration: 0.8,\n  bounce: 0.35,\n} as const;\n\n// Constant radius — never animated. The browser clamps it to half the shell\n// height, so the pill-to-rounded-rect morph falls out of the resize for free\n// with zero chance of corner glitches.\nconst RADIUS = 32;\n\n// iPhone pill dimensions. Also the shell's pre-measure animate target: if the\n// first commit already has a view active (e.g. a click replayed after\n// hydration), the shell blooms from the pill instead of rendering expanded\n// with no animation. Lives in `animate`, not `initial`, so server and client\n// markup agree.\nconst PILL_WIDTH = 126;\nconst PILL_HEIGHT = 37;\n\n/** Tracks the natural size of the content so the shell can spring to it. */\nfunction useContentSize() {\n  const ref = useRef<HTMLDivElement | null>(null);\n  const [size, setSize] = useState<{ width: number; height: number } | null>(null);\n\n  // Synchronous mount measure: the shell must own explicit dimensions before\n  // the first interaction. ResizeObserver fires async after mount — a quick\n  // first press could beat it, leaving the shell auto-sized so the view\n  // snapped open instead of springing.\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    setSize({ width: el.offsetWidth, height: el.offsetHeight });\n  }, []);\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => {\n      setSize({ width: el.offsetWidth, height: el.offsetHeight });\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  return [ref, size] as const;\n}\n\nfunction Slot({\n  keyId,\n  children,\n  className,\n}: {\n  keyId: string;\n  children: ReactNode;\n  className?: string;\n}) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.div\n      key={keyId}\n      initial={\n        reduce\n          ? { opacity: 0, filter: \"blur(0px)\" }\n          : { opacity: 0, scale: 0.9, y: -8, filter: \"blur(5px)\" }\n      }\n      animate={\n        reduce\n          ? { opacity: 1, filter: \"blur(0px)\" }\n          : { opacity: 1, scale: 1, y: 0, filter: \"blur(0px)\" }\n      }\n      // Exit gets sucked up into the pill — fast, blur-free, before the\n      // shrinking shell can clip it.\n      exit={\n        reduce\n          ? { opacity: 0, filter: \"blur(0px)\", transition: { duration: 0.1 } }\n          : {\n              opacity: 0,\n              scale: 0.9,\n              y: -6,\n              filter: \"blur(0px)\",\n              transition: { duration: 0.08, ease: EASE_OUT },\n            }\n      }\n      // One spring drives transform, opacity and blur together — no per\n      // property tweens, no delays. Content travels with the shell.\n      transition={reduce ? { duration: 0.15 } : CONTENT_SPRING}\n      // Anchored to the pill line: content unfurls downward out of it and is\n      // sucked back up into it. will-change pre-promotes the layer so the\n      // first blur/transform pass doesn't rasterize mid-animation.\n      style={{ transformOrigin: \"top center\", willChange: \"transform, opacity, filter\" }}\n      className={cn(\"flex items-center justify-center\", className)}\n    >\n      {children}\n    </motion.div>\n  );\n}\n\nexport interface DynamicIslandProps {\n  /** Active view id. `null` shows the compact pill. */\n  view: string | null;\n  /** Compact pill content, shown when no view is active. */\n  compact?: ReactNode;\n  /** DynamicIslandView elements. */\n  children?: ReactNode;\n  className?: string;\n}\n\nexport function DynamicIsland({\n  view,\n  compact,\n  children,\n  className,\n}: DynamicIslandProps) {\n  const reduce = useReducedMotion();\n  const expanded = view !== null;\n  const [sizerRef, size] = useContentSize();\n\n  return (\n    <IslandContext.Provider value={{ view }}>\n      <motion.div\n        role=\"status\"\n        aria-live=\"polite\"\n        initial={false}\n        animate={\n          size\n            ? { width: size.width, height: size.height }\n            : { width: PILL_WIDTH, height: PILL_HEIGHT }\n        }\n        transition={reduce ? { duration: 0 } : SHELL_SPRING}\n        style={{ borderRadius: RADIUS }}\n        // items-start pins content to the top edge while the shell springs, so\n        // expansion reads as unfurling downward out of the pill. Top-align the\n        // island in its parent (like under a notch) to complete the effect.\n        className={cn(\n          \"relative inline-flex items-start justify-center overflow-hidden\",\n          \"bg-foreground text-background shadow-2xl\",\n          className,\n        )}\n      >\n        {/* w-max keeps this at the natural size of the active content; the\n            shell springs toward it. */}\n        <div ref={sizerRef} className=\"w-max\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            {!expanded && compact ? (\n              <Slot\n                keyId=\"compact\"\n                // iPhone pill proportions: ~126 x 37.\n                className=\"min-h-[37px] min-w-[126px] gap-2 px-4 py-1.5 text-xs font-medium\"\n              >\n                {compact}\n              </Slot>\n            ) : null}\n          </AnimatePresence>\n          {children}\n        </div>\n      </motion.div>\n    </IslandContext.Provider>\n  );\n}\n\nexport interface DynamicIslandViewProps {\n  /** Matches the parent `view` prop when active. */\n  id: string;\n  children: ReactNode;\n  className?: string;\n}\n\nexport function DynamicIslandView({ id, children, className }: DynamicIslandViewProps) {\n  const ctx = useContext(IslandContext);\n  if (!ctx) throw new Error(\"DynamicIslandView must be used inside <DynamicIsland>\");\n  const active = ctx.view === id;\n\n  return (\n    <AnimatePresence mode=\"popLayout\" initial={false}>\n      {active ? (\n        <Slot keyId={id} className={cn(\"px-6 py-4\", className)}>\n          {children}\n        </Slot>\n      ) : null}\n    </AnimatePresence>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}