# Dynamic Island (Moonarc)

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.

- Import: `import DynamicIsland from '@moonarc/core/DynamicIsland'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/dynamic-island.json`
- Tier A · category ui · trigger click
- Readout: `<DynamicIsland states={states} state="music">`
- Browser support: widely (every browser)
- Measured cost: 0 B JS (CSS 5.5 kB raw)
- Page: https://moonarc.dev/components/dynamic-island/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `states` | `{ name, width, height, radius? }[]` | none | Every state and its size in rem; radius defaults to half the height. The first is the default. |
| `state` | `string` | `states[0].name` | The active state, as data-state on the element. |
| `id` | `string` | `a hash of the states` | Element 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. |
| `live` | `boolean` | `false` | aria-live="polite" on the body, so a state change is announced. |
| `color` | `string` | `'#0a0a0b'` | Island colour; black by default, independent of the ink token. |
| `textColor` | `string` | `'#fff'` | Content colour. |

## Usage

```astro
---
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>
```

## 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.

## Craft

- Sizes are declared because they must be: a box that animates from one auto size to another needs a script to measure both, and this one has none. Each state is three lengths, and the transition between them is on the preset spring: lively overshoots, ui settles.
- The names come from props, so the stylesheet cannot know them: the component renders one small style block per instance, keyed by an id hashed from the states, with one rule per state for the lengths and one for the child that shows. It is CSS, printed in the readout.
- Content per state is one default slot with data-state children, because a dynamic slot name inside a map() fails at prerender (7B). Children stack in one grid cell; the inactive ones are blurred, scaled down and transparent, and the active one waits one fast token so the old is gone before it arrives.
- The island stays black in both themes: it is an object on the screen, not a panel of the page. color and textColor override it.

## Replaces

- DynamicIsland (Cult UI)
- Dynamic Island (Kokonut UI)
- Toolbar Dynamic (Motion Primitives)

## Source

```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>

```
