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.
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.
Adjust · props are custom properties, set inline, so you edit the running instance
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
every browser.
Install
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
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.
npx shadcn@latest add https://moonarc.dev/r/dynamic-island.jsonThe 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 — 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
| 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. |
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
- 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). See the migration table.
Related
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.
Toast
UINotifications that slide in, stack behind each other, push up as new ones arrive, and expand into a list on hover.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown