---
title: Dynamic Island
description: A black pill that springs between live activities.
category: Navigation
tier: free
url: https://ui.dimaac.com/components/dynamic-island
registry: https://ui.dimaac.com/r/dynamic-island.json
---

# Dynamic Island

A black, rounded pill at the top of the page that morphs between live activities: idle, music (album art, title, waveform, progress and controls), an incoming call (photo, name, a talk timer, answer and decline), a countdown timer with rolling digits, and a notification. The shape springs between sizes with motion's layout animations (transforms only, so it stays at 60fps), and each state's content fades and un-blurs in once the shape has settled. Use it controlled or let it keep its own state, cycle through the states by itself (pausing while someone uses it), and tune the spring. Every control is a real, labelled button, screen readers hear each change, and with reduced motion it simply crossfades between sizes.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/dynamic-island.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/dynamic-island.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/dynamic-island.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/dynamic-island`

Dependencies:

```bash
pnpm add motion
npm install motion
```

By hand:

1. Install the packages it uses: motion.
2. Copy the component file below into components/dimaac/dynamic-island.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { DynamicIsland } from "@/components/dimaac/dynamic-island" and give its parent a size: it fills it.

## Usage

```tsx
import { DynamicIsland } from "@/components/dimaac/dynamic-island";

<DynamicIsland />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `state` | `IslandState` | - | The state to show. Set it (with onStateChange) to control the island; leave it out to let the island keep its own. |
| `defaultState` | `IslandState` | `"idle"` | The first state when the island keeps its own state. |
| `onStateChange` | `(state: IslandState) => void` | - | Called whenever the island wants a new state: auto cycle, a tap on the idle pill, declining a call, dismissing a notification. |
| `autoCycle` | `boolean` | `false` | 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. |
| `cycleDelay` | `number` | `3.6` | How long each state stays before the auto cycle moves on, in seconds. |
| `springStiffness` | `number` | `380` | Spring stiffness of the shape morph. Higher is snappier. |
| `springDamping` | `number` | `30` | 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. |
| `music` | `IslandMusic` | `defaultMusic` | The music state's track. |
| `call` | `IslandCall` | `defaultCall` | The call state's caller. |
| `timer` | `IslandTimer` | `defaultTimer` | The timer state's countdown. |
| `notification` | `IslandNotification` | `defaultNotification` | The notification state's two lines. |
| `className` | `string` | `""` | Extra classes for the outer box. The pill sits centred at its top. |

## Credits

