AnimatedMotion
Dynamic Island
A dark pill that springs around live activity: a caption and value at rest, a progress rim traced around its edge, a glow in the activity's tone, detail when tapped.
Deploying atlas-web to production
Tap the island to open it. Escape or a tap outside closes it.
Installation
npx shadcn@latest add https://ui-collections.vercel.app/r/tide/dynamic-island.jsonAlso installs the theme, cn() and motion-scope. Packages: clsx, tailwind-merge, motion.
Usage
dynamic-island-demo.tsx
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Button } from "@/registry/kit/ui/button";
import { DynamicIsland, type IslandActivity } from "@/registry/kit/ui/dynamic-island";
import { Progress } from "@/registry/kit/ui/progress";
import { SegmentedControl } from "@/registry/kit/ui/segmented-control";
type Kind = "none" | "deploy" | "focus" | "upload";
const rocket = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden className="text-accent">
<path d="M12 15l-3-3c1.2-4.4 4.2-7.4 9-8 0 4.8-3.6 7.8-6 11zM9 12H5l2.5-3.5H11M12 15v4l3.5-2.5V13M5.5 18.5c.7-1.7 1.8-2.3 3-2" />
</svg>
);
const timer = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" aria-hidden className="text-warning">
<circle cx="12" cy="13" r="8" />
<path d="M12 9v4l2.5 2M9.5 2.5h5" />
</svg>
);
const cloud = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden className="text-info">
<path d="M7 18a4.5 4.5 0 0 1-.6-8.96A6 6 0 0 1 18 9.5a4 4 0 0 1-.5 8.5H7zM12 15v-5M9.5 12.5L12 10l2.5 2.5" />
</svg>
);
const clock = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
/** Steps from `start` while `running` and returns the current value. */
function useTicker(running: boolean, start: number, step: number, interval: number) {
const [ticks, setTicks] = useState(0);
const [wasRunning, setWasRunning] = useState(running);
if (running !== wasRunning) {
setWasRunning(running);
setTicks(0);
}
useEffect(() => {
if (!running) return;
const id = setInterval(() => setTicks((t) => t + 1), interval);
return () => clearInterval(id);
}, [running, interval]);
return start + ticks * step;
}
export default function DynamicIslandDemo() {
const [kind, setKind] = useState<Kind>("deploy");
const [expanded, setExpanded] = useState(false);
// Set only from a click, so `document` exists whenever it is true.
const [onPage, setOnPage] = useState(false);
const deploy = Math.min(100, useTicker(kind === "deploy", 38, 3, 700));
const focusLeft = Math.max(0, useTicker(kind === "focus", 1453, -1, 1000));
const uploaded = Math.min(14, useTicker(kind === "upload", 5, 1, 1400));
function choose(next: Kind) {
setKind(next);
setExpanded(false);
}
const activities: Record<Exclude<Kind, "none">, IslandActivity> = {
deploy: {
id: "deploy",
label: "Deploying atlas-web to production",
leading: rocket,
caption: "atlas-web",
trailing: `${deploy}%`,
progress: deploy / 100,
tone: deploy === 100 ? "success" : "accent",
expanded: (
<div className="grid gap-4">
<div className="flex items-start gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-accent/15 [&>svg]:size-5">{rocket}</span>
<div className="min-w-0">
<p className="font-medium">{deploy === 100 ? "atlas-web is live" : "Deploying atlas-web"}</p>
<p className="text-xs text-muted">Production · commit 7f3c2e1 by Ines Duarte</p>
</div>
<span className="ms-auto text-lg font-medium tabular-nums text-accent">{deploy}%</span>
</div>
<Progress value={deploy} tone={deploy === 100 ? "success" : "accent"} aria-label="Deploy progress" />
<div className="flex justify-end gap-2">
<Button size="sm" onClick={() => choose("none")}>
{deploy === 100 ? "Dismiss" : "Cancel deploy"}
</Button>
</div>
</div>
),
},
focus: {
id: "focus",
label: "Focus session running",
leading: timer,
caption: "Roadmap review",
trailing: clock(focusLeft),
progress: 1 - focusLeft / 1500,
tone: "warning",
expanded: (
<div className="flex items-center gap-4">
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-warning/15 [&>svg]:size-5">{timer}</span>
<div className="min-w-0">
<p className="text-xs text-muted">Focus · Q4 roadmap review</p>
<p className="font-display text-3xl tabular-nums tracking-tight text-warning">{clock(focusLeft)}</p>
</div>
<Button size="sm" className="ms-auto" onClick={() => choose("none")}>
End session
</Button>
</div>
),
},
upload: {
id: "upload",
label: "Uploading files to Brand assets",
leading: cloud,
caption: "Brand assets",
trailing: `${uploaded}/14`,
progress: uploaded / 14,
tone: "info",
// No expanded view: the pill stays compact and doesn't open.
},
};
const island = (
<DynamicIsland activity={kind === "none" ? null : activities[kind]} expanded={expanded} onExpandedChange={setExpanded} />
);
return (
<div className="flex w-full flex-col items-center gap-8">
<div className="surface relative flex h-72 w-full max-w-sm justify-center overflow-hidden rounded-card pt-3">
{!onPage && island}
<p className="absolute inset-x-0 bottom-5 px-6 text-center text-xs text-muted text-pretty">
{onPage
? "The island is pinned to the top of the page."
: kind === "upload"
? "Uploads have no detail view, so the pill stays compact."
: "Tap the island to open it. Escape or a tap outside closes it."}
</p>
</div>
{/* Portalled to <body>: a surface's backdrop-filter would otherwise trap `position: fixed`. */}
{onPage && createPortal(<div className="fixed inset-x-0 top-3 z-[60]">{island}</div>, document.body)}
<Button size="sm" aria-pressed={onPage} onClick={() => setOnPage((v) => !v)}>
{onPage ? "Put it back in the box" : "Show on top of the page"}
</Button>
<SegmentedControl<Kind>
aria-label="Live activity"
value={kind}
onValueChange={choose}
options={[
{ value: "none", label: "Idle" },
{ value: "deploy", label: "Deploy" },
{ value: "focus", label: "Focus" },
{ value: "upload", label: "Upload" },
]}
/>
</div>
);
}API
activity= null
{ id; label; leading?; caption?; trailing?; expanded? } | nullLive activity. A new `id` morphs the island; `null` rests as the empty pill.
activity.progressnumber0–1. Traces a rim around the island, clockwise from the top.
activity.tone= "accent"
"accent" | "success" | "warning" | "info" | "danger"Colour of the rim, the glow and the trailing value.
expanded / defaultExpanded= false
booleanControlled or initial open state. Needs `activity.expanded`.
onExpandedChange(expanded: boolean) => voidCalled on tap, Escape or a tap outside.
Source
components/ui/dynamic-island.tsx
"use client";
import { AnimatePresence, m } from "motion/react";
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
import { MotionScope } from "@/registry/kit/lib/motion";
import { cn } from "@/registry/kit/lib/utils";
export type IslandTone = "accent" | "success" | "warning" | "info" | "danger";
export interface IslandActivity {
/** Changing it morphs the island to the new activity. */
id: string;
/** Read to screen readers when the activity starts, and names the island button. */
label: string;
/** Start of the compact pill, usually an icon. */
leading?: React.ReactNode;
/** A few words between the slots, such as the project name. Hidden on narrow screens. */
caption?: React.ReactNode;
/** End of the compact pill, usually a short value. Coloured in the tone. */
trailing?: React.ReactNode;
/** 0–1. Traces a rim around the island, clockwise from the top. */
progress?: number;
/** Colour of the rim, the glow under the island and the trailing value. */
tone?: IslandTone;
/** Detail shown when the island is opened. Without it the pill does not open. */
expanded?: React.ReactNode;
}
export interface DynamicIslandProps {
/** The live activity to show. `null` rests as the empty pill. */
activity?: IslandActivity | null;
/** Controlled open state. */
expanded?: boolean;
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
className?: string;
}
type View = "idle" | "compact" | "expanded";
const spring = { type: "spring", stiffness: 420, damping: 32, mass: 0.9 } as const;
const radius: Record<View, number> = { idle: 18, compact: 18, expanded: 34 };
/**
* A pill that grows around live activity, after the iPhone's Dynamic Island:
* empty at rest, a compact status line while something runs, and a rounded
* sheet with detail when tapped. The box springs to the measured size of
* whatever is inside it. Unlike the phone it has no camera to dodge, so the
* middle carries a caption; progress traces a rim around the edge and the
* activity's tone glows underneath. It sits in a `data-mode="dark"` subtree on
* the theme's deepest background, so it is dark in every theme. Escape or a
* tap outside folds it back.
*/
export function DynamicIsland({
activity = null,
expanded: expandedProp,
defaultExpanded = false,
onExpandedChange,
className,
}: DynamicIslandProps) {
const [internal, setInternal] = useState(defaultExpanded);
const open = expandedProp ?? internal;
const view: View = !activity ? "idle" : open && activity.expanded ? "expanded" : "compact";
const rootRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [size, setSize] = useState<{ width: number; height: number } | null>(null);
function setOpen(next: boolean) {
setInternal(next);
onExpandedChange?.(next);
}
// Follow the content's own size, so the box springs to whatever is inside.
const contentKey = `${view}-${activity?.id ?? "none"}`;
useLayoutEffect(() => {
const el = contentRef.current;
if (!el) return;
const measure = () => setSize({ width: el.offsetWidth, height: el.offsetHeight });
measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [contentKey]);
useEffect(() => {
if (view !== "expanded") return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
const onPointer = (e: PointerEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
// setOpen only reads the latest props; re-binding per render is not needed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view]);
return (
<MotionScope>
{/* Only the pill takes pointer events, so a full-width fixed wrapper never blocks the page. */}
<div ref={rootRef} className={cn("pointer-events-none flex justify-center", className)}>
<m.div
data-mode="dark"
initial={false}
animate={size ? { ...size, borderRadius: radius[view] } : { borderRadius: radius[view] }}
transition={spring}
style={{ "--tone": `var(--color-${activity?.tone ?? "accent"})` } as CSSProperties}
className={cn(
// Opaque on purpose: a translucent raised surface turns grey over a light page.
"pointer-events-auto relative overflow-hidden border border-line bg-bg-deep text-fg transition-shadow duration-500",
activity
? "shadow-[0_12px_30px_-14px_color-mix(in_oklab,var(--tone)_40%,transparent)]"
: "shadow-[0_10px_30px_-14px_rgb(0_0_0/0.6)]",
)}
>
{activity?.progress !== undefined && (
<span
aria-hidden
className="pointer-events-none absolute inset-0 z-10 rounded-[inherit] p-[1.5px] [mask:linear-gradient(#000_0_0)_content-box_exclude,linear-gradient(#000_0_0)]"
style={{
background: `conic-gradient(var(--tone) ${Math.min(1, Math.max(0, activity.progress)) * 360}deg, color-mix(in oklab, var(--tone) 14%, transparent) 0)`,
}}
/>
)}
<AnimatePresence mode="popLayout" initial={false}>
<m.div
key={contentKey}
ref={contentRef}
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 1, scale: 1, transition: { delay: 0.06, duration: 0.25 } }}
exit={{ opacity: 0, scale: 0.92, transition: { duration: 0.12 } }}
className="w-max origin-top"
>
{view === "idle" && <div aria-hidden className="h-9 w-30" />}
{view === "compact" && activity && (
<button
type="button"
aria-label={activity.label}
aria-expanded={activity.expanded ? false : undefined}
disabled={!activity.expanded}
onClick={() => setOpen(true)}
className="flex h-9 min-w-30 cursor-pointer items-center gap-3 rounded-full px-2.5 text-xs font-medium tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-inset disabled:cursor-default"
>
<span className="flex min-w-5 items-center [&>svg]:size-5">{activity.leading}</span>
<span className="max-w-40 flex-1 truncate text-center text-muted max-sm:hidden">{activity.caption}</span>
<span className="ms-auto flex min-w-5 items-center justify-end text-(--tone) [&>svg]:size-5">{activity.trailing}</span>
</button>
)}
{view === "expanded" && activity && (
<div
role="region"
aria-label={activity.label}
className="w-[min(22rem,calc(100vw-2rem))] p-5 text-sm"
>
{activity.expanded}
</div>
)}
</m.div>
</AnimatePresence>
</m.div>
<span className="sr-only" aria-live="polite">
{activity?.label}
</span>
</div>
</MotionScope>
);
}