Mobile

Dynamic Island

A notch-anchored live-activity pill that morphs between idle, compact, minimal and expanded presentations.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * The four live-activity presentations. `idle` / `compact` / `minimal` are the collapsed forms the
 * system picks between; `expanded` is the grown card.
 */
export type DynamicIslandPresentation = "idle" | "compact" | "minimal" | "expanded"

/** Pill geometry in px. */
const COLLAPSED_HEIGHT = 40

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/dynamic-island.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "DynamicIsland" component with lucide-react
icons: a live activity that lives in the sensor housing at the top of a phone
screen and morphs between the four presentations the system can pick from. It is
a phone control on purpose — anchored to the top safe-area inset, laid out around
a camera cutout, driven by a vertical thumb gesture.

Contract
- type DynamicIslandPresentation = "idle" | "compact" | "minimal" | "expanded".
  idle / compact / minimal are the collapsed forms; expanded is the grown card.
- <DynamicIsland presentation? defaultPresentation="compact" onPresentationChange?
  leading? trailing? title? status? progress? actions? expandedWidth=320
  interactive=true gestures=true collapseOnOutsidePointer=true label="Live activity"
  className? ...divProps> children </DynamicIsland>, forwardRef to the root div,
  rest props spread onto it. Controlled when `presentation` is passed, uncontrolled
  otherwise; onPresentationChange fires for taps, keys and swipes alike, so a
  controlled consumer sees every one of them.
- leading is one glyph (left of the housing when compact, alone inside the ring
  when minimal, inside a fixed chip when expanded). trailing is a short value — a
  timer, a count. title / status are the card's two text lines, children is the
  card body, actions is its button row.
- progress is 0..1, clamped; a non-finite value means "no progress" rather than a
  NaN width, and undefined hides the ring and the bar entirely.
- Export DynamicIslandAction (variant "solid" | "ghost", default ghost): a button
  sized min-h-11 for the inverted surface inside the card. Every onClick belongs to
  the consumer — the island never acts on its own behalf.

Behavior
- Geometry is measured, never hard-coded per app: one ResizeObserver watches the
  screen (for clamping), the collapsed row (its natural width IS the pill width)
  and the card (its natural height IS the grown height). Both presentations stay in
  the DOM at their own natural size the whole time — the collapsed row centred, the
  card pinned to its final width — and only the shell animates its width and height
  over them, clipping with overflow hidden. Nothing re-lays-out mid-morph, so text
  never reflows while it travels.
- Clamp the collapsed row's max-width to the SCREEN, never to the shell: capping it
  at the box it is measured inside is a fixed point that freezes the pill at
  whatever width it happens to have and never lets it grow for longer content again.
- Sizes: 40px collapsed height, 40px minimal circle, 104px dormant pill, card
  min(expandedWidth, screen - 2x12px gutter). The corner radius is constant at half
  the collapsed height — a perfect stadium while short, a continuous-corner card
  once tall — so it never has to animate, and it comes from the hardware being
  imitated rather than from the theme's --radius.
- Morph: 420ms cubic-bezier(0.32, 0.72, 0, 1) on width and height; the incoming
  layer fades and scales in over 200ms with a 100ms delay, so content arrives after
  the shell has made room for it. Suppress the transition until a double rAF after
  mount, otherwise the island animates in from nothing on its first paint.
- Gesture, Pointer Events only (never separate mouse and touch handlers): a press
  on a drag zone — the trigger, or the card header — is a candidate. Under 4px of
  movement it is still a tap. Past that, claim it only if it is more vertical than
  horizontal AND points the only way this state can go (down while collapsed, up
  while expanded); anything else is handed back for good and never re-evaluated, so
  a sideways swipe is never stolen halfway through. On claim: setPointerCapture on
  the element that started the gesture (released on that same node), re-baseline the
  origin so the pull does not jump by the lock distance, and follow the finger
  through an exponential rubber band capped at 14px — the island is anchored to its
  housing, it does not travel with the thumb. Release commits at 28px of raw travel
  or 0.4px/ms of smoothed velocity; anything less springs back.
- touch-action: none on the drag zones, so the browser never fights the gesture and
  preventDefault is never needed (and so never called on a passive listener).
- A drag that moved sets a one-shot suppression ref, read and cleared by the very
  next click in a capture-phase handler on the root — pointer capture retargets the
  click, so the suppression cannot live on the button itself. Every new press clears
  the flag first, so a pointercancel that never produced a click cannot eat the next
  tap.
- Keyboard and pointer parity: the pill is a real disclosure <button> with
  aria-expanded + aria-controls. Enter/Space toggle, ArrowDown grows, ArrowUp
  collapses (the same axis as the gesture), Escape collapses and is stopped at the
  island so a layer underneath does not close on the same key. A press anywhere else
  on the screen collapses too (document pointerdown, capture phase, removed when it
  collapses and on unmount).
- Focus: the trigger never unmounts — it travels from "the whole pill" to "the grab
  strip under the card" — so no morph can drop focus onto <body>. Before the card
  goes inert, anything focused inside it is handed back to the trigger.
- Dormant: with presentation="idle", or with no title / status / children / actions
  / progress, there is nothing to expand. The trigger stays mounted and reports
  aria-disabled with a guarded handler; never the disabled attribute, because the
  user may be standing on it at the moment the activity ends.
- Screen reader: a polite role="status" region carries only the summary (title,
  status, percent), so a moving progress bar is not announced pixel by pixel. The
  collapsed row is aria-hidden — the trigger's accessible name already says those
  words. The card is role="group" and inert while collapsed, so its buttons are
  unreachable until it is really open. The percentage is spelled into the trigger's
  name as well: in the minimal presentation the arc is the only visual, and an arc
  says nothing to a screen reader.
- prefers-reduced-motion, subscribed through matchMedia + useSyncExternalStore
  rather than read once: no morph transition, no cross-fade, no rubber band. Every
  gesture and every threshold still works, the island simply changes size instantly.
- Cleanup: disconnect the observer, cancel both rAFs, remove the document listener,
  release pointer capture on the node that took it.

Rendering & styling
- Semantic tokens only. The island is the highest-priority surface on the screen, so
  it INVERTS instead of taking a colour: bg-foreground / text-background, with
  bg-background/10..25 for inner chips, tracks and the housing dot, and
  text-background/70 for secondary lines. The screen underneath is bg-card /
  bg-muted. No hex, rgb, hsl or oklch anywhere.
- The root is pointer-events-none and absolute (swap in fixed for a real app) with
  padding-top: max(0.5rem, var(--safe-area-inset-top, env(safe-area-inset-top, 0px)))
  — the OS inset, with a CSS-variable override first so a device frame or a preview
  can stage a notch on a machine that has none. Only the island box is
  pointer-events-auto, so the app underneath keeps every tap the island is not
  sitting on.
- Touch targets: the visible pill stays 40px tall while the transparent trigger over
  it is never under 44px in either dimension; expanded, that trigger becomes a 44px
  grab strip across the bottom of the card, and actions are min-h-11.
- The progress ring is a conic-gradient in currentColor punched out by an alpha-only
  radial-gradient mask, so it carries no colour of its own and inverts with the
  shell.
- cn() merges every className; decorative nodes are aria-hidden; the trigger keeps a
  focus-visible ring with an offset so it reads against the app behind it.

Customization levers
- Geometry: COLLAPSED_HEIGHT / MINIMAL_SIZE / IDLE_WIDTH / GUTTER are four constants
  at the top of the file. Raising COLLAPSED_HEIGHT gives a chunkier pill and, since
  the radius is derived from it, a rounder card for free.
- Physics: DRAG_START_PX (tap vs drag), TOGGLE_PX (commit distance), PULL_MAX (how
  far it follows the finger), FLING_VELOCITY (flick sensitivity), MORPH_MS + EASING.
  Lower TOGGLE_PX for a hair-trigger island, raise PULL_MAX for a looser one.
- Anatomy: leading / trailing / title / status / children / actions are independent
  slots — drop the body for a two-line card, drop actions for a pure readout, drop
  progress and both the ring and the bar disappear.
- Presentation policy: keep it uncontrolled for a self-contained island, or drive
  `presentation` from app state (a second activity arrives, go minimal; the activity
  ends, go idle). interactive={false} removes the trigger and the gestures entirely
  and leaves a readout the app alone drives.
- Placement: className overrides the position (fixed for a real app), the z-index and
  the top offset; keep the env() inset in whatever replaces it, or the island lands
  under the notch on a real handset.
- Colour: the invert is the design. If a brand really needs one, swap bg-foreground
  for bg-primary and text-background for text-primary-foreground in the shell and in
  DynamicIslandAction together — never one of the two on its own.

Concepts

  • Presentation morph — the four live-activity forms are one state machine, not four components: idle, compact and minimal are the collapsed spellings, expanded is the same island grown, and the island remembers which collapsed form it came from so it returns there.
  • Mask, not container — both layers sit in the DOM at their own natural size and the shell animates over them with overflow hidden. Growing a box that also lays out its content is what makes a morph judder; here nothing reflows while it travels.
  • Housing anchor — the pill is positioned by env(safe-area-inset-top) (with a CSS-variable override for previews) and lays its compact content out around a sensor dot. That anchoring is the reason this control has no desktop equivalent.
  • An equal key for every gesture — pull-down and flick-up are shortcuts, not the interface: the same two calls sit behind Enter, Space, ArrowDown, ArrowUp and Escape, and the whole pill is a disclosure button carrying aria-expanded.
  • A trigger that never unmounts — the control travels from covering the pill to a grab strip under the card instead of being swapped out, so a morph can never hand focus to <body>; anything focused inside the card is returned to it before the card goes inert.
  • Summary, not stream — a polite live region announces only title, status and percent. The arc and the bar are painted decoration; the number behind them is spelled into the trigger's accessible name, because an arc says nothing to a screen reader.

On This Page