A black pill or MacBook-style notch that morphs between live activities (timers, uploads, an AI thinking state, notifications) with a spring layout animation. Each view is announced politely.
"use client"
import * as React from "react"
import { motion, useReducedMotion } from "motion/react"
import { BatteryFull, Check, Command, Rocket, Timer, UploadCloud, Wifi } from "lucide-react"
import { DynamicIsland, DynamicIslandView } from "@/components/ballmac/dynamic-island"
import { cn } from "@/lib/utils"
const views = [
{ value: "idle", label: "Idle" },
{ value: "thinking", label: "Thinking" },
{ value: "timer", label: "Timer" },
{ value: "upload", label: "Upload" },
{ value: "deploy", label: "Notification" },
] as const
type View = (typeof views)[number]["value"]
function Waveform() {
return (
<span aria-hidden="true" className="flex h-4 items-center gap-[3px]">
{[0.5, 0.9, 0.65, 1, 0.55].map((peak, i) => (
<motion.span
key={i}
className="h-full w-[3px] origin-center rounded-full bg-linear-to-b from-chart-1 to-chart-4"
initial={{ scaleY: 0.35 }}
animate={{ scaleY: [0.3, peak, 0.3] }}
transition={{ duration: 0.9 + i * 0.13, repeat: Infinity, ease: "easeInOut", delay: i * 0.08 }}
/>
))}
</span>
)
}
function Timecode({ seconds }: { seconds: number }) {
const m = Math.floor(seconds / 60)
const s = String(seconds % 60).padStart(2, "0")
return (
<span className="font-medium tabular-nums text-chart-3">
{m}:{s}
</span>
)
}
export default function DynamicIslandDemo() {
const [view, setView] = React.useState<View>("idle")
const [auto, setAuto] = React.useState(true)
const [seconds, setSeconds] = React.useState(272)
const reduceMotion = useReducedMotion()
React.useEffect(() => {
if (!auto || reduceMotion) return
const id = window.setTimeout(() => {
setView((v) => views[(views.findIndex((x) => x.value === v) + 1) % views.length].value)
}, view === "idle" ? 1600 : 3200)
return () => window.clearTimeout(id)
}, [auto, view, reduceMotion])
React.useEffect(() => {
const id = window.setInterval(() => setSeconds((s) => (s > 0 ? s - 1 : 300)), 1000)
return () => window.clearInterval(id)
}, [])
return (
<div className="flex w-full max-w-[640px] flex-col items-center gap-5">
{/* A MacBook screen, cropped to the top edge. */}
<div className="relative w-full rounded-t-[26px] bg-black p-[7px] pb-0 shadow-[0_0_0_1px_rgb(0_0_0/0.08),0_30px_60px_-30px_rgb(0_0_0/0.5)] [mask-image:linear-gradient(to_bottom,black_75%,transparent)] dark:shadow-[0_0_0_1px_rgb(255_255_255/0.12)]">
<div className="relative h-[220px] overflow-hidden rounded-t-[19px] sm:h-[240px]">
<div aria-hidden="true" className="absolute inset-0 dark:brightness-[0.6]">
<div className="absolute inset-0 bg-linear-to-br from-chart-1 via-chart-4 to-chart-5" />
<div className="absolute -inset-x-1/4 top-[45%] h-full rounded-[50%] bg-white/15 blur-2xl" />
</div>
{/* Menu bar, split by the notch. */}
<div
aria-hidden="true"
className="absolute inset-x-0 top-0 flex h-7 items-center justify-between bg-white/30 px-3 text-[12px] font-medium text-black/80 backdrop-blur-xl dark:bg-black/25 dark:text-white/90"
>
<div className="flex items-center gap-3.5">
<Command className="size-3.5" />
<span className="font-bold">Acme</span>
<span className="max-sm:hidden">File</span>
<span className="max-sm:hidden">Edit</span>
<span className="max-md:hidden">View</span>
</div>
<div className="flex items-center gap-3">
<Wifi className="size-3.5 max-sm:hidden" />
<BatteryFull className="size-4" />
<span className="tabular-nums">
<span className="max-md:hidden">Tue Sep 29 </span>9:41
</span>
</div>
</div>
<DynamicIsland view={view} shape="notch" className="absolute inset-x-0 top-0 z-10">
<DynamicIslandView value="idle" radius={10} className="h-7 w-[120px] justify-center sm:w-[150px]">
<span aria-hidden="true" className="size-2 rounded-full bg-white/[0.07] ring-1 ring-white/10" />
</DynamicIslandView>
<DynamicIslandView value="thinking" radius={14} label="Assistant is thinking" className="h-8 w-[250px] justify-between gap-3 px-3 sm:w-[310px]">
<span className="flex items-center gap-2">
<motion.span
aria-hidden="true"
className="size-4 rounded-full blur-[1px]"
style={{ background: "conic-gradient(from 0deg, var(--chart-1), var(--chart-4), var(--chart-5), var(--chart-2), var(--chart-1))" }}
animate={{ rotate: 360 }}
transition={{ duration: 2.4, repeat: Infinity, ease: "linear" }}
/>
<motion.span
className="bg-[linear-gradient(90deg,rgb(255_255_255/0.5)_0%,rgb(255_255_255/0.5)_35%,white_50%,rgb(255_255_255/0.5)_65%,rgb(255_255_255/0.5)_100%)] bg-size-[200%_100%] bg-clip-text text-[13px] font-medium text-transparent"
initial={{ backgroundPosition: "100% 0" }}
animate={reduceMotion ? undefined : { backgroundPosition: ["100% 0", "-100% 0"] }}
transition={{ duration: 1.8, repeat: Infinity, ease: "linear" }}
>
Thinking…
</motion.span>
</span>
<Waveform />
</DynamicIslandView>
<DynamicIslandView value="timer" radius={12} label="Focus timer running" className="h-7 w-[230px] justify-between px-3 text-[13px] sm:w-[280px]">
<span className="flex items-center gap-1.5 text-chart-3">
<Timer className="size-4" aria-hidden="true" />
<span className="font-medium max-sm:sr-only">Focus</span>
</span>
<Timecode seconds={seconds} />
</DynamicIslandView>
<DynamicIslandView value="upload" radius={24} label="Uploading 5 files" className="w-[300px] flex-col items-stretch gap-3 px-4 pt-9 pb-4 sm:w-[360px]">
<div className="flex items-center gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-[10px] bg-linear-to-b from-chart-1/80 to-chart-1">
<UploadCloud className="size-5" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-semibold">Uploading to Acme Drive</span>
<span className="block truncate text-xs text-white/55">3 of 5 files · 1.2 GB left</span>
</span>
<span className="text-xs font-medium tabular-nums text-white/70">68%</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-white/15">
<motion.div
className="h-full origin-left rounded-full bg-linear-to-r from-chart-1 to-chart-2"
initial={{ scaleX: 0.2 }}
animate={{ scaleX: 0.68 }}
transition={{ duration: 2.4, ease: [0.22, 1, 0.36, 1] }}
/>
</div>
</DynamicIslandView>
<DynamicIslandView value="deploy" radius={24} label="Deploy succeeded: acme-web is live" className="w-[300px] gap-3 px-3.5 pt-9 pb-3.5 sm:w-[360px]">
<span className="relative flex size-10 shrink-0 items-center justify-center rounded-[11px] bg-linear-to-b from-chart-2/80 to-chart-2">
<Rocket className="size-5" aria-hidden="true" />
<span className="absolute -end-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-white text-black ring-2 ring-black">
<Check className="size-2.5" strokeWidth={3} aria-hidden="true" />
</span>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-semibold">Deploy succeeded</span>
<span className="block truncate text-xs text-white/55">acme-web is live · 42s</span>
</span>
<button
type="button"
className="h-7 shrink-0 rounded-full bg-white/15 px-3 text-xs font-medium outline-none transition-colors hover:bg-white/25 focus-visible:ring-2 focus-visible:ring-white/60"
>
Open
</button>
</DynamicIslandView>
</DynamicIsland>
</div>
</div>
<div role="group" aria-label="Island state" className="flex flex-wrap justify-center gap-1.5">
{views.map((v) => (
<button
key={v.value}
type="button"
aria-pressed={view === v.value}
onClick={() => {
setAuto(false)
setView(v.value)
}}
className={cn(
"h-7 rounded-full border px-3 text-xs font-medium outline-none transition-[color,background-color,border-color] duration-150 focus-visible:ring-[3px] focus-visible:ring-ring/50",
view === v.value ? "border-transparent bg-foreground text-background" : "bg-background text-muted-foreground hover:text-foreground"
)}
>
{v.label}
</button>
))}
</div>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/dynamic-islandInstall the dependencies.
$ pnpm add motion@^12Copy the source into your project.
components/ballmac/dynamic-island.tsx// Ballmac UI: Dynamic Island. https://ui.ballmac.com/components/dynamic-island "use client" import * as React from "react" import { AnimatePresence, MotionConfig, motion, type Transition } from "motion/react" import { cn } from "@/lib/utils" /** The island's spring: fast, with the small overshoot that makes it feel alive. */ const islandSpring: Transition = { type: "spring", stiffness: 420, damping: 30, mass: 0.9 } type DynamicIslandViewProps = React.ComponentProps<"div"> & { /** Identifies this view; the island shows the view whose value matches its `view` prop. */ value: string /** Corner radius in px while this view is showing. Around half the height gives a pill. */ radius?: number /** Announced to screen readers when this view appears (the visual content is not re-read on every change). */ label?: string } /** * One state of the island. Size it with className (e.g. `h-9 w-32`, or padding around content): * the island morphs to whatever size the active view has. */ function DynamicIslandView({ value, radius: _radius, label: _label, className, ...props }: DynamicIslandViewProps) { return <div data-slot="dynamic-island-view" data-value={value} className={cn("flex items-center", className)} {...props} /> } type DynamicIslandProps = React.ComponentProps<"div"> & { /** Value of the view to show. Changing it morphs the island to the new view. */ view: string /** "pill" floats with all corners rounded; "notch" hangs from the top edge with square top corners, like a MacBook notch. */ shape?: "pill" | "notch" } /** * A black surface that morphs between views with a spring layout animation, like the iPhone Dynamic Island * or a MacBook notch app. Pass `<DynamicIslandView value="…">` children and choose one with `view`. */ function DynamicIsland({ view, shape = "pill", className, children, ...props }: DynamicIslandProps) { const views = React.Children.toArray(children).filter( (child): child is React.ReactElement<DynamicIslandViewProps> => React.isValidElement(child) && typeof (child.props as DynamicIslandViewProps).value === "string" ) const active = views.find((child) => child.props.value === view) ?? views[0] const radius = active?.props.radius ?? 22 const notch = shape === "notch" return ( <MotionConfig reducedMotion="user"> <div data-slot="dynamic-island-root" data-shape={shape} className={cn("flex justify-center", className)} {...props}> <motion.div layout data-slot="dynamic-island" data-view={active?.props.value} initial={false} animate={{ borderTopLeftRadius: notch ? 0 : radius, borderTopRightRadius: notch ? 0 : radius, borderBottomLeftRadius: radius, borderBottomRightRadius: radius, }} transition={islandSpring} className={cn( "relative isolate overflow-hidden bg-black text-white antialiased", notch ? "shadow-[0_10px_30px_-10px_rgb(0_0_0/0.55)]" : "shadow-[0_0_0_1px_rgb(255_255_255/0.06),0_10px_30px_-10px_rgb(0_0_0/0.55)]" )} > <AnimatePresence mode="popLayout" initial={false}> {active ? ( <motion.div key={active.props.value} layout="position" initial={{ opacity: 0, scale: 0.92, filter: "blur(6px)" }} animate={{ opacity: 1, scale: 1, filter: "blur(0px)", transition: { ...islandSpring, delay: 0.06 } }} exit={{ opacity: 0, scale: 0.92, filter: "blur(6px)", transition: { duration: 0.14 } }} > {active} </motion.div> ) : null} </AnimatePresence> </motion.div> <span role="status" aria-live="polite" aria-atomic="true" data-slot="dynamic-island-announcer" className="sr-only"> {active?.props.label ?? ""} </span> </div> </MotionConfig> ) } export { DynamicIsland, DynamicIslandView, islandSpring, type DynamicIslandProps, type DynamicIslandViewProps }Update the import paths to match your project setup.
Usage
import { DynamicIsland, DynamicIslandView, islandSpring } from "@/components/ballmac/dynamic-island"The full example is in the Code tab above.
Examples
Floating pill
"use client"
import * as React from "react"
import { Mic, Phone, PhoneOff } from "lucide-react"
import { DynamicIsland, DynamicIslandView } from "@/components/ballmac/dynamic-island"
export default function DynamicIslandPill() {
const [view, setView] = React.useState<"idle" | "call" | "expanded">("call")
return (
<div className="flex w-full max-w-sm flex-col items-center gap-4">
<DynamicIsland view={view}>
<DynamicIslandView value="idle" radius={18} className="h-9 w-32" />
<DynamicIslandView value="call" radius={18} label="Call in progress" className="h-9 w-56 justify-between px-2">
<span className="flex size-6 items-center justify-center rounded-full bg-chart-2 text-black">
<Phone className="size-3.5" aria-hidden="true" />
</span>
<span className="pe-1 text-[13px] font-medium tabular-nums text-chart-2">12:04</span>
</DynamicIslandView>
<DynamicIslandView value="expanded" radius={34} label="Call with Design Review, 12 minutes" className="w-[min(340px,calc(100vw-3rem))] flex-col items-stretch gap-4 p-4">
<div className="flex items-center gap-3">
<span className="flex size-11 items-center justify-center rounded-full bg-linear-to-b from-chart-4/80 to-chart-4 text-sm font-semibold">DR</span>
<span className="min-w-0 flex-1">
<span className="block text-xs text-white/55">Acme Meet</span>
<span className="block truncate text-[15px] font-semibold">Design Review</span>
</span>
<span className="text-[13px] tabular-nums text-chart-2">12:04</span>
</div>
<div className="flex justify-end gap-2">
<button type="button" aria-label="Mute" className="flex size-10 items-center justify-center rounded-full bg-white/15 outline-none hover:bg-white/25 focus-visible:ring-2 focus-visible:ring-white/60">
<Mic className="size-5" aria-hidden="true" />
</button>
<button
type="button"
aria-label="End call"
onClick={() => setView("idle")}
className="flex size-10 items-center justify-center rounded-full bg-destructive outline-none hover:brightness-110 focus-visible:ring-2 focus-visible:ring-white/60"
>
<PhoneOff className="size-5" aria-hidden="true" />
</button>
</div>
</DynamicIslandView>
</DynamicIsland>
<div className="flex gap-2">
{(["idle", "call", "expanded"] as const).map((v) => (
<button
key={v}
type="button"
aria-pressed={view === v}
onClick={() => setView(v)}
className="h-8 rounded-md border bg-background px-3 text-sm capitalize outline-none aria-pressed:bg-foreground aria-pressed:text-background focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{v}
</button>
))}
</div>
</div>
)
}API reference
<DynamicIslandView>
| Prop | Type | Default |
|---|---|---|
value*Identifies this view; the island shows the view whose value matches its `view` prop. | string | — |
radiusCorner radius in px while this view is showing. Around half the height gives a pill. | number | — |
labelAnnounced to screen readers when this view appears (the visual content is not re-read on every change). | string | — |
<DynamicIsland>
| Prop | Type | Default |
|---|---|---|
view*Value of the view to show. Changing it morphs the island to the new view. | string | — |
shape"pill" floats with all corners rounded; "notch" hangs from the top edge with square top corners, like a MacBook notch. | "pill" | "notch" | "pill" |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| — | The active view's label is announced in a polite live region; interactive content inside a view is reachable with Tab |
Use with AI
<DynamicIsland view={state}> renders whichever <DynamicIslandView value="…"> child matches and springs its size and corner radius to fit. Size each view with className (h-/w-/padding); set radius per view; label is announced via a polite live region. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Dynamic Island (@ballmac/dynamic-island) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Showing one live background activity at a time: an upload, a build, a timer, an AI response in progress
- A compact status surface pinned to the top of an app or a product mockup
- Landing-page heroes that demo a Mac notch or iPhone app
Not for
- Several notifications that need to stay visible together (use notification-stack)
- Long or critical messages that must not disappear (use a dialog or an inline alert)
Registry JSON: https://ui.ballmac.com/r/dynamic-island.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- shadcn/utils
Pairs well with
Dock
A macOS-style dock whose icons grow with spring physics as the pointer approaches and push their neighbors apart. Labels, running dots, a launch bounce and arrow-key navigation included.
Control Center
The macOS Control Center panel in frosted glass: toggle tiles with round badges, grouped Wi-Fi, Bluetooth and AirDrop rows, pill sliders with the icon in the track, and a Now Playing tile. Real toggle buttons and Radix sliders.
Desktop Icons
A macOS desktop of icons: box-select with the pointer, drag to move with snapping to grid cells, double-click or Return to open, arrow keys to move focus and Alt plus arrows to move an icon.
Finder Window
A working mini Finder: sidebar shortcuts, back and forward history, icon and list views, search, click, shift and command selection, keyboard navigation and a path bar, on a macOS window.