# Dynamic Island

> A black pill that springs open into live activities, with ten ready-made views from a timer to AirDrop.

- Page: https://ui.devigner.cc/components/dynamic-island
- Category: Interface
- Requirements: React 18 or 19, Tailwind CSS v4
- Install: `npx devignerui add dynamic-island` (writes components/ui/dynamic-island.tsx)
- Packages it needs: cn, motion, @devigner-ui/icons, devignerui

## What the demo shows

The live demo starts on Music playing. Pick any of the ten views below it: going from one view to another, the island rests as the pill for a moment, then springs open into the next. Back to Idle, it snaps in with a liquid bounce. On the component page the views sit in the Settings panel and play through on their own, one every 3s, until you turn Auto-play off.

## When to use it

- Live status that should stay in view without a toast stack: a timer, a recording, an upload, music playing.
- A small, glanceable control surface that can briefly grow for an incoming event, such as a call or a delivery arriving.

## When to reach for something else

- Messages people must read and dismiss, or errors. Use a toast or an inline message.
- Several things happening at once. The island shows one view; queue them or pick the most important.

## Usage

```tsx
import { DynamicIsland } from "@/components/ui/dynamic-island";

<DynamicIsland views={[{ id: "music", label: "Now playing", content: <IslandMusic /> }]} view="music" />
```

Custom composition:

```tsx
<DynamicIsland views={views} view={activity} floating label="Now playing" />
```

### Use the presets

Each preset is a content component sized like its iOS counterpart. Wire its buttons to your own state.

```tsx
import { useState } from "react";
import { SPRING_ISLAND_POP } from "devignerui/motion";
import { DynamicIsland, IslandAirdrop, IslandMusic, IslandTimer } from "@/components/ui/dynamic-island";

export function Activity() {
  const [view, setView] = useState<string | null>("music");
  const rest = () => setView(null);
  return (
    <DynamicIsland
      view={view}
      views={[
        { id: "music", label: "Now playing", content: <IslandMusic title="Night Drive" artist="Lumen" /> },
        { id: "timer", label: "Timer, 1 minute", content: <IslandTimer seconds={60} onClose={rest} />, transition: SPRING_ISLAND_POP },
        { id: "airdrop", label: "AirDrop from Mira", content: <IslandAirdrop from="Mira" onDecline={rest} onAccept={rest} /> },
      ]}
    />
  );
}
```

### Show an upload, then rest

Grow into a progress view while a file uploads and go back to the pill when it finishes.

```tsx
import { useState } from "react";
import { DynamicIsland } from "@/components/ui/dynamic-island";

export function UploadStatus({ start }: { start: (onProgress: (n: number) => void) => Promise<void> }) {
  const [pct, setPct] = useState<number | null>(null);
  const run = async () => {
    setPct(0);
    await start(setPct);
    setPct(null);
  };
  return (
    <div className="grid justify-items-center gap-4">
      <DynamicIsland
        view={pct === null ? null : "upload"}
        views={[
          {
            id: "upload",
            label: "Uploading",
            content: <span className="font-mono text-sm tabular-nums">Uploading {pct}%</span>,
          },
        ]}
      />
      <button type="button" onClick={run}>Upload</button>
    </div>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| views | `DynamicIslandView[]` | - | Every view the island can grow into: { id, label, content, width?, height?, transition? }. label is announced when the view opens; width and height in px fix its size, otherwise it fits the content; transition is the spring it opens on (SPRING_ISLAND_OPEN by default). |
| view | `string \| null` | null | The id of the view showing. null, or an id that isn't in views, rests as the small pill. |
| hop | `boolean` | true | Going from one view straight to another, rests as the pill for 0.5s first, then opens into the next. |
| floating | `boolean` | false | Pins the island to the top center of the viewport, like a phone's cutout. |
| label | `string` | Live activity | Accessible name of the island's region. |
| classNames | `DynamicIslandClassNames` | - | Slot classes: island (the black shape) and content (the box around each view). |

## Keyboard

| Keys | Action |
| --- | --- |
| Tab | Moves to the buttons inside the current view, if it has any. The island itself takes no focus. |

## Accessibility

- The island is a region named "Live activity" by default, and each view's label is announced in a polite live region when it opens.
- Content stays in the DOM order of the page, so buttons inside a view are reachable by keyboard as normal.
- The changing numbers inside a view, such as a countdown, are not announced every tick; only the label is.
- Honors prefers-reduced-motion and an ancestor <MotionConfig reducedMotion="always">, so an in-app motion switch works too.

## Theming

- The island is black with white text in both themes, like the cutout it grows from, with no border. Restyle it through classNames.island.
- classNames.content styles the box around each view. It adds no padding: a view's content owns its padding and size, as the presets do.
- The shape moves on springs fitted to iOS: SPRING_ISLAND_OPEN (bounce 0.2) opening a view, SPRING_ISLAND_POP (0.3) for one that lands harder, SPRING_ISLAND_REST (0.5) snapping back to the pill, each settling in about 0.8s. Content rises from 90% scale out of a 5px blur on the same spring.
- The presets use the system colors iOS uses on the island (orange, red, green, blue as hex), not theme tokens, since the island is black in every theme.

## Edge cases

- A view without width and height is measured from its content, so it grows and shrinks with it, such as a label that changes length.
- The island rests at 100 by 28, never gets shorter than that, and its corners stop rounding at 32px so a tall view reads as a slab.
- Changing view while a hop is resting cancels it and heads for the newest view, so fast clicks never queue.
- Each preset keeps its own state (a countdown, a paused track) while it shows, and starts fresh the next time it opens. The timers stop when they reach zero.
- IslandRing without silent flips between Ring and Silent every 2s, like the switch being toggled; pass silent to hold one. IslandAirdropMini without progress runs a transfer on its own and calls onDone a beat after it completes.
- IslandAirdrop and IslandAirdropMini take count and noun (3 files by default; an s is added past one) and an avatar photo, falling back to the sender's initials. IslandScreenRecord's ring turns slowly once it settles, and holds still under reduced motion.
- The island sizes itself from its content and moves between sizes as one layout transform, so the content squashes and stretches with the shape on the way and never relays the page out mid-move.
- It never grows wider than the viewport minus 32px.
- The island doesn't change view on its own. Set view from your own state, such as an upload finishing.
- floating fixes it to the top center; only the island catches clicks, so the page under the empty band stays usable.

## Troubleshooting

**The island snaps to its new size instead of growing.**
Reduced motion is on, either in the OS or through an ancestor MotionConfig. That is intended; the island changes size at once.

**My view is cut off.**
A fixed width or height smaller than the content clips it. Leave both out to fit the content, or make them larger.

**Switching views waits half a second.**
That is the hop through the resting pill, as iOS does between two live activities. Pass hop={false} to morph straight from one view to the next.

**The component renders with no background or the wrong colors.**
Colors use the standard shadcn/ui tokens only (background, foreground, primary, secondary, muted, accent, border, input, ring, destructive), so a project set up with shadcn/ui needs nothing extra and the component follows its theme, dark mode included. Without shadcn/ui, define those CSS variables for :root and .dark and map them in your Tailwind v4 @theme, or run npx shadcn init.
