Motion
Dynamic Island
An iOS-style Dynamic Island with eleven activities that collapse and reopen as you switch between them.
Hover to preview · click to pin
import { useState } from "react";
import {
DynamicIsland,
islandStateLabels,
islandStates,
type IslandState,
} from "@/components/ui/dynamic-island";
export default function DynamicIslandDemo() {
const [pinned, setPinned] = useState<IslandState>("idle");
const [previewed, setPreviewed] = useState<IslandState | null>(null);
// Hovering a button previews that activity; clicking pins it.
const active = previewed ?? pinned;
return (
<div className="flex w-full max-w-xl flex-col items-center gap-5">
{/* Fixed height and a dark stage: tall activities never shift the layout,
and the near-black island reads in either theme. */}
<div className="grid h-50 w-full place-items-center rounded-2xl bg-[radial-gradient(120%_120%_at_50%_0%,#2b2b33,#0f0f13)]">
<DynamicIsland state={active} timerFrom={60} />
</div>
<div className="flex flex-wrap justify-center gap-1.5">
{islandStates.map((option) => (
<button
key={option}
type="button"
onClick={() => setPinned(option)}
onMouseEnter={() => setPreviewed(option)}
onMouseLeave={() => setPreviewed(null)}
onFocus={() => setPreviewed(option)}
onBlur={() => setPreviewed(null)}
aria-pressed={pinned === option}
className={
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors " +
(active === option
? "border-transparent bg-ink text-canvas"
: "border-line bg-surface text-muted hover:text-ink")
}
>
{islandStateLabels[option]}
</button>
))}
</div>
<p className="text-xs text-faint">Hover to preview · click to pin</p>
</div>
);
}Installation
1. Install the dependencies.
terminal npm install motion clsx tailwind-merge2. Add the
cnhelper, if you don't already have it.lib/utils.ts import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into your project.
components/ui/dynamic-island.tsx "use client"; import { AnimatePresence, motion } from "motion/react"; import { useEffect, useMemo, useState, type ReactNode } from "react"; import { cn } from "@/lib/utils"; export const islandStates = [ "idle", "ring", "timer", "record", "music", "airdrop", "call", "screenrecord", "faceid", "charging", "maps", ] as const; export type IslandState = (typeof islandStates)[number]; export const islandStateLabels: Record<IslandState, string> = { idle: "Idle", ring: "Ring", timer: "Timer", record: "Voice memo", music: "Music", airdrop: "AirDrop", call: "Call", screenrecord: "Screen record", faceid: "Face ID", charging: "Charging", maps: "Maps", }; type Shape = { width: number; height: number; radius: number }; /** The island is sized per state; everything else springs between these. */ const shapes: Record<IslandState, Shape> = { idle: { width: 122, height: 36, radius: 18 }, ring: { width: 194, height: 36, radius: 18 }, timer: { width: 284, height: 66, radius: 24 }, record: { width: 284, height: 38, radius: 19 }, music: { width: 284, height: 145, radius: 30 }, airdrop: { width: 284, height: 145, radius: 30 }, call: { width: 284, height: 42, radius: 21 }, screenrecord: { width: 284, height: 66, radius: 24 }, faceid: { width: 194, height: 36, radius: 18 }, charging: { width: 182, height: 36, radius: 18 }, maps: { width: 284, height: 66, radius: 24 }, }; const spring = { type: "spring" as const, stiffness: 420, damping: 32, mass: 0.9 }; /** * `corner-shape` gives true squircle corners in browsers that support it and is * ignored everywhere else, so the plain border radius stays as the fallback. */ const squircle = { cornerShape: "squircle" } as unknown as React.CSSProperties; type DynamicIslandProps = { /** Which activity the island is showing. */ state?: IslandState; /** Seconds the timer state counts down from. */ timerFrom?: number; /** Bar count in the voice-memo visualiser. */ recordBars?: number; className?: string; }; export function DynamicIsland({ state = "idle", timerFrom = 60, recordBars = 10, className, }: DynamicIslandProps) { // Every change routes through `idle` first, so the island collapses and then // reopens into its new shape the way iOS does. const [rendered, setRendered] = useState<IslandState>(state); useEffect(() => { if (state === rendered) return; setRendered("idle"); const id = window.setTimeout(() => setRendered(state), 240); return () => window.clearTimeout(id); // Intentionally keyed on the incoming state only — `rendered` is the output. // eslint-disable-next-line react-hooks/exhaustive-deps }, [state]); const shape = shapes[rendered]; return ( <motion.div role="status" aria-live="polite" aria-label={`Dynamic island — ${islandStateLabels[rendered]}`} animate={{ width: shape.width, height: shape.height, borderRadius: shape.radius, }} transition={spring} // Width and height are repeated in `style` so the island is correctly // sized in the server-rendered markup, before motion hydrates. style={{ width: shape.width, height: shape.height, borderRadius: shape.radius, ...squircle, }} className={cn( "relative overflow-hidden bg-[#050505] text-white select-none", "shadow-[0_10px_30px_-12px_rgb(0_0_0/0.8)]", className, )} > {/* Fixed to the target size so content never reflows mid-animation. */} <div className="absolute top-0 left-1/2 -translate-x-1/2" style={{ width: shape.width, height: shape.height }} > <AnimatePresence mode="wait" initial={false}> <motion.div key={rendered} initial={{ opacity: 0, filter: "blur(6px)", y: 4 }} animate={{ opacity: 1, filter: "blur(0px)", y: 0 }} exit={{ opacity: 0, filter: "blur(6px)", y: -4 }} transition={{ duration: 0.18, ease: "easeOut" }} className="h-full w-full" > {rendered === "ring" && <Ring />} {rendered === "timer" && <Timer from={timerFrom} />} {rendered === "record" && <Record bars={recordBars} />} {rendered === "music" && <Music />} {rendered === "airdrop" && <AirDrop />} {rendered === "call" && <Call />} {rendered === "screenrecord" && <ScreenRecord />} {rendered === "faceid" && <FaceId />} {rendered === "charging" && <Charging />} {rendered === "maps" && <Maps />} </motion.div> </AnimatePresence> </div> </motion.div> ); } /* -------------------------------------------------------------------------- */ /* Hooks */ /* -------------------------------------------------------------------------- */ /** Seconds elapsed since mount, pausable. */ function useTicker(running = true) { const [seconds, setSeconds] = useState(0); useEffect(() => { if (!running) return; const id = window.setInterval(() => setSeconds((value) => value + 1), 1000); return () => window.clearInterval(id); }, [running]); return seconds; } function clock(totalSeconds: number, padMinutes = false) { const safe = Math.max(0, Math.floor(totalSeconds)); const minutes = Math.floor(safe / 60); const seconds = safe % 60; const head = padMinutes ? String(minutes).padStart(2, "0") : String(minutes); return `${head}:${String(seconds).padStart(2, "0")}`; } /* -------------------------------------------------------------------------- */ /* States */ /* -------------------------------------------------------------------------- */ function Ring() { // The state demonstrates itself by flipping between ringer on and silent. const [silent, setSilent] = useState(false); useEffect(() => { const id = window.setInterval(() => setSilent((value) => !value), 2600); return () => window.clearInterval(id); }, []); return ( <div className="flex h-full items-center justify-between pr-4 pl-2.5"> <div className="relative grid size-7 place-items-center"> <AnimatePresence> {silent && ( <motion.span key="chip" initial={{ opacity: 0, scaleX: 0.4 }} animate={{ opacity: 1, scaleX: 1 }} exit={{ opacity: 0, scaleX: 0.4 }} transition={spring} className="absolute -left-[1px] -right-[11px] inset-y-[3px] rounded-full bg-[#ff3b30]" /> )} </AnimatePresence> <motion.span className="relative" animate={silent ? { rotate: 0 } : { rotate: [0, -16, 13, -9, 6, -3, 0] }} transition={ silent ? { duration: 0.2 } : { duration: 0.9, repeat: Infinity, repeatDelay: 1.3, ease: "easeInOut" } } > {silent ? <BellOffIcon /> : <BellIcon />} </motion.span> </div> <div className="relative h-4 w-14 overflow-hidden"> <AnimatePresence mode="wait" initial={false}> <motion.span key={silent ? "silent" : "ring"} initial={{ y: 14, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: -14, opacity: 0 }} transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }} className={cn( "absolute inset-0 text-right text-[13px] font-medium", silent ? "text-[#ff453a]" : "text-white/85", )} > {silent ? "Silent" : "Ring"} </motion.span> </AnimatePresence> </div> </div> ); } function Timer({ from }: { from: number }) { const [running, setRunning] = useState(true); const [remaining, setRemaining] = useState(from); useEffect(() => { if (!running) return; const id = window.setInterval( () => setRemaining((value) => (value <= 0 ? 0 : value - 1)), 1000, ); return () => window.clearInterval(id); }, [running]); return ( <div className="flex h-full items-center justify-between px-3"> <div className="flex items-center gap-2.5"> <button type="button" onClick={() => setRunning((value) => !value)} aria-label={running ? "Pause timer" : "Resume timer"} className="grid size-10 place-items-center rounded-full bg-[#8f6b1f] text-[#f5b70a] transition-transform active:scale-92" > {running ? <PauseIcon /> : <PlayIcon />} </button> <button type="button" onClick={() => { setRunning(false); setRemaining(from); }} aria-label="Cancel timer" className="grid size-10 place-items-center rounded-full bg-white/12 text-white transition-transform active:scale-92" > <CloseIcon /> </button> </div> <div className="flex items-baseline gap-2 pr-1 text-[#f5b70a]"> <span className="text-[15px]">Timer</span> <span className="text-[30px] leading-none font-light tabular-nums"> {clock(remaining)} </span> </div> </div> ); } function Record({ bars }: { bars: number }) { const elapsed = useTicker(); return ( <div className="flex h-full items-center justify-between px-4"> <Waveform count={bars} className="bg-[#ff3b30]" maxHeight={16} /> <span className="text-[14px] tabular-nums text-[#ff453a]"> {clock(elapsed, true)} </span> </div> ); } function Music() { const elapsed = useTicker(); const total = 120; const played = Math.min(elapsed + 8, total); return ( <div className="flex h-full flex-col justify-between p-3.5"> <div className="flex items-start gap-3"> <div className="size-12 shrink-0 rounded-xl bg-[linear-gradient(150deg,#ff7a45,#f472b6_45%,#818cf8)]" /> <div className="min-w-0 flex-1 pt-0.5 text-[15px] leading-[1.15] font-semibold"> <p className="truncate">Glow</p> <p className="truncate text-white/55">Echo</p> </div> <Waveform count={5} className="bg-[#4f8ef7]" maxHeight={12} width={3} /> </div> <div className="flex items-center gap-2 text-[10px] tabular-nums text-white/45"> <span>{clock(played, true)}</span> <div className="h-1 flex-1 overflow-hidden rounded-full bg-white/18"> <motion.div className="h-full rounded-full bg-white/70" animate={{ width: `${(played / total) * 100}%` }} transition={{ ease: "linear", duration: 0.9 }} /> </div> <span>-{clock(total - played, true)}</span> </div> <div className="flex items-center justify-center gap-9 text-white"> <button type="button" aria-label="Previous track" className="active:scale-90"> <SkipIcon /> </button> <button type="button" aria-label="Play" className="active:scale-90"> <PlayIcon size={18} /> </button> <button type="button" aria-label="Next track" className="rotate-180 active:scale-90"> <SkipIcon /> </button> </div> </div> ); } function AirDrop() { return ( <div className="flex h-full flex-col justify-between p-3.5"> <div className="flex items-start gap-3"> <div className="min-w-0 flex-1"> <AirDropIcon /> <p className="mt-1 text-[15px] leading-tight font-semibold">AirDrop</p> <p className="text-[12px] leading-snug text-white/55"> Gxuri would like to share </p> <p className="text-[12px] leading-snug text-white/55">23 photos</p> </div> <div className="size-16 shrink-0 rounded-xl bg-[linear-gradient(165deg,#5b8def_0%,#8fb8d8_38%,#c2703f_62%,#2f5d3a_100%)]" /> </div> <div className="grid grid-cols-2 gap-2"> <button type="button" className="rounded-full bg-white/16 py-1.5 text-[13px] font-medium transition-transform active:scale-97" > Decline </button> <button type="button" className="rounded-full bg-[#0a6cff] py-1.5 text-[13px] font-medium transition-transform active:scale-97" > Accept </button> </div> </div> ); } function Call() { const elapsed = useTicker(); return ( <div className="flex h-full items-center justify-between px-4"> <div className="flex items-center gap-2 text-[#30d158]"> <PhoneIcon /> <span className="text-[14px] tabular-nums">{clock(elapsed, true)}</span> </div> <Waveform count={22} className="bg-[#30d158]" maxHeight={14} width={2} gap={2} /> </div> ); } function ScreenRecord() { const elapsed = useTicker(); return ( <div className="flex h-full items-center justify-between px-4"> <div> <div className="flex items-center gap-1.5"> <motion.span className="size-2 rounded-full bg-[#ff453a]" animate={{ opacity: [1, 0.35, 1] }} transition={{ duration: 1.8, repeat: Infinity, ease: "easeInOut" }} /> <span className="text-[14px] tabular-nums text-[#ff453a]"> {clock(elapsed)} </span> </div> <p className="mt-0.5 text-[13px] text-white/90">Screen Recording</p> </div> <button type="button" aria-label="Stop screen recording" className="grid size-9 place-items-center rounded-full border-2 border-white/85 transition-transform active:scale-92" > <span className="size-3.5 rounded-[3px] bg-[#ff3b30]" /> </button> </div> ); } function FaceId() { return ( <div className="flex h-full items-center justify-between pr-4 pl-3"> <motion.span animate={{ opacity: [0.55, 1, 0.55] }} transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }} className="text-white" > <FaceIdIcon /> </motion.span> <span className="text-[13px] font-medium text-white/85">Face ID</span> </div> ); } function Charging() { return ( <div className="flex h-full items-center justify-between pr-4 pl-3"> <span className="text-[#30d158]"> <BoltIcon /> </span> <div className="flex items-center gap-2"> <span className="text-[13px] font-medium tabular-nums text-white/85">80%</span> <div className="relative h-3.5 w-7 rounded-[4px] border border-white/40 p-[2px]"> <motion.div className="h-full rounded-[1.5px] bg-[#30d158]" animate={{ width: ["55%", "85%", "55%"] }} transition={{ duration: 2.4, repeat: Infinity, ease: "easeInOut" }} /> <span className="absolute top-1/2 -right-[3px] h-1.5 w-[2px] -translate-y-1/2 rounded-r-[1px] bg-white/40" /> </div> </div> </div> ); } function Maps() { return ( <div className="flex h-full items-center justify-between px-3.5"> <div className="flex items-center gap-3"> <div className="grid size-10 place-items-center rounded-xl bg-[#0a84ff] text-white"> <TurnIcon /> </div> <div className="leading-tight"> <p className="text-[14px] font-semibold">Turn right</p> <p className="text-[12px] text-white/55">onto Market St</p> </div> </div> <span className="text-[17px] font-medium tabular-nums text-[#30d158]">300 ft</span> </div> ); } /* -------------------------------------------------------------------------- */ /* Pieces */ /* -------------------------------------------------------------------------- */ function Waveform({ count, className, maxHeight, width = 3, gap = 3, }: { count: number; className?: string; maxHeight: number; width?: number; gap?: number; }) { // Stable per-bar rhythm so the visualiser looks organic rather than uniform. const seeds = useMemo( () => Array.from({ length: count }, (_, index) => ({ duration: 0.7 + ((index * 37) % 60) / 100, delay: ((index * 53) % 70) / 100, peak: 0.45 + ((index * 29) % 55) / 100, })), [count], ); return ( <div className="flex items-center" style={{ gap, height: maxHeight }}> {seeds.map((seed, index) => ( <motion.span key={index} className={cn("rounded-full", className)} style={{ width }} animate={{ height: [ maxHeight * 0.25, maxHeight * seed.peak, maxHeight * 0.35, maxHeight, maxHeight * 0.3, ], }} transition={{ duration: seed.duration * 2, delay: seed.delay, repeat: Infinity, repeatType: "mirror", ease: "easeInOut", }} /> ))} </div> ); } /* -------------------------------------------------------------------------- */ /* Icons */ /* -------------------------------------------------------------------------- */ function Svg({ children, size = 16, ...rest }: { children: ReactNode; size?: number } & React.SVGProps<SVGSVGElement>) { return ( <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...rest} > {children} </svg> ); } function BellIcon() { return ( <Svg size={17}> <path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" /> <path d="M13.7 21a2 2 0 0 1-3.4 0" /> </Svg> ); } function BellOffIcon() { return ( <Svg size={17}> <path d="M18 8a6 6 0 0 0-9.3-5" /> <path d="M6.3 6.3A6 6 0 0 0 6 8c0 7-3 9-3 9h14" /> <path d="M13.7 21a2 2 0 0 1-3.4 0" /> <path d="m2 2 20 20" /> </Svg> ); } function PauseIcon() { return ( <Svg size={16} fill="currentColor" stroke="none"> <rect x="6" y="4" width="4" height="16" rx="1.5" /> <rect x="14" y="4" width="4" height="16" rx="1.5" /> </Svg> ); } function PlayIcon({ size = 16 }: { size?: number }) { return ( <Svg size={size} fill="currentColor" stroke="none"> <path d="M8 5.2a1 1 0 0 1 1.5-.87l9 6.8a1 1 0 0 1 0 1.74l-9 6.8A1 1 0 0 1 8 18.8Z" /> </Svg> ); } function SkipIcon() { return ( <Svg size={18} fill="currentColor" stroke="none"> <path d="M11 6.6a.8.8 0 0 1 1.2-.7l7 5.4a.8.8 0 0 1 0 1.4l-7 5.4a.8.8 0 0 1-1.2-.7Z" /> <path d="M3 6.6a.8.8 0 0 1 1.2-.7l7 5.4a.8.8 0 0 1 0 1.4l-7 5.4a.8.8 0 0 1-1.2-.7Z" /> </Svg> ); } function CloseIcon() { return ( <Svg size={16}> <path d="M18 6 6 18M6 6l12 12" /> </Svg> ); } function PhoneIcon() { return ( <Svg size={15} fill="currentColor" stroke="none"> <path d="M6.6 3.3a1.6 1.6 0 0 1 2.2.5l1.3 2a1.6 1.6 0 0 1-.3 2.1l-1 .8a10 10 0 0 0 4.5 4.5l.8-1a1.6 1.6 0 0 1 2.1-.3l2 1.3a1.6 1.6 0 0 1 .5 2.2l-1 1.6a2.4 2.4 0 0 1-2.8 1A16.6 16.6 0 0 1 5 8.7a2.4 2.4 0 0 1 1-2.8Z" /> </Svg> ); } function AirDropIcon() { return ( <svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <path d="M5.5 13.5a7.5 7.5 0 0 1 13 0" stroke="#0a84ff" strokeWidth="2" strokeLinecap="round" /> <path d="M8.4 16.4a4 4 0 0 1 7.2 0" stroke="#0a84ff" strokeWidth="2" strokeLinecap="round" /> <circle cx="12" cy="19.4" r="1.6" fill="#0a84ff" /> </svg> ); } function FaceIdIcon() { return ( <Svg size={18} strokeWidth={1.8}> <path d="M4 8V6a2 2 0 0 1 2-2h2M16 4h2a2 2 0 0 1 2 2v2M20 16v2a2 2 0 0 1-2 2h-2M8 20H6a2 2 0 0 1-2-2v-2" /> <path d="M9 10v1.5M15 10v1.5M12 10v3.2M9.5 16a3.4 3.4 0 0 0 5 0" /> </Svg> ); } function BoltIcon() { return ( <Svg size={17} fill="currentColor" stroke="none"> <path d="M13.4 2.2a.5.5 0 0 1 .9.42L13 10h4.6a.6.6 0 0 1 .47.97l-8 10.5a.5.5 0 0 1-.88-.42L10.5 14H6a.6.6 0 0 1-.47-.97Z" /> </Svg> ); } function TurnIcon() { return ( <Svg size={18} strokeWidth={2.2}> <path d="M6 19v-6a4 4 0 0 1 4-4h7" /> <path d="m14 6 3.5 3L14 12" /> </Svg> ); }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| state | IslandState | "idle" | Which activity to show. Changing it collapses to idle, then reopens. |
| timerFrom | number | 60 | Seconds the timer activity counts down from. |
| recordBars | number | 10 | Bar count in the voice-memo visualiser. |
| className | string | — | Merged onto the island itself. |