{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"dynamic-island","type":"registry:component","title":"Dynamic Island","description":"A black pill that springs between live activities.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["motion"],"files":[{"path":"registry/dimaac/dynamic-island/dynamic-island.tsx","type":"registry:component","target":"components/dimaac/dynamic-island.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useReducedMotion, type Transition } from \"motion/react\";\n\n// A black pill that morphs between live activities, like the island at the top of a phone.\n// The pill is one motion element with `layout`: when its size changes, motion measures the old\n// and new box and springs between them with transforms (no width/height animation, so it stays\n// at 60fps). Each state's content waits for the shape to settle, then fades and\n// un-blurs in. Content sits in a child with `layout` too, so the pill's scale never squashes it.\n\nexport type IslandState = \"idle\" | \"music\" | \"call\" | \"timer\" | \"notification\";\n\nexport type IslandMusic = {\n  /** The track title */\n  title: string;\n  /** The line under it, e.g. the artist */\n  subtitle: string;\n  /** Album art (a square photo looks best) */\n  image?: string;\n  /** Describes the album art for screen readers. Empty means it is decoration. */\n  imageAlt?: string;\n  /** Track length, in seconds */\n  length?: number;\n};\n\nexport type IslandCall = {\n  /** The caller's name */\n  name: string;\n  /** The line under the name before the call is answered */\n  subtitle: string;\n  /** The caller's photo */\n  image?: string;\n  /** Describes the photo for screen readers */\n  imageAlt?: string;\n};\n\nexport type IslandTimer = {\n  /** What the countdown starts from, in seconds */\n  seconds: number;\n  /** A short name for the timer */\n  label: string;\n};\n\nexport type IslandNotification = {\n  /** The first, bold line */\n  title: string;\n  /** The second line */\n  body: string;\n};\n\nexport type DynamicIslandProps = {\n  /** The state to show. Set it (with onStateChange) to control the island; leave it out to let the island keep its own. */\n  state?: IslandState;\n  /** The first state when the island keeps its own state. */\n  defaultState?: IslandState;\n  /** Called whenever the island wants a new state: auto cycle, a tap on the idle pill, declining a call, dismissing a notification. */\n  onStateChange?: (state: IslandState) => void;\n  /** Move through the states by itself. It pauses while the pointer or keyboard focus is on the island, while the visitor is using it (a call answered, music or the timer paused), and each tap or key press on it starts the wait again. */\n  autoCycle?: boolean;\n  /** How long each state stays before the auto cycle moves on, in seconds. */\n  cycleDelay?: number;\n  /** Spring stiffness of the shape morph. Higher is snappier. */\n  springStiffness?: number;\n  /** Spring damping of the shape morph. Lower overshoots and wobbles more. It never goes below a damping ratio of 0.4, so the pill can't swell far past its size. */\n  springDamping?: number;\n  /** The music state's track. */\n  music?: IslandMusic;\n  /** The call state's caller. */\n  call?: IslandCall;\n  /** The timer state's countdown. */\n  timer?: IslandTimer;\n  /** The notification state's two lines. */\n  notification?: IslandNotification;\n  /** Extra classes for the outer box. The pill sits centred at its top. */\n  className?: string;\n};\n\n/** The order the auto cycle (and a tap on the idle pill) moves through */\nexport const islandStates: IslandState[] = [\"idle\", \"music\", \"call\", \"timer\", \"notification\"];\n\n// Each state's shape: width, height and corner radius in pixels. Widths shrink to fit narrow boxes.\nconst shapes: Record<IslandState, { width: number; height: number; radius: number }> = {\n  idle: { width: 126, height: 36, radius: 18 },\n  music: { width: 364, height: 158, radius: 46 },\n  call: { width: 372, height: 76, radius: 38 },\n  timer: { width: 318, height: 70, radius: 35 },\n  notification: { width: 368, height: 84, radius: 34 },\n};\n\nconst defaultMusic: IslandMusic = { title: \"Slow Light\", subtitle: \"Harbour Rooms\", length: 214 };\nconst defaultCall: IslandCall = { name: \"Maya\", subtitle: \"Incoming call\" };\nconst defaultTimer: IslandTimer = { seconds: 300, label: \"Tea\" };\nconst defaultNotification: IslandNotification = { title: \"Stand-up in 5 minutes\", body: \"Studio B, second floor. Bring the new cuts.\" };\n\nconst pad = (value: number) => String(value).padStart(2, \"0\");\nconst clock = (seconds: number) => `${pad(Math.floor(seconds / 60))}:${pad(Math.floor(seconds % 60))}`;\n\n/** Counts whole seconds while `running` is true. `reset` changes start it again from 0. */\nfunction useSeconds(running: boolean, reset: unknown) {\n  const [seconds, setSeconds] = useState(0);\n  useEffect(() => setSeconds(0), [reset]);\n  useEffect(() => {\n    if (!running) return;\n    const id = window.setInterval(() => setSeconds((s) => s + 1), 1000);\n    return () => window.clearInterval(id);\n  }, [running]);\n  return seconds;\n}\n\nexport function DynamicIsland({\n  state,\n  defaultState = \"idle\",\n  onStateChange,\n  autoCycle = false,\n  cycleDelay = 3.6,\n  springStiffness = 380,\n  springDamping = 30,\n  music = defaultMusic,\n  call = defaultCall,\n  timer = defaultTimer,\n  notification = defaultNotification,\n  className = \"\",\n}: DynamicIslandProps) {\n  const reduced = useReducedMotion() ?? false;\n  const [ownState, setOwnState] = useState<IslandState>(defaultState);\n  const current = state ?? ownState;\n\n  // Kept in a ref so the auto cycle timer doesn't restart when a parent passes a new function\n  const onChangeRef = useRef(onStateChange);\n  onChangeRef.current = onStateChange;\n  const change = (next: IslandState) => {\n    if (state === undefined) setOwnState(next);\n    onChangeRef.current?.(next);\n  };\n  const changeRef = useRef(change);\n  changeRef.current = change;\n\n  // A change made from a button inside the island unmounts that button, which would drop keyboard\n  // focus to the page. This flag asks for focus to move into the new content once it has settled.\n  const refocus = useRef(false);\n  const changeFromInside = (next: IslandState) => {\n    refocus.current = true;\n    change(next);\n  };\n\n  // \"Engaged\" is set by the content while the visitor is using it (call answered, music or timer\n  // paused): the auto cycle waits until they let go, so it never undoes what they just did.\n  const [engaged, setEngaged] = useState(false);\n  // Bumped on every pointer down or key press on the pill; it restarts the cycle's wait\n  const [nudge, setNudge] = useState(0);\n\n  // Warm the browser cache for both photos as soon as the island mounts, so the music and call\n  // content never fades in with an empty hole where the picture is still downloading.\n  useEffect(() => {\n    for (const src of [music.image, call.image]) {\n      if (!src) continue;\n      const img = new Image();\n      img.decoding = \"async\";\n      img.src = src;\n    }\n  }, [music.image, call.image]);\n\n  // The room the pill has, so it never grows wider than its box (phones)\n  const boxRef = useRef<HTMLDivElement>(null);\n  const [room, setRoom] = useState(Infinity);\n  useEffect(() => {\n    const box = boxRef.current;\n    if (!box) return;\n    const observer = new ResizeObserver(() => setRoom(box.clientWidth - 24));\n    observer.observe(box);\n    return () => observer.disconnect();\n  }, []);\n\n  // Auto cycle: one timeout per state, paused while a mouse is over the island or keyboard focus is\n  // inside it. Focus is checked when the timeout fires instead of tracked with focus/blur: a focused\n  // button that unmounts (Decline, Dismiss) never fires blur, which would pause the cycle for good.\n  // Only :focus-visible counts, so a tap on a phone (which focuses the button too) doesn't pause it.\n  const pillRef = useRef<HTMLDivElement>(null);\n  const [hovered, setHovered] = useState(false);\n  useEffect(() => {\n    if (!autoCycle || hovered || engaged) return;\n    let id = 0;\n    const tick = () => {\n      const focused = document.activeElement;\n      if (focused && pillRef.current?.contains(focused) && focused.matches(\":focus-visible\")) {\n        id = window.setTimeout(tick, cycleDelay * 1000);\n        return;\n      }\n      changeRef.current(islandStates[(islandStates.indexOf(current) + 1) % islandStates.length]!);\n    };\n    id = window.setTimeout(tick, cycleDelay * 1000);\n    return () => window.clearTimeout(id);\n  }, [autoCycle, hovered, engaged, nudge, current, cycleDelay]);\n\n  const shape = shapes[current];\n  const width = Math.max(shapes.idle.width, Math.min(shape.width, room));\n\n  // The content waits until the shape has settled, so it never shows clipped by a pill that is\n  // still growing or wobbling. \"Settled\" is whichever comes first: motion reporting the layout\n  // animation done, or the time the spring's envelope has decayed to a few percent (motion's own\n  // completion can trail a soft wobble by a long, invisible tail). The time comes from the spring's\n  // natural frequency w and damping ratio z (mass 1), so it accounts for stiffness and damping.\n  const [settled, setSettled] = useState(true);\n  const [settledFor, setSettledFor] = useState(current);\n  if (settledFor !== current) {\n    // Adjusting state during render (not in an effect) so the new content never gets a frame as \"settled\"\n    setSettledFor(current);\n    setSettled(false);\n  }\n  const w = Math.sqrt(springStiffness);\n  // Floor the damping ratio at 0.4 (about 25% overshoot): softer springs swell the pill far past its box\n  const damping = Math.max(springDamping, 0.8 * w);\n  const z = damping / (2 * w);\n  const decay = z < 1 ? z * w : w * (z - Math.sqrt(z * z - 1));\n  // Reduced motion: a short, plain change of size with no overshoot and no blur; the content crossfades.\n  const morph: Transition = reduced ? { type: \"tween\", duration: 0.15, ease: [0.4, 0, 0.2, 1] } : { type: \"spring\", stiffness: springStiffness, damping, mass: 1 };\n  const settleTime = reduced ? 0.15 : Math.min(2.5, Math.max(0.15, 3.2 / decay));\n  useEffect(() => {\n    if (settled) return;\n    const id = window.setTimeout(() => setSettled(true), settleTime * 1000);\n    return () => window.clearTimeout(id);\n  }, [settled, settleTime]);\n\n  // Once the new content is shown (and no longer inert), hand focus to its first control, or the pill\n  useEffect(() => {\n    if (!settled || !refocus.current) return;\n    refocus.current = false;\n    const pill = pillRef.current;\n    const target = pill?.querySelector<HTMLElement>(\"[data-island-content] button\") ?? pill;\n    target?.focus({ preventScroll: true });\n  }, [settled, current]);\n\n  return (\n    <div ref={boxRef} className={`relative flex w-full justify-center ${className}`}>\n      <motion.div\n        ref={pillRef}\n        tabIndex={-1}\n        layout\n        transition={{ layout: morph, borderRadius: morph }}\n        onLayoutAnimationComplete={() => setSettled(true)}\n        initial={false}\n        animate={{ borderRadius: shape.radius }}\n        className=\"relative overflow-hidden bg-black text-white outline-none\"\n        style={{\n          width,\n          height: shape.height,\n          borderRadius: shape.radius,\n          // A hairline ring keeps the pill readable on dark pages, the shadow lifts it on light ones\n          boxShadow: \"0 0 0 1px rgba(255,255,255,0.07), 0 18px 40px -18px rgba(0,0,0,0.55)\",\n          WebkitTapHighlightColor: \"transparent\",\n        }}\n        onPointerEnter={(event) => event.pointerType === \"mouse\" && setHovered(true)}\n        onPointerLeave={() => setHovered(false)}\n        onPointerDown={() => setNudge((n) => n + 1)}\n        onKeyDown={() => setNudge((n) => n + 1)}\n      >\n        <AnimatePresence initial={false}>\n          <motion.div\n            key={current}\n            layout\n            transition={{ layout: morph }}\n            data-island-content\n            className=\"absolute top-0 left-0\"\n            style={{ width, height: shape.height }}\n          >\n            <Reveal reduced={reduced} shown={settled}>\n              {current === \"idle\" && <IdleContent onOpen={() => changeFromInside(\"music\")} />}\n              {current === \"music\" && <MusicContent music={music} reduced={reduced} onEngage={setEngaged} />}\n              {current === \"call\" && <CallContent call={call} onEnd={() => changeFromInside(\"idle\")} onEngage={setEngaged} />}\n              {current === \"timer\" && <TimerContent timer={timer} reduced={reduced} onEngage={setEngaged} />}\n              {current === \"notification\" && <NotificationContent notification={notification} onDismiss={() => changeFromInside(\"idle\")} />}\n            </Reveal>\n          </motion.div>\n        </AnimatePresence>\n      </motion.div>\n\n      {/* Screen readers hear what the island now shows */}\n      <p className=\"sr-only\" aria-live=\"polite\">\n        {current === \"music\" && `Now playing ${music.title} by ${music.subtitle}`}\n        {current === \"call\" && `${call.subtitle} from ${call.name}`}\n        {current === \"timer\" && `${timer.label} timer running`}\n        {current === \"notification\" && `${notification.title}. ${notification.body}`}\n      </p>\n    </div>\n  );\n}\n\nconst hiddenContent = { opacity: 0, scale: 0.94, filter: \"blur(8px)\" };\n\n/** Tells the island the visitor is using this content while `active` is true; lets go on unmount. */\nfunction useEngage(active: boolean, onEngage: (engaged: boolean) => void) {\n  useEffect(() => {\n    onEngage(active);\n    return () => onEngage(false);\n  }, [active, onEngage]);\n}\n\n/**\n * Fades and un-blurs a state's content in once `shown` turns on (the shape has settled). Until then\n * it is inert, so the invisible content can't be clicked, focused or read out mid-morph. Leaving\n * content only fades, and very fast: it is pinned to the pill's top-left corner, so if it lingered\n * while the pill shrinks it would show sliced off on the right and bottom.\n */\nfunction Reveal({ reduced, shown, children }: { reduced: boolean; shown: boolean; children: ReactNode }) {\n  const hidden = reduced ? { opacity: 0 } : hiddenContent;\n  return (\n    <motion.div\n      className=\"h-full w-full\"\n      inert={!shown}\n      initial={hidden}\n      animate={shown ? (reduced ? { opacity: 1 } : { opacity: 1, scale: 1, filter: \"blur(0px)\" }) : hidden}\n      exit={{ opacity: 0, transition: { duration: 0.08, ease: \"easeOut\" } }}\n      transition={{ duration: reduced ? 0.2 : 0.38, ease: [0.22, 1, 0.36, 1] }}\n    >\n      {children}\n    </motion.div>\n  );\n}\n\nfunction IdleContent({ onOpen }: { onOpen: () => void }) {\n  return (\n    <button type=\"button\" onClick={onOpen} aria-label=\"Open live activity\" className=\"flex h-full w-full cursor-pointer items-center justify-end pr-3.5 outline-none focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-inset\" style={{ borderRadius: \"inherit\" }}>\n      {/* The camera lens: decoration */}\n      <span aria-hidden className=\"block size-2.5 rounded-full\" style={{ background: \"radial-gradient(circle at 35% 35%, #3a3f5c 0%, #15172a 55%, #0a0a0f 100%)\", boxShadow: \"inset 0 0 0 1px rgba(255,255,255,0.05)\" }} />\n    </button>\n  );\n}\n\nfunction Art({ src, alt, className }: { src?: string; alt?: string; className: string }) {\n  if (!src) return <span aria-hidden className={`block bg-gradient-to-br from-[#ff6a88] via-[#ff99ac] to-[#fbd38d] ${className}`} />;\n  // A soft tile behind the photo, so a slow network shows a quiet placeholder rather than a hole\n  return <img src={src} alt={alt ?? \"\"} draggable={false} decoding=\"async\" className={`block bg-white/10 object-cover ${className}`} />;\n}\n\nfunction MusicContent({ music, reduced, onEngage }: { music: IslandMusic; reduced: boolean; onEngage: (engaged: boolean) => void }) {\n  const length = music.length ?? 214;\n  const [playing, setPlaying] = useState(true);\n  useEngage(!playing, onEngage);\n  // Previous and next start the track again (there is only one); before that it plays from 1:12\n  const [skips, setSkips] = useState(0);\n  const played = (useSeconds(playing, `${music.title}-${skips}`) + (skips === 0 ? 72 : 0)) % length;\n\n  return (\n    <div className=\"flex h-full flex-col justify-between px-5 pt-4.5 pb-4\">\n      <div className=\"flex items-center gap-3.5\">\n        <Art src={music.image} alt={music.imageAlt} className=\"size-[52px] shrink-0 rounded-[14px]\" />\n        <div className=\"min-w-0 flex-1\">\n          <p className=\"truncate text-[15px] leading-tight font-semibold tracking-[-0.01em]\">{music.title}</p>\n          <p className=\"mt-0.5 truncate text-[13px] leading-tight text-white/55\">{music.subtitle}</p>\n        </div>\n        <Waveform playing={playing && !reduced} />\n      </div>\n\n      <div className=\"flex items-center gap-2.5 text-[11px] text-white/50 tabular-nums\">\n        <span className=\"w-7\">{clock(played)}</span>\n        <span className=\"relative h-[5px] flex-1 overflow-hidden rounded-full bg-white/15\">\n          <span className=\"absolute inset-0 origin-left rounded-full bg-white/85 transition-transform duration-1000 ease-linear\" style={{ transform: `scaleX(${played / length})` }} />\n        </span>\n        <span className=\"w-8 text-right\">-{clock(length - played)}</span>\n      </div>\n\n      <div className=\"flex items-center justify-center gap-7\">\n        <IconButton label=\"Previous track\" onClick={() => setSkips((n) => n + 1)}>\n          <path d=\"M6 6h2v12H6zM20 6v12l-9-6z\" />\n        </IconButton>\n        <IconButton label={playing ? \"Pause\" : \"Play\"} onClick={() => setPlaying((p) => !p)} big>\n          {playing ? <path d=\"M7 5h3.5v14H7zM13.5 5H17v14h-3.5z\" /> : <path d=\"M8 5.5v13l11-6.5z\" />}\n        </IconButton>\n        <IconButton label=\"Next track\" onClick={() => setSkips((n) => n + 1)}>\n          <path d=\"M16 6h2v12h-2zM4 6v12l9-6z\" />\n        </IconButton>\n      </div>\n    </div>\n  );\n}\n\n// Six bars that bounce while the music plays. Only scaleY moves, so it stays on the compositor.\n// Started by hand in an effect: the island's AnimatePresence skips mount animations for its\n// first content, which would leave the bars frozen.\nfunction Waveform({ playing }: { playing: boolean }) {\n  const barRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  useEffect(() => {\n    const bars = barRefs.current.filter((bar): bar is HTMLSpanElement => bar !== null);\n    const runs = bars.map((bar, i) =>\n      playing\n        ? animate(bar, { scaleY: [0.3, 1, 0.45, 0.85, 0.3] }, { duration: 1 + i * 0.13, repeat: Infinity, repeatType: \"mirror\", ease: \"easeInOut\", delay: i * 0.07 })\n        : animate(bar, { scaleY: 0.2 + (i % 3) * 0.1 }, { duration: 0.3 }),\n    );\n    return () => runs.forEach((run) => run.stop());\n  }, [playing]);\n\n  return (\n    <span aria-hidden className=\"flex h-6 shrink-0 items-center gap-[3px]\">\n      {[0, 1, 2, 3, 4, 5].map((i) => (\n        <span\n          key={i}\n          ref={(bar) => {\n            barRefs.current[i] = bar;\n          }}\n          className=\"block h-full w-[3px] rounded-full bg-gradient-to-t from-[#ff6a88] to-[#fbd38d]\"\n          style={{ transform: `scaleY(${0.3 + (i % 3) * 0.2})` }}\n        />\n      ))}\n    </span>\n  );\n}\n\nfunction IconButton({ label, onClick, big = false, children }: { label: string; onClick: () => void; big?: boolean; children: ReactNode }) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      onClick={onClick}\n      className=\"grid size-9 cursor-pointer place-items-center rounded-full text-white outline-none transition-[transform,background-color] duration-150 hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white/60 active:scale-90\"\n    >\n      <svg viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden className={big ? \"size-7\" : \"size-5 opacity-80\"}>\n        {children}\n      </svg>\n    </button>\n  );\n}\n\nfunction CallContent({ call, onEnd, onEngage }: { call: IslandCall; onEnd: () => void; onEngage: (engaged: boolean) => void }) {\n  const [answered, setAnswered] = useState(false);\n  useEngage(answered, onEngage);\n  const [muted, setMuted] = useState(false);\n  const talked = useSeconds(answered, call.name);\n\n  return (\n    <div className=\"flex h-full items-center gap-3 pr-3.5 pl-3.5\">\n      <Art src={call.image} alt={call.imageAlt} className=\"size-12 shrink-0 rounded-full\" />\n      <div className=\"min-w-0 flex-1\">\n        <p className=\"truncate text-[12px] leading-tight text-white/50\">{answered ? \"On call\" : call.subtitle}</p>\n        <p className=\"mt-0.5 truncate text-[16px] leading-tight font-semibold tracking-[-0.01em]\">\n          {call.name}\n          {answered && <span className=\"ml-2 font-normal text-[#30d158] tabular-nums\">{clock(talked)}</span>}\n        </p>\n      </div>\n      <button\n        type=\"button\"\n        onClick={onEnd}\n        aria-label={answered ? \"End call\" : \"Decline call\"}\n        className=\"grid size-11 shrink-0 cursor-pointer place-items-center rounded-full bg-[#ff453a] outline-none transition-transform duration-150 focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-black active:scale-90\"\n      >\n        <svg viewBox=\"0 0 24 24\" aria-hidden className=\"size-[22px] rotate-[135deg]\" fill=\"white\">\n          <path d=\"M6.6 10.8a15.1 15.1 0 0 0 6.6 6.6l2.2-2.2c.3-.3.7-.4 1-.2 1.1.4 2.3.6 3.6.6.6 0 1 .4 1 1V20c0 .6-.4 1-1 1A17 17 0 0 1 3 4c0-.6.4-1 1-1h3.5c.6 0 1 .4 1 1 0 1.3.2 2.5.6 3.6.1.3 0 .7-.2 1z\" />\n        </svg>\n      </button>\n      {answered ? (\n        <button\n          type=\"button\"\n          onClick={() => setMuted((m) => !m)}\n          aria-label=\"Mute\"\n          aria-pressed={muted}\n          className={`grid size-11 shrink-0 cursor-pointer place-items-center rounded-full outline-none transition-[transform,background-color] duration-150 focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-black active:scale-90 ${muted ? \"bg-white text-black\" : \"bg-white/15 text-white\"}`}\n        >\n          <svg viewBox=\"0 0 24 24\" aria-hidden className=\"size-5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\">\n            <path d=\"M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3zM19 11a7 7 0 0 1-14 0M12 18v3\" />\n            {muted && <path d=\"M4 4l16 16\" />}\n          </svg>\n        </button>\n      ) : (\n        <button\n          type=\"button\"\n          onClick={() => setAnswered(true)}\n          aria-label=\"Accept call\"\n          className=\"grid size-11 shrink-0 cursor-pointer place-items-center rounded-full bg-[#30d158] outline-none transition-transform duration-150 focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-black active:scale-90\"\n        >\n          <svg viewBox=\"0 0 24 24\" aria-hidden className=\"size-[22px]\" fill=\"white\">\n            <path d=\"M6.6 10.8a15.1 15.1 0 0 0 6.6 6.6l2.2-2.2c.3-.3.7-.4 1-.2 1.1.4 2.3.6 3.6.6.6 0 1 .4 1 1V20c0 .6-.4 1-1 1A17 17 0 0 1 3 4c0-.6.4-1 1-1h3.5c.6 0 1 .4 1 1 0 1.3.2 2.5.6 3.6.1.3 0 .7-.2 1z\" />\n          </svg>\n        </button>\n      )}\n    </div>\n  );\n}\n\nfunction TimerContent({ timer, reduced, onEngage }: { timer: IslandTimer; reduced: boolean; onEngage: (engaged: boolean) => void }) {\n  const [running, setRunning] = useState(true);\n  useEngage(!running, onEngage);\n  const passed = useSeconds(running, timer.seconds);\n  // Counts down, then starts over\n  const left = timer.seconds - (passed % (timer.seconds + 1));\n  const ring = 2 * Math.PI * 17;\n\n  return (\n    <div className=\"flex h-full items-center gap-3 pr-6 pl-3\">\n      <button\n        type=\"button\"\n        onClick={() => setRunning((r) => !r)}\n        aria-label={running ? `Pause ${timer.label} timer` : `Resume ${timer.label} timer`}\n        className=\"relative grid size-11 shrink-0 cursor-pointer place-items-center rounded-full bg-[#ff9f0a]/20 text-[#ff9f0a] outline-none transition-transform duration-150 focus-visible:ring-2 focus-visible:ring-[#ff9f0a] active:scale-90\"\n      >\n        <svg viewBox=\"0 0 40 40\" aria-hidden className=\"absolute inset-0 size-full -rotate-90\">\n          <circle cx=\"20\" cy=\"20\" r=\"17\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeDasharray={ring} strokeDashoffset={ring * (1 - left / timer.seconds)} className=\"transition-[stroke-dashoffset] duration-1000 ease-linear\" />\n        </svg>\n        <svg viewBox=\"0 0 24 24\" aria-hidden className=\"size-4\" fill=\"currentColor\">\n          {running ? <path d=\"M7 5h3.5v14H7zM13.5 5H17v14h-3.5z\" /> : <path d=\"M8 5.5v13l11-6.5z\" />}\n        </svg>\n      </button>\n      <p className=\"flex-1 text-[13px] leading-tight text-[#ff9f0a]/80\">{timer.label}</p>\n      <Digits text={clock(left)} reduced={reduced} />\n    </div>\n  );\n}\n\n// Each digit rolls in from above when it changes. popLayout keeps the old one out of the flow.\nfunction Digits({ text, reduced }: { text: string; reduced: boolean }) {\n  return (\n    <span className=\"flex text-[38px] leading-none font-light tracking-[-0.03em] text-[#ff9f0a] tabular-nums\" aria-label={text} role=\"timer\">\n      {text.split(\"\").map((char, i) => (\n        <span key={i} aria-hidden className=\"relative inline-block overflow-hidden\" style={{ width: char === \":\" ? \"0.3em\" : \"0.6em\" }}>\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.span\n              key={char}\n              className=\"inline-block w-full text-center\"\n              initial={reduced ? { opacity: 0 } : { y: \"-70%\", opacity: 0, filter: \"blur(3px)\" }}\n              animate={reduced ? { opacity: 1 } : { y: \"0%\", opacity: 1, filter: \"blur(0px)\" }}\n              exit={reduced ? { opacity: 0 } : { y: \"70%\", opacity: 0, filter: \"blur(3px)\" }}\n              transition={{ duration: reduced ? 0.15 : 0.42, ease: [0.22, 1, 0.36, 1] }}\n            >\n              {char}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      ))}\n    </span>\n  );\n}\n\nfunction NotificationContent({ notification, onDismiss }: { notification: IslandNotification; onDismiss: () => void }) {\n  return (\n    <div className=\"flex h-full items-center gap-3 pr-3 pl-4\">\n      <span aria-hidden className=\"grid size-11 shrink-0 place-items-center rounded-[13px] bg-gradient-to-br from-[#5e9bff] to-[#7a5cff] shadow-[inset_0_1px_0_rgba(255,255,255,0.25)]\">\n        <svg viewBox=\"0 0 24 24\" className=\"size-[22px]\" fill=\"white\">\n          <path d=\"M12 22a2.5 2.5 0 0 0 2.45-2h-4.9A2.5 2.5 0 0 0 12 22zm7-6V11a7 7 0 0 0-5.5-6.84V3.5a1.5 1.5 0 0 0-3 0v.66A7 7 0 0 0 5 11v5l-2 2v1h18v-1z\" />\n        </svg>\n      </span>\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"flex items-baseline justify-between gap-2\">\n          <p className=\"truncate text-[15px] leading-tight font-semibold tracking-[-0.01em]\">{notification.title}</p>\n          <span className=\"shrink-0 text-[11px] text-white/40\">now</span>\n        </div>\n        <p className=\"mt-1 truncate text-[13px] leading-tight text-white/60\">{notification.body}</p>\n      </div>\n      <button\n        type=\"button\"\n        onClick={onDismiss}\n        aria-label=\"Dismiss notification\"\n        className=\"grid size-7 shrink-0 cursor-pointer place-items-center rounded-full bg-white/10 text-white/70 outline-none transition-[transform,background-color] duration-150 hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-white/60 active:scale-90\"\n      >\n        <svg viewBox=\"0 0 24 24\" aria-hidden className=\"size-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\">\n          <path d=\"M6 6l12 12M18 6L6 18\" />\n        </svg>\n      </button>\n    </div>\n  );\n}\n"}],"categories":["navigation","motion","layout animation","spring","status","morph"],"docs":"import { DynamicIsland } from \"@/components/dimaac/dynamic-island\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/dynamic-island","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/dynamic-island","credits":[{"by":"marisol","url":"https://www.lummi.ai/photo/glossy-pink-sphere-45si5","what":"Album art","source":"Lummi","licence":"Lummi License"},{"by":"rena","url":"https://www.lummi.ai/photo/stylish-portrait-upjeu","what":"Caller photo","source":"Lummi","licence":"Lummi License"}]}}