Dynamic Island
A black pill that springs between live activities.
Customize
<DynamicIsland />Changes show live and stay in the address, so a link keeps them.
Code
"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>
);
}
The component. Installs to components/dimaac/dynamic-island.tsx.
Install
With the shadcn CLI
npx shadcn@latest add https://ui.dimaac.com/r/dynamic-island.jsonIt adds components/dimaac/dynamic-island.tsx and installs what it imports. With the @dimaac namespace in components.json (Docs) it's just npx shadcn@latest add @dimaac/dynamic-island.
By hand
npm install motion- Install the packages it uses: motion.
- Copy the component file below into components/dimaac/dynamic-island.tsx.
- It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
- Import it with import { DynamicIsland } from "@/components/dimaac/dynamic-island" and give its parent a size: it fills it.
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. |
Use with AI
Agent prompt
Paste it into Claude Code, Cursor or any coding agent, or open it straight in a chat. It carries your Customize settings.
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.txtFiles agents read
- llms.txt
- Every component and how to install it
- dynamic-island.md
- This page as Markdown: install, props and the full source
- dynamic-island.json
- The shadcn registry item
About
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.
- Installs to
components/dimaac/dynamic-island.tsx- Uses
- motion
- Tags
- motion, layout animation, spring, status, morph
Album art by marisol on Lummi (Lummi License). Caller photo by rena on Lummi (Lummi License).