Skip to content

Components / UI

Dynamic Island

A black pill that changes shape with its state. auto → auto never animates, so every state declares its width, height and radius in rem; the active one is data-state on the element, set by your script, and a small style block rendered per instance maps each name to its lengths and shows the matching child. The shape transitions on the preset spring; the old content blurs out on the fast token and the new one arrives after it. Zero JavaScript.

Live demo

click · four declared sizes, one attribute

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 5.5 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

every browser.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import DynamicIsland from '@moonarc/core/DynamicIsland';
const states = [
  { name: 'idle', width: 7.5, height: 2.25 },
  { name: 'music', width: 15, height: 3.5 },
  { name: 'call', width: 14, height: 3.25 },
  { name: 'timer', width: 11, height: 5, radius: 1.5 },
];
---
<DynamicIsland id="island" states={states} state="idle" live>
  <span data-state="idle"><i class="dot" /></span>
  <span data-state="music"><img src="/art.jpg" alt="" /> Now playing · Orbit</span>
  <span data-state="call">Ari · 00:42</span>
  <span data-state="timer">12:00</span>
</DynamicIsland>

<script>
  // your code drives it: one attribute
  document.getElementById('island').dataset.state = 'music';
</script>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/dynamic-island.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

DynamicIsland.astro
---
/**
 * DynamicIsland — a black pill that changes shape with its state, zero
 * JS. `auto → auto` never animates, so every state declares its size:
 * `states: [{ name, width, height, radius }]` in rem. The active state is
 * `data-state` on the element — set by the prop or by your script — and
 * because the names come from props, one small inline style block is
 * rendered per instance (an id from a hash of the states) that maps each name to its
 * three lengths and shows the matching child. Width, height and radius
 * transition on the preset spring; the old content blurs and fades on the
 * fast token, the new one arrives after it. Children carry `data-state`;
 * the first state is the default.
 */
import type { HTMLAttributes } from 'astro/types';

interface State {
  name: string;
  /** rem */
  width: number;
  /** rem */
  height: number;
  /** rem; default half the height (a pill). */
  radius?: number;
}

interface Props extends HTMLAttributes<'div'> {
  states: State[];
  /** Active state; default the first. */
  state?: string;
  /** Element id — deterministic from the states when omitted; pass one for two identical islands on a page. */
  id?: string;
  /** Announce content changes (aria-live="polite" on the body). */
  live?: boolean;
  /** Island colour; the island is black by nature, not the ink token. */
  color?: string;
  textColor?: string;
}

const { states, state, id, live = false, color, textColor, class: className, style, ...rest } = Astro.props;
const first = states[0] ?? { name: 'idle', width: 7.5, height: 2.25 };
// djb2 over the states: the same states give the same id on every build, so a visual test is stable
const hash = (s: string) => {
  let h = 5381;
  for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
  return h.toString(36);
};
const islandId = id ?? `ma-island-${hash(JSON.stringify(states))}`;
const active = states.some((s) => s.name === state) ? state! : first.name;
const vars = [color && `--ma-di-bg:${color}`, textColor && `--ma-di-fg:${textColor}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
// both come from props and are printed into CSS: the id as an identifier (CSS.escape's rules: an id may start with a
// digit or hold punctuation, and `#2fa` alone is not a selector), each name as a quoted string. `<` is escaped in both,
// so no value can close the style element it is printed in.
const hex = (c: string) => `\\${c.codePointAt(0)!.toString(16)} `;
const ident = (v: string) => v.replace(/^-?\d|[\0-\x1f\x7f]|[^\w\u0080-\uffff-]/g, (c) => (c.length > 1 ? c[0] + hex(c[1]!) : /[\d\0-\x1f\x7f]/.test(c) ? hex(c) : `\\${c}`));
const str = (v: string) => `"${v.replace(/["\\]/g, '\\$&').replace(/[\n\r\f<]/g, hex)}"`;
const sel = `#${ident(islandId)}`;
const rule = (s: State) => {
  const r = s.radius ?? s.height / 2;
  const n = str(s.name);
  return `${sel}[data-state=${n}]{--ma-di-w:${s.width}rem;--ma-di-h:${s.height}rem;--ma-di-r:${r}rem}${sel}[data-state=${n}]>.ma-island__body>[data-state=${n}]{opacity:1;filter:none;scale:1;pointer-events:auto;transition-delay:var(--ma-duration-fast)}`;
};
const css = states.map(rule).join('');
---

<style is:global>
  @layer components {
    :where(.ma-island) {
      --ma-di-w: 7.5rem;
      --ma-di-h: 2.25rem;
      --ma-di-r: 1.125rem;
      display: grid;
      place-items: stretch;
      inline-size: var(--ma-di-w);
      block-size: var(--ma-di-h);
      border-radius: var(--ma-di-r);
      background: var(--ma-di-bg, #0a0a0b);
      color: var(--ma-di-fg, #fff);
      overflow: clip;
      box-shadow: 0 8px 30px -12px rgb(0 0 0 / 0.6);
      transition:
        inline-size var(--ma-duration) var(--ma-ease),
        block-size var(--ma-duration) var(--ma-ease),
        border-radius var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-island__body) {
      display: grid;
      inline-size: 100%;
      block-size: 100%;
      min-inline-size: 0;
      min-block-size: 0;
    }
    /* every state's content sits in the same cell; the active one is picked by the instance's own rules */
    :where(.ma-island__body > [data-state]) {
      grid-area: 1 / 1;
      display: flex;
      align-items: center;
      gap: 0.6em;
      min-inline-size: 0;
      opacity: 0;
      filter: blur(4px);
      scale: 0.92;
      pointer-events: none;
      transition:
        opacity var(--ma-duration-fast) var(--ma-ease-out),
        filter var(--ma-duration-fast) var(--ma-ease-out),
        scale var(--ma-duration-fast) var(--ma-ease-out);
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-island),
      :where(.ma-island__body > [data-state]) {
        transition: none;
        filter: none;
        scale: 1;
      }
    }
  }
</style>

<div class:list={['ma-island', className]} id={islandId} data-state={active} style={vars || undefined} {...rest}>
  <div class="ma-island__body" aria-live={live ? 'polite' : undefined}><slot /></div>
</div>
<style is:inline set:html={css}></style>

Props

PropTypeDefaultDescription
states{ name, width, height, radius? }[]noneEvery state and its size in rem; radius defaults to half the height. The first is the default.
statestringstates[0].nameThe active state, as data-state on the element.
idstringa hash of the statesElement id and the selector of the per-instance rules, escaped for CSS (an id may start with a digit). Deterministic when omitted; pass one when two identical islands share a page.
livebooleanfalsearia-live="polite" on the body, so a state change is announced.
colorstring'#0a0a0b'Island colour; black by default, independent of the ink token.
textColorstring'#fff'Content colour.

Reduced motion

The shape and the content change at once, no spring, no blur.

With ClientRouter

Static; the state attribute is on the element and goes with the page. The per-instance style block is part of the page HTML and swaps with it.

Why it is built this way

Replaces: DynamicIsland (Cult UI) · Dynamic Island (Kokonut UI) · Toolbar Dynamic (Motion Primitives). See the migration table.

A generic phone frame around a screenshot or any content, with three cut-outs (island, notch, punch), a home bar, and no logo, buttons or brand.

every browseralways

idle → pending → done · one attribute

One submit button with four faces (idle, pending with a spinner, done with a check that draws itself, error) and a width that follows the label.

every browserclick

Notifications that slide in, stack behind each other, push up as new ones arrive, and expand into a list on hover.

newly · Chrome 117 · Firefox 129 · Safari 17.5clickhover

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown