"use client"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { createContext, useContext, useEffect, 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, Apple style: expansion blooms out of the pill with a visible // overshoot, and collapse returns with the same life — the pill squeezes a // touch past its size and springs back. The shell animates real width/height // (not transforms), so slots are never scale-distorted. const EXPAND_SPRING = { type: "spring", stiffness: 550, damping: 25, mass: 0.5, } as const; const COLLAPSE_SPRING = { type: "spring", stiffness: 520, damping: 24, mass: 0.45, } as const; // Content pops from the pill core just after the shell starts moving. const CONTENT_SPRING = { type: "spring", stiffness: 560, damping: 28, mass: 0.5, } as const; // Real radii, tweened separately from the size spring. Springing between a // fake huge radius and a small one makes corners glitch mid-resize; these two // values are close (18.5 is exactly half the 37px pill) so the corner shape // stays stable throughout. const RADIUS_COMPACT = 18.5; const RADIUS_EXPANDED = 24; /** 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); 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, scaleFrom = 0.8, delay = 0.04, }: { keyId: string; children: ReactNode; className?: string; /** Scale the content emerges from — everything originates at the pill. */ scaleFrom?: number; /** Lets the shell lead the bloom before content appears. */ delay?: number; }) { 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} ); }