"use client"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, type ReactNode, } from "react"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; type IslandContextValue = { view: string | null; }; const IslandContext = createContext(null); // Shell physics in Apple's duration/bounce form (per Emil Kowalski's island // lesson): one long perceptual glide with barely-there bounce, identical in // both directions. The shell animates real width/height (not transforms), so // slots are never scale-distorted. const SHELL_SPRING = { type: "spring", duration: 0.8, bounce: 0.2, } as const; // Content gets a touch more life than the shell. const CONTENT_SPRING = { type: "spring", duration: 0.8, bounce: 0.35, } as const; // Constant radius — never animated. The browser clamps it to half the shell // height, so the pill-to-rounded-rect morph falls out of the resize for free // with zero chance of corner glitches. const RADIUS = 32; // iPhone pill dimensions. Also the shell's pre-measure animate target: if the // first commit already has a view active (e.g. a click replayed after // hydration), the shell blooms from the pill instead of rendering expanded // with no animation. Lives in `animate`, not `initial`, so server and client // markup agree. const PILL_WIDTH = 126; const PILL_HEIGHT = 37; /** Tracks the natural size of the content so the shell can spring to it. */ function useContentSize() { const ref = useRef(null); const [size, setSize] = useState<{ width: number; height: number } | null>(null); // Synchronous mount measure: the shell must own explicit dimensions before // the first interaction. ResizeObserver fires async after mount — a quick // first press could beat it, leaving the shell auto-sized so the view // snapped open instead of springing. useLayoutEffect(() => { const el = ref.current; if (!el) return; setSize({ width: el.offsetWidth, height: el.offsetHeight }); }, []); useEffect(() => { const el = ref.current; if (!el || typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(() => { setSize({ width: el.offsetWidth, height: el.offsetHeight }); }); observer.observe(el); return () => observer.disconnect(); }, []); return [ref, size] as const; } function Slot({ keyId, children, className, }: { keyId: string; children: ReactNode; className?: string; }) { const reduce = useReducedMotion(); return ( {children} ); } export interface DynamicIslandProps { /** Active view id. `null` shows the compact pill. */ view: string | null; /** Compact pill content, shown when no view is active. */ compact?: ReactNode; /** DynamicIslandView elements. */ children?: ReactNode; className?: string; } export function DynamicIsland({ view, compact, children, className, }: DynamicIslandProps) { const reduce = useReducedMotion(); const expanded = view !== null; const [sizerRef, size] = useContentSize(); return ( {/* w-max keeps this at the natural size of the active content; the shell springs toward it. */}
{!expanded && compact ? ( {compact} ) : null} {children}
); } export interface DynamicIslandViewProps { /** Matches the parent `view` prop when active. */ id: string; children: ReactNode; className?: string; } export function DynamicIslandView({ id, children, className }: DynamicIslandViewProps) { const ctx = useContext(IslandContext); if (!ctx) throw new Error("DynamicIslandView must be used inside "); const active = ctx.view === id; return ( {active ? ( {children} ) : null} ); }