- Album art: marisol, Lummi (https://www.lummi.ai/photo/glossy-pink-sphere-45si5), Lummi License
- Caller photo: rena, Lummi (https://www.lummi.ai/photo/stylish-portrait-upjeu), Lummi License

## Source

### dynamic-island.tsx → `components/dimaac/dynamic-island.tsx`

```tsx
"use client";

import { useEffect, useRef, useState, type ReactNode } from "react";
import { AnimatePresence, animate, motion, useReducedMotion, type Transition } from "motion/react";

// A black pill that morphs between live activities, like the island at the top of a phone.
// The pill is one motion element with `layout`: when its size changes, motion measures the old
// and new box and springs between them with transforms (no width/height animation, so it stays
// at 60fps). Each state's content waits for the shape to settle, then fades and
// un-blurs in. Content sits in a child with `layout` too, so the pill's scale never squashes it.

export type IslandState = "idle" | "music" | "call" | "timer" | "notification";

export type IslandMusic = {
  /** The track title */
  title: string;
  /** The line under it, e.g. the artist */
  subtitle: string;
  /** Album art (a square photo looks best) */
  image?: string;
  /** Describes the album art for screen readers. Empty means it is decoration. */
  imageAlt?: string;
  /** Track length, in seconds */
  length?: number;
};

export type IslandCall = {
  /** The caller's name */
  name: string;
  /** The line under the name before the call is answered */
  subtitle: string;
  /** The caller's photo */
  image?: string;
  /** Describes the photo for screen readers */
  imageAlt?: string;
};

export type IslandTimer = {
  /** What the countdown starts from, in seconds */
  seconds: number;
  /** A short name for the timer */
  label: string;
};

export type IslandNotification = {
  /** The first, bold line */
  title: string;
  /** The second line */
  body: string;
};

export type DynamicIslandProps = {
  /** The state to show. Set it (with onStateChange) to control the island; leave it out to let the island keep its own. */
  state?: IslandState;
  /** The first state when the island keeps its own state. */
  defaultState?: IslandState;
  /** Called whenever the island wants a new state: auto cycle, a tap on the idle pill, declining a call, dismissing a notification. */
  onStateChange?: (state: IslandState) => void;
  /** 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. */
  autoCycle?: boolean;
  /** How long each state stays before the auto cycle moves on, in seconds. */
  cycleDelay?: number;
  /** Spring stiffness of the shape morph. Higher is snappier. */
  springStiffness?: number;
  /** 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. */
  springDamping?: number;
  /** The music state's track. */
  music?: IslandMusic;
  /** The call state's caller. */
  call?: IslandCall;
  /** The timer state's countdown. */
  timer?: IslandTimer;
  /** The notification state's two lines. */
  notification?: IslandNotification;
  /** Extra classes for the outer box. The pill sits centred at its top. */
  className?: string;
};

/** The order the auto cycle (and a tap on the idle pill) moves through */
export const islandStates: IslandState[] = ["idle", "music", "call", "timer", "notification"];

// Each state's shape: width, height and corner radius in pixels. Widths shrink to fit narrow boxes.
const shapes: Record<IslandState, { width: number; height: number; radius: number }> = {
  idle: { width: 126, height: 36, radius: 18 },
  music: { width: 364, height: 158, radius: 46 },
  call: { width: 372, height: 76, radius: 38 },
  timer: { width: 318, height: 70, radius: 35 },
  notification: { width: 368, height: 84, radius: 34 },
};

const defaultMusic: IslandMusic = { title: "Slow Light", subtitle: "Harbour Rooms", length: 214 };
const defaultCall: IslandCall = { name: "Maya", subtitle: "Incoming call" };
const defaultTimer: IslandTimer = { seconds: 300, label: "Tea" };
const defaultNotification: IslandNotification = { title: "Stand-up in 5 minutes", body: "Studio B, second floor. Bring the new cuts." };

const pad = (value: number) => String(value).padStart(2, "0");
const clock = (seconds: number) => `${pad(Math.floor(seconds / 60))}:${pad(Math.floor(seconds % 60))}`;

/** Counts whole seconds while `running` is true. `reset` changes start it again from 0. */
function useSeconds(running: boolean, reset: unknown) {
  const [seconds, setSeconds] = useState(0);
  useEffect(() => setSeconds(0), [reset]);
  useEffect(() => {
    if (!running) return;
    const id = window.setInterval(() => setSeconds((s) => s + 1), 1000);
    return () => window.clearInterval(id);
  }, [running]);
  return seconds;
}

export function DynamicIsland({
  state,
  defaultState = "idle",
  onStateChange,
  autoCycle = false,
  cycleDelay = 3.6,
  springStiffness = 380,
  springDamping = 30,
  music = defaultMusic,
  call = defaultCall,
  timer = defaultTimer,
  notification = defaultNotification,
  className = "",
}: DynamicIslandProps) {
  const reduced = useReducedMotion() ?? false;
  const [ownState, setOwnState] = useState<IslandState>(defaultState);
  const current = state ?? ownState;

  // Kept in a ref so the auto cycle timer doesn't restart when a parent passes a new function
  const onChangeRef = useRef(onStateChange);
  onChangeRef.current = onStateChange;
  const change = (next: IslandState) => {
    if (state === undefined) setOwnState(next);
    onChangeRef.current?.(next);
  };
  const changeRef = useRef(change);
  changeRef.current = change;

  // A change made from a button inside the island unmounts that button, which would drop keyboard
  // focus to the page. This flag asks for focus to move into the new content once it has settled.
  const refocus = useRef(false);
  const changeFromInside = (next: IslandState) => {
    refocus.current = true;
    change(next);
  };

  // "Engaged" is set by the content while the visitor is using it (call answered, music or timer
  // paused): the auto cycle waits until they let go, so it never undoes what they just did.
  const [engaged, setEngaged] = useState(false);
  // Bumped on every pointer down or key press on the pill; it restarts the cycle's wait
  const [nudge, setNudge] = useState(0);

  // Warm the browser cache for both photos as soon as the island mounts, so the music and call
  // content never fades in with an empty hole where the picture is still downloading.
  useEffect(() => {
    for (const src of [music.image, call.image]) {
      if (!src) continue;
      const img = new Image();
      img.decoding = "async";
      img.src = src;
    }
  }, [music.image, call.image]);

  // The room the pill has, so it never grows wider than its box (phones)
  const boxRef = useRef<HTMLDivElement>(null);
  const [room, setRoom] = useState(Infinity);
  useEffect(() => {
    const box = boxRef.current;
    if (!box) return;
    const observer = new ResizeObserver(() => setRoom(box.clientWidth - 24));
    observer.observe(box);
    return () => observer.disconnect();
  }, []);

  // Auto cycle: one timeout per state, paused while a mouse is over the island or keyboard focus is
  // inside it. Focus is checked when the timeout fires instead of tracked with focus/blur: a focused
  // button that unmounts (Decline, Dismiss) never fires blur, which would pause the cycle for good.
  // Only :focus-visible counts, so a tap on a phone (which focuses the button too) doesn't pause it.
  const pillRef = useRef<HTMLDivElement>(null);
  const [hovered, setHovered] = useState(false);
  useEffect(() => {
    if (!autoCycle || hovered || engaged) return;
    let id = 0;
    const tick = () => {
      const focused = document.activeElement;
      if (focused && pillRef.current?.contains(focused) && focused.matches(":focus-visible")) {
        id = window.setTimeout(tick, cycleDelay * 1000);
        return;
      }
      changeRef.current(islandStates[(islandStates.indexOf(current) + 1) % islandStates.length]!);
    };
    id = window.setTimeout(tick, cycleDelay * 1000);
    return () => window.clearTimeout(id);
  }, [autoCycle, hovered, engaged, nudge, current, cycleDelay]);

  const shape = shapes[current];
  const width = Math.max(shapes.idle.width, Math.min(shape.width, room));

  // The content waits until the shape has settled, so it never shows clipped by a pill that is
  // still growing or wobbling. "Settled" is whichever comes first: motion reporting the layout
  // animation done, or the time the spring's envelope has decayed to a few percent (motion's own
  // completion can trail a soft wobble by a long, invisible tail). The time comes from the spring's
  // natural frequency w and damping ratio z (mass 1), so it accounts for stiffness and damping.
  const [settled, setSettled] = useState(true);
  const [settledFor, setSettledFor] = useState(current);
  if (settledFor !== current) {
    // Adjusting state during render (not in an effect) so the new content never gets a frame as "settled"
    setSettledFor(current);
    setSettled(false);
  }
  const w = Math.sqrt(springStiffness);
  // Floor the damping ratio at 0.4 (about 25% overshoot): softer springs swell the pill far past its box
  const damping = Math.max(springDamping, 0.8 * w);
  const z = damping / (2 * w);
  const decay = z < 1 ? z * w : w * (z - Math.sqrt(z * z - 1));
  // Reduced motion: a short, plain change of size with no overshoot and no blur; the content crossfades.
  const morph: Transition = reduced ? { type: "tween", duration: 0.15, ease: [0.4, 0, 0.2, 1] } : { type: "spring", stiffness: springStiffness, damping, mass: 1 };
  const settleTime = reduced ? 0.15 : Math.min(2.5, Math.max(0.15, 3.2 / decay));
  useEffect(() => {
    if (settled) return;
    const id = window.setTimeout(() => setSettled(true), settleTime * 1000);
    return () => window.clearTimeout(id);
  }, [settled, settleTime]);

  // Once the new content is shown (and no longer inert), hand focus to its first control, or the pill
  useEffect(() => {
    if (!settled || !refocus.current) return;
    refocus.current = false;
    const pill = pillRef.current;
    const target = pill?.querySelector<HTMLElement>("[data-island-content] button") ?? pill;
    target?.focus({ preventScroll: true });
  }, [settled, current]);

  return (
    <div ref={boxRef} className={`relative flex w-full justify-center ${className}`}>
      <motion.div
        ref={pillRef}
        tabIndex={-1}
        layout
        transition={{ layout: morph, borderRadius: morph }}
        onLayoutAnimationComplete={() => setSettled(true)}
        initial={false}
        animate={{ borderRadius: shape.radius }}
        className="relative overflow-hidden bg-black text-white outline-none"
        style={{
          width,
          height: shape.height,
          borderRadius: shape.radius,
          // A hairline ring keeps the pill readable on dark pages, the shadow lifts it on light ones
          boxShadow: "0 0 0 1px rgba(255,255,255,0.07), 0 18px 40px -18px rgba(0,0,0,0.55)",
          WebkitTapHighlightColor: "transparent",
        }}
        onPointerEnter={(event) => event.pointerType === "mouse" && setHovered(true)}
        onPointerLeave={() => setHovered(false)}
        onPointerDown={() => setNudge((n) => n + 1)}
        onKeyDown={() => setNudge((n) => n + 1)}
      >
        <AnimatePresence initial={false}>
          <motion.div
            key={current}
            layout
            transition={{ layout: morph }}
            data-island-content
            className="absolute top-0 left-0"
            style={{ width, height: shape.height }}
          >
            <Reveal reduced={reduced} shown={settled}>
              {current === "idle" && <IdleContent onOpen={() => changeFromInside("music")} />}
              {current === "music" && <MusicContent music={music} reduced={reduced} onEngage={setEngaged} />}
              {current === "call" && <CallContent call={call} onEnd={() => changeFromInside("idle")} onEngage={setEngaged} />}
              {current === "timer" && <TimerContent timer={timer} reduced={reduced} onEngage={setEngaged} />}
              {current === "notification" && <NotificationContent notification={notification} onDismiss={() => changeFromInside("idle")} />}
            </Reveal>
          </motion.div>
        </AnimatePresence>
      </motion.div>

      {/* Screen readers hear what the island now shows */}
      <p className="sr-only" aria-live="polite">
        {current === "music" && `Now playing ${music.title} by ${music.subtitle}`}
        {current === "call" && `${call.subtitle} from ${call.name}`}
        {current === "timer" && `${timer.label} timer running`}
        {current === "notification" && `${notification.title}. ${notification.body}`}
      </p>
    </div>
  );
}

const hiddenContent = { opacity: 0, scale: 0.94, filter: "blur(8px)" };

/** Tells the island the visitor is using this content while `active` is true; lets go on unmount. */
function useEngage(active: boolean, onEngage: (engaged: boolean) => void) {
  useEffect(() => {
    onEngage(active);
    return () => onEngage(false);
  }, [active, onEngage]);
}

/**
 * Fades and un-blurs a state's content in once `shown` turns on (the shape has settled). Until then
 * it is inert, so the invisible content can't be clicked, focused or read out mid-morph. Leaving
 * content only fades, and very fast: it is pinned to the pill's top-left corner, so if it lingered
 * while the pill shrinks it would show sliced off on the right and bottom.
 */
function Reveal({ reduced, shown, children }: { reduced: boolean; shown: boolean; children: ReactNode }) {
  const hidden = reduced ? { opacity: 0 } : hiddenContent;
  return (
    <motion.div
      className="h-full w-full"
      inert={!shown}
      initial={hidden}
      animate={shown ? (reduced ? { opacity: 1 } : { opacity: 1, scale: 1, filter: "blur(0px)" }) : hidden}
      exit={{ opacity: 0, transition: { duration: 0.08, ease: "easeOut" } }}
      transition={{ duration: reduced ? 0.2 : 0.38, ease: [0.22, 1, 0.36, 1] }}
    >
      {children}
    </motion.div>
  );
}

function IdleContent({ onOpen }: { onOpen: () => void }) {
  return (
    <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" }}>
      {/* The camera lens: decoration */}
      <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)" }} />
    </button>
  );
}

function Art({ src, alt, className }: { src?: string; alt?: string; className: string }) {
  if (!src) return <span aria-hidden className={`block bg-gradient-to-br from-[#ff6a88] via-[#ff99ac] to-[#fbd38d] ${className}`} />;
  // A soft tile behind the photo, so a slow network shows a quiet placeholder rather than a hole
  return <img src={src} alt={alt ?? ""} draggable={false} decoding="async" className={`block bg-white/10 object-cover ${className}`} />;
}

function MusicContent({ music, reduced, onEngage }: { music: IslandMusic; reduced: boolean; onEngage: (engaged: boolean) => void }) {
  const length = music.length ?? 214;
  const [playing, setPlaying] = useState(true);
  useEngage(!playing, onEngage);
  // Previous and next start the track again (there is only one); before that it plays from 1:12
  const [skips, setSkips] = useState(0);
  const played = (useSeconds(playing, `${music.title}-${skips}`) + (skips === 0 ? 72 : 0)) % length;

  return (
    <div className="flex h-full flex-col justify-between px-5 pt-4.5 pb-4">
      <div className="flex items-center gap-3.5">
        <Art src={music.image} alt={music.imageAlt} className="size-[52px] shrink-0 rounded-[14px]" />
        <div className="min-w-0 flex-1">
          <p className="truncate text-[15px] leading-tight font-semibold tracking-[-0.01em]">{music.title}</p>
          <p className="mt-0.5 truncate text-[13px] leading-tight text-white/55">{music.subtitle}</p>
        </div>
        <Waveform playing={playing && !reduced} />
      </div>

      <div className="flex items-center gap-2.5 text-[11px] text-white/50 tabular-nums">
        <span className="w-7">{clock(played)}</span>
        <span className="relative h-[5px] flex-1 overflow-hidden rounded-full bg-white/15">
          <span className="absolute inset-0 origin-left rounded-full bg-white/85 transition-transform duration-1000 ease-linear" style={{ transform: `scaleX(${played / length})` }} />
        </span>
        <span className="w-8 text-right">-{clock(length - played)}</span>
      </div>

      <div className="flex items-center justify-center gap-7">
        <IconButton label="Previous track" onClick={() => setSkips((n) => n + 1)}>
          <path d="M6 6h2v12H6zM20 6v12l-9-6z" />
        </IconButton>
        <IconButton label={playing ? "Pause" : "Play"} onClick={() => setPlaying((p) => !p)} big>
          {playing ? <path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z" /> : <path d="M8 5.5v13l11-6.5z" />}
        </IconButton>
        <IconButton label="Next track" onClick={() => setSkips((n) => n + 1)}>
          <path d="M16 6h2v12h-2zM4 6v12l9-6z" />
        </IconButton>
      </div>
    </div>
  );
}

// Six bars that bounce while the music plays. Only scaleY moves, so it stays on the compositor.
// Started by hand in an effect: the island's AnimatePresence skips mount animations for its
// first content, which would leave the bars frozen.
function Waveform({ playing }: { playing: boolean }) {
  const barRefs = useRef<(HTMLSpanElement | null)[]>([]);
  useEffect(() => {
    const bars = barRefs.current.filter((bar): bar is HTMLSpanElement => bar !== null);
    const runs = bars.map((bar, i) =>
      playing
        ? 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 })
        : animate(bar, { scaleY: 0.2 + (i % 3) * 0.1 }, { duration: 0.3 }),
    );
    return () => runs.forEach((run) => run.stop());
  }, [playing]);

  return (
    <span aria-hidden className="flex h-6 shrink-0 items-center gap-[3px]">
      {[0, 1, 2, 3, 4, 5].map((i) => (
        <span
          key={i}
          ref={(bar) => {
            barRefs.current[i] = bar;
          }}
          className="block h-full w-[3px] rounded-full bg-gradient-to-t from-[#ff6a88] to-[#fbd38d]"
          style={{ transform: `scaleY(${0.3 + (i % 3) * 0.2})` }}
        />
      ))}
    </span>
  );
}

function IconButton({ label, onClick, big = false, children }: { label: string; onClick: () => void; big?: boolean; children: ReactNode }) {
  return (
    <button
      type="button"
      aria-label={label}
      onClick={onClick}
      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"
    >
      <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={big ? "size-7" : "size-5 opacity-80"}>
        {children}
      </svg>
    </button>
  );
}

function CallContent({ call, onEnd, onEngage }: { call: IslandCall; onEnd: () => void; onEngage: (engaged: boolean) => void }) {
  const [answered, setAnswered] = useState(false);
  useEngage(answered, onEngage);
  const [muted, setMuted] = useState(false);
  const talked = useSeconds(answered, call.name);

  return (
    <div className="flex h-full items-center gap-3 pr-3.5 pl-3.5">
      <Art src={call.image} alt={call.imageAlt} className="size-12 shrink-0 rounded-full" />
      <div className="min-w-0 flex-1">
        <p className="truncate text-[12px] leading-tight text-white/50">{answered ? "On call" : call.subtitle}</p>
        <p className="mt-0.5 truncate text-[16px] leading-tight font-semibold tracking-[-0.01em]">
          {call.name}
          {answered && <span className="ml-2 font-normal text-[#30d158] tabular-nums">{clock(talked)}</span>}
        </p>
      </div>
      <button
        type="button"
        onClick={onEnd}
        aria-label={answered ? "End call" : "Decline call"}
        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"
      >
        <svg viewBox="0 0 24 24" aria-hidden className="size-[22px] rotate-[135deg]" fill="white">
          <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" />
        </svg>
      </button>
      {answered ? (
        <button
          type="button"
          onClick={() => setMuted((m) => !m)}
          aria-label="Mute"
          aria-pressed={muted}
          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"}`}
        >
          <svg viewBox="0 0 24 24" aria-hidden className="size-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
            <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" />
            {muted && <path d="M4 4l16 16" />}
          </svg>
        </button>
      ) : (
        <button
          type="button"
          onClick={() => setAnswered(true)}
          aria-label="Accept call"
          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"
        >
          <svg viewBox="0 0 24 24" aria-hidden className="size-[22px]" fill="white">
            <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" />
          </svg>
        </button>
      )}
    </div>
  );
}

function TimerContent({ timer, reduced, onEngage }: { timer: IslandTimer; reduced: boolean; onEngage: (engaged: boolean) => void }) {
  const [running, setRunning] = useState(true);
  useEngage(!running, onEngage);
  const passed = useSeconds(running, timer.seconds);
  // Counts down, then starts over
  const left = timer.seconds - (passed % (timer.seconds + 1));
  const ring = 2 * Math.PI * 17;

  return (
    <div className="flex h-full items-center gap-3 pr-6 pl-3">
      <button
        type="button"
        onClick={() => setRunning((r) => !r)}
        aria-label={running ? `Pause ${timer.label} timer` : `Resume ${timer.label} timer`}
        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"
      >
        <svg viewBox="0 0 40 40" aria-hidden className="absolute inset-0 size-full -rotate-90">
          <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" />
        </svg>
        <svg viewBox="0 0 24 24" aria-hidden className="size-4" fill="currentColor">
          {running ? <path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z" /> : <path d="M8 5.5v13l11-6.5z" />}
        </svg>
      </button>
      <p className="flex-1 text-[13px] leading-tight text-[#ff9f0a]/80">{timer.label}</p>
      <Digits text={clock(left)} reduced={reduced} />
    </div>
  );
}

// Each digit rolls in from above when it changes. popLayout keeps the old one out of the flow.
function Digits({ text, reduced }: { text: string; reduced: boolean }) {
  return (
    <span className="flex text-[38px] leading-none font-light tracking-[-0.03em] text-[#ff9f0a] tabular-nums" aria-label={text} role="timer">
      {text.split("").map((char, i) => (
        <span key={i} aria-hidden className="relative inline-block overflow-hidden" style={{ width: char === ":" ? "0.3em" : "0.6em" }}>
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={char}
              className="inline-block w-full text-center"
              initial={reduced ? { opacity: 0 } : { y: "-70%", opacity: 0, filter: "blur(3px)" }}
              animate={reduced ? { opacity: 1 } : { y: "0%", opacity: 1, filter: "blur(0px)" }}
              exit={reduced ? { opacity: 0 } : { y: "70%", opacity: 0, filter: "blur(3px)" }}
              transition={{ duration: reduced ? 0.15 : 0.42, ease: [0.22, 1, 0.36, 1] }}
            >
              {char}
            </motion.span>
          </AnimatePresence>
        </span>
      ))}
    </span>
  );
}

function NotificationContent({ notification, onDismiss }: { notification: IslandNotification; onDismiss: () => void }) {
  return (
    <div className="flex h-full items-center gap-3 pr-3 pl-4">
      <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)]">
        <svg viewBox="0 0 24 24" className="size-[22px]" fill="white">
          <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" />
        </svg>
      </span>
      <div className="min-w-0 flex-1">
        <div className="flex items-baseline justify-between gap-2">
          <p className="truncate text-[15px] leading-tight font-semibold tracking-[-0.01em]">{notification.title}</p>
          <span className="shrink-0 text-[11px] text-white/40">now</span>
        </div>
        <p className="mt-1 truncate text-[13px] leading-tight text-white/60">{notification.body}</p>
      </div>
      <button
        type="button"
        onClick={onDismiss}
        aria-label="Dismiss notification"
        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"
      >
        <svg viewBox="0 0 24 24" aria-hidden className="size-3.5" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
          <path d="M6 6l12 12M18 6L6 18" />
        </svg>
      </button>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { useEffect, useState } from "react";
import { DynamicIsland, islandStates, type DynamicIslandProps, type IslandState } from "@/components/dimaac/dynamic-island";

// The island at the top of a soft lock-screen stage, with a quiet row of buttons to pick a live
// activity. The demo owns the state, so the island, the buttons and the auto cycle stay in step.
// Photos: marisol (album art) and rena (caller) on Lummi (Lummi License).

const labels: Record<IslandState, string> = { idle: "Idle", music: "Music", call: "Call", timer: "Timer", notification: "Alert" };

export default function Demo({ state: chosen = "music", autoCycle = true, ...props }: Partial<DynamicIslandProps>) {
  const [state, setState] = useState<IslandState>(chosen);
  // A new pick in the playground wins over whatever the cycle reached, and so does turning the
  // cycle off: the island snaps back to the picked state, so it always matches the State control
  useEffect(() => setState(chosen), [chosen, autoCycle]);

  return (
    <div className="relative flex h-full min-h-[560px] w-full flex-col items-center overflow-hidden bg-[#efece6] text-[#16140f] dark:bg-[#0b0b0d] dark:text-[#f2efe9]">
      {/* Soft colour behind everything, like a wallpaper. Painted with radial gradients rather than
          blurred blobs: a big blur filter is costly to repaint on low-end phones while the pill morphs. */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 dark:hidden"
        style={{
          background:
            "radial-gradient(46vmax 46vmax at 35% 52%, rgba(255,179,193,0.55), transparent 70%), radial-gradient(40vmax 40vmax at 78% 92%, rgba(255,213,154,0.6), transparent 70%), radial-gradient(30vmax 30vmax at 64% 8%, rgba(185,204,255,0.55), transparent 70%)",
        }}
      />
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 hidden dark:block"
        style={{
          background:
            "radial-gradient(46vmax 46vmax at 35% 52%, rgba(255,179,193,0.16), transparent 70%), radial-gradient(40vmax 40vmax at 78% 92%, rgba(255,159,106,0.13), transparent 70%), radial-gradient(30vmax 30vmax at 64% 8%, rgba(94,123,255,0.16), transparent 70%)",
        }}
      />

      {/* A fixed-height slot, taller than the biggest pill: the pill's real height changes at once
          while its visible size springs, so if it sat in the flow the clock below would jump */}
      <div className="relative z-10 h-[190px] w-full shrink-0 pt-5">
      <DynamicIsland
        {...props}
        state={state}
        onStateChange={setState}
        autoCycle={autoCycle}
        music={{ title: "Slow Light", subtitle: "Harbour Rooms", image: "https://ui.dimaac.com/media/art/pink-sphere-1x1.jpg", imageAlt: "A glossy pink sphere against a dreamy sky", length: 214 }}
        call={{ name: "Maya", subtitle: "Incoming call", image: "https://ui.dimaac.com/media/portraits/sunglasses-1x1.jpg", imageAlt: "Maya in dark sunglasses" }}
        className={props.className ?? ""}
      />
      </div>

      <div className="pointer-events-none relative z-0 flex flex-1 flex-col items-center justify-center px-6 text-center select-none">
        <p className="text-[clamp(96px,19vw,236px)] leading-[0.9] font-semibold tracking-[-0.06em] tabular-nums opacity-90">9:41</p>
      </div>

      <div role="group" aria-label="Live activity" className="relative z-10 mb-8 flex gap-5 sm:gap-7">
        {islandStates.map((name) => (
          <button
            key={name}
            type="button"
            onClick={() => setState(name)}
            aria-pressed={state === name}
            className={`cursor-pointer rounded-md px-1 py-1.5 text-[14px] outline-none transition-opacity duration-200 focus-visible:ring-2 focus-visible:ring-current ${state === name ? "opacity-100" : "opacity-40 hover:opacity-75"}`}
          >
            {labels[name]}
          </button>
        ))}
      </div>
    </div>
  );
}
```

## Agent prompt

```text
Add the Dynamic Island component (<DynamicIsland />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/dynamic-island
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/dynamic-island.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/dynamic-island.json

Then use it like this:
import { DynamicIsland } from "@/components/dimaac/dynamic-island";
<DynamicIsland />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/dynamic-island.tsx and install motion.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```
