Skip to content

Components / UI

App Screen

A phone app's screen drawn from rows instead of a screenshot: a status bar, a title, then rows of fourteen kinds (a big figure, a ring of progress, a checklist, a bar chart, a card, chips, a map with a route, a player, a chat, a list, a week of days, a button, a lock screen's clock, a notification) and a tab bar. Without its chrome it draws a widget, a lock screen or a watch face. It sizes itself to the box it sits in with container units, so inside PhoneFrame it scales with the phone, and it takes its colours from the theme and the colour block it names, so a palette recolours every screen. As it arrives the ring fills, the bars grow, the route draws and the checks tick in, once; inside a Reveal they wait for it. One accessible name for the whole screen, or hidden as decoration. Zero JavaScript.

Live demo

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

Measured

JavaScript of its own

0 B

CSS 17.4 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

Chrome 105 · Firefox 110 · Safari 16; needs container-units. Elsewhere: without container units the screen keeps the page's text size and its rows stack as they are.

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 AppScreen from '@moonarc/core/AppScreen';
---
<PhoneFrame width="17rem">
  <AppScreen
    eyebrow="Friday 9 October"
    title="Today"
    block={1}
    label="The app's today screen: three of four habits done and a six-day streak"
    rows={[
      { kind: 'ring', value: 0.75, text: '3 of 4', label: 'habits done' },
      { kind: 'checks', items: [{ text: 'Walk 20 minutes', done: true }, { text: 'Read 10 pages', done: true }, { text: 'No phone after 10', done: true }, { text: 'Water the plants' }] },
      { kind: 'days', label: 'This week', days: 'xxxxxx.' },
    ]}
    tabs={[{ label: 'Today', icon: 'home' }, { label: 'Stats', icon: 'chart' }, { label: 'You', icon: 'user' }]}
  />
</PhoneFrame>
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/app-screen.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. If you paste it, add the JS gate to your head.

AppScreen.astro
---
/**
 * AppScreen — a phone app's screen, drawn from rows instead of a screenshot: a status bar, a title, then rows of a dozen
 * kinds (a big figure, a ring of progress, a checklist, a bar chart, a card, chips, a map with a route, a player, a
 * chat, a list, a week of days, a button, a lock screen's clock, a notification) and a tab bar. Without its chrome (no
 * status bar) it draws a widget or a watch face as well. It sizes itself to the box it sits in (container units), so
 * inside PhoneFrame it scales with the phone, and it takes its colours from the theme contract and the colour block it
 * names, so a palette recolours every screen. The ring fills, the bars grow and the checks tick once, as it arrives:
 * inside a Reveal that has not arrived yet they wait for it, and --ma-as-at delays them further (a screen shown later).
 * A picture: one accessible name for the whole screen, or hidden when it is decoration. Zero JavaScript.
 */
import type { HTMLAttributes } from 'astro/types';

type Block = 1 | 2 | 3 | 4 | 5;
type Icon = 'home' | 'chart' | 'list' | 'user' | 'search' | 'plus' | 'bell' | 'book' | 'moon' | 'map' | 'chat' | 'wallet';
type Row =
  | { kind: 'figure'; label: string; value: string; note?: string }
  | { kind: 'ring'; value: number; label: string; text: string }
  | { kind: 'checks'; items: { text: string; done?: boolean; meta?: string }[] }
  | { kind: 'bars'; values: number[]; labels?: string[]; highlight?: number }
  | { kind: 'card'; title: string; text?: string; tag?: string; block?: Block }
  | { kind: 'chips'; items: string[]; active?: number }
  | { kind: 'map'; label?: string; seed?: string }
  | { kind: 'player'; title: string; meta?: string; progress: number }
  | { kind: 'chat'; messages: { from: 'me' | 'them'; text: string }[] }
  | { kind: 'list'; items: { title: string; meta?: string; value?: string }[] }
  | { kind: 'days'; label?: string; days: string }
  | { kind: 'button'; text: string }
  | { kind: 'clock'; time: string; date?: string }
  | { kind: 'notice'; app: string; title: string; text?: string; time?: string };

interface Props extends HTMLAttributes<'div'> {
  rows: Row[];
  /** The screen's title, large, under the status bar. */
  title?: string;
  /** A small line over the title: a date, a greeting. */
  eyebrow?: string;
  /** The tab bar at the foot: up to five, the active one marked. */
  tabs?: { label: string; icon: Icon }[];
  active?: number;
  /** The colour block the screen's accent takes; unset, the theme's primary. */
  block?: Block;
  /** The clock in the status bar. */
  time?: string;
  /** What the screen shows, for a screen reader; unset, the screen is decoration and hidden. */
  label?: string;
  /** The status bar; false draws a widget or a watch face: rows only, edge to edge. */
  chrome?: boolean;
}

const { rows, title, eyebrow, tabs = [], active = 0, block, time = '9:41', label, chrome = true, class: className, ...rest } = Astro.props;
const icons: Record<Icon, string> = {
  home: 'M4 11l8-6 8 6v8H4z M10 19v-5h4v5',
  chart: 'M5 19V11 M10 19V6 M15 19v-9 M20 19v-4',
  list: 'M9 7h11 M9 12h11 M9 17h11 M4.5 7h.01 M4.5 12h.01 M4.5 17h.01',
  user: 'M12 12a4 4 0 100-8 4 4 0 000 8z M5 20c1-4 4-5 7-5s6 1 7 5',
  search: 'M11 18a7 7 0 100-14 7 7 0 000 14z M16 16l4 4',
  plus: 'M12 5v14 M5 12h14',
  bell: 'M6 16V11a6 6 0 0112 0v5l2 2H4z M10 20h4',
  book: 'M5 5h6a2 2 0 012 2v12a2 2 0 00-2-2H5z M19 5h-6a2 2 0 00-2 2v12a2 2 0 012-2h6z',
  moon: 'M18 14A7 7 0 0110 6a7 7 0 108 8z',
  map: 'M4 6l5-2 6 2 5-2v14l-5 2-6-2-5 2z M9 4v14 M15 6v14',
  chat: 'M5 5h14v10H9l-4 4z',
  wallet: 'M4 7h14a2 2 0 012 2v8H4z M4 7V5h12 M16 12h2',
};
/** A seeded route over the map: the same seed draws the same way. */
const route = (seed: string) => {
  let h = 2166136261;
  for (const c of seed) h = Math.imul(h ^ c.charCodeAt(0), 16777619);
  const r = () => ((h = Math.imul(h ^ (h >>> 15), 2246822507)) >>> 0) / 4294967296;
  const pts = Array.from({ length: 5 }, (_, i) => [8 + i * 21 + r() * 6, 12 + r() * 36]);
  return { d: `M${pts.map((p) => p.map((v) => v.toFixed(1)).join(' ')).join(' L')}`, end: pts[pts.length - 1]! };
};
const pct = (v: number) => Math.max(0, Math.min(1, v));
---

<div class:list={['ma-appscreen', className]} data-ma-block={block} data-bare={chrome ? undefined : ''} role={label ? 'img' : undefined} aria-label={label} aria-hidden={label ? undefined : 'true'} {...rest}>
  {chrome && <div class="ma-appscreen__status"><span>{time}</span><span class="ma-appscreen__glyphs"><i></i><i></i><i></i></span></div>}
  {(eyebrow || title) && (
    <div class="ma-appscreen__head">
      {eyebrow && <span class="ma-appscreen__eyebrow">{eyebrow}</span>}
      {title && <span class="ma-appscreen__title">{title}</span>}
    </div>
  )}
  <div class="ma-appscreen__rows">
    {rows.map((row, i) => {
      const style = `--ma-as-i:${i}`;
      if (row.kind === 'figure') return (
        <div class="ma-appscreen__figure" style={style}><span class="ma-appscreen__small">{row.label}</span><span class="ma-appscreen__big">{row.value}</span>{row.note && <span class="ma-appscreen__note">{row.note}</span>}</div>
      );
      if (row.kind === 'ring') return (
        <div class="ma-appscreen__ringrow" style={style}>
          <svg class="ma-appscreen__ring" viewBox="0 0 40 40"><circle cx="20" cy="20" r="16" pathLength="100" /><circle class="ma-appscreen__arc" cx="20" cy="20" r="16" pathLength="100" stroke-dasharray={`${(pct(row.value) * 100).toFixed(1)} 100`} style={`--ma-as-len:${(pct(row.value) * 100).toFixed(1)}`} /></svg>
          <span><span class="ma-appscreen__big">{row.text}</span><span class="ma-appscreen__small">{row.label}</span></span>
        </div>
      );
      if (row.kind === 'checks') return (
        <ul class="ma-appscreen__checks" style={style}>
          {row.items.map((it, j) => (
            <li data-done={it.done ? '' : undefined} style={`--ma-as-j:${j}`}><span class="ma-appscreen__box"><svg viewBox="0 0 16 16"><path d="M3.5 8.5l3 3 6-7" /></svg></span><span class="ma-appscreen__text">{it.text}</span>{it.meta && <span class="ma-appscreen__small">{it.meta}</span>}</li>
          ))}
        </ul>
      );
      if (row.kind === 'bars') {
        const top = Math.max(...row.values, 1);
        return (
          <div class="ma-appscreen__bars" style={style}>
            {row.values.map((v, j) => (
              <span data-on={row.highlight === j ? '' : undefined} style={`--ma-as-h:${(v / top).toFixed(3)};--ma-as-j:${j}`}><i></i>{row.labels?.[j] && <b>{row.labels[j]}</b>}</span>
            ))}
          </div>
        );
      }
      if (row.kind === 'card') return (
        <div class="ma-appscreen__card" data-ma-block={row.block} style={style}>{row.tag && <span class="ma-appscreen__tag">{row.tag}</span>}<span class="ma-appscreen__text">{row.title}</span>{row.text && <span class="ma-appscreen__small">{row.text}</span>}</div>
      );
      if (row.kind === 'chips') return (
        <div class="ma-appscreen__chips" style={style}>{row.items.map((c, j) => <span data-on={(row.active ?? 0) === j ? '' : undefined}>{c}</span>)}</div>
      );
      if (row.kind === 'map') {
        const r = route(row.seed ?? row.label ?? 'map');
        return (
          <div class="ma-appscreen__map" style={style}>
            <svg viewBox="0 0 110 60" preserveAspectRatio="none"><path class="ma-appscreen__roads" d="M0 18H110 M0 42H110 M30 0V60 M72 0V60 M0 6L110 54" /><path class="ma-appscreen__route" d={r.d} pathLength="100" /><circle cx={r.end[0]} cy={r.end[1]} r="3" /></svg>
            {row.label && <span class="ma-appscreen__maplabel">{row.label}</span>}
          </div>
        );
      }
      if (row.kind === 'player') return (
        <div class="ma-appscreen__player" style={style}>
          <span class="ma-appscreen__play"><svg viewBox="0 0 16 16"><path d="M5 3.5l8 4.5-8 4.5z" /></svg></span>
          <span><span class="ma-appscreen__text">{row.title}</span>{row.meta && <span class="ma-appscreen__small">{row.meta}</span>}</span>
          <span class="ma-appscreen__track"><i style={`--ma-as-p:${pct(row.progress)}`}></i></span>
        </div>
      );
      if (row.kind === 'chat') return (
        <div class="ma-appscreen__chat" style={style}>{row.messages.map((m, j) => <span data-from={m.from} style={`--ma-as-j:${j}`}>{m.text}</span>)}</div>
      );
      if (row.kind === 'list') return (
        <ul class="ma-appscreen__list" style={style}>
          {row.items.map((it) => (
            <li><span class="ma-appscreen__avatar">{it.title.slice(0, 1)}</span><span><span class="ma-appscreen__text">{it.title}</span>{it.meta && <span class="ma-appscreen__small">{it.meta}</span>}</span>{it.value && <span class="ma-appscreen__value">{it.value}</span>}</li>
          ))}
        </ul>
      );
      if (row.kind === 'days') return (
        <div class="ma-appscreen__days" style={style}>
          {row.label && <span class="ma-appscreen__small">{row.label}</span>}
          <span class="ma-appscreen__week">{[...row.days].map((c, j) => <i data-on={c === 'x' ? '' : undefined} style={`--ma-as-j:${j}`}></i>)}</span>
        </div>
      );
      if (row.kind === 'clock') return (
        <div class="ma-appscreen__clock" style={style}>{row.date && <span class="ma-appscreen__date">{row.date}</span>}<span class="ma-appscreen__time">{row.time}</span></div>
      );
      if (row.kind === 'notice') return (
        <div class="ma-appscreen__notice" style={style}>
          <span class="ma-appscreen__icon">{row.app.slice(0, 1)}</span>
          <span><span class="ma-appscreen__small">{row.app}{row.time && <> · {row.time}</>}</span><span class="ma-appscreen__text">{row.title}</span>{row.text && <span class="ma-appscreen__small">{row.text}</span>}</span>
        </div>
      );
      return <span class="ma-appscreen__button" style={style}>{row.text}</span>;
    })}
  </div>
  {tabs.length > 0 && (
    <div class="ma-appscreen__tabs">
      {tabs.slice(0, 5).map((t, j) => (
        <span data-on={j === active ? '' : undefined}><svg viewBox="0 0 24 24"><path d={icons[t.icon]} /></svg>{t.label}</span>
      ))}
    </div>
  )}
</div>

<style is:global>
  @layer components {
    /* sized by the box it sits in: every length is a share of its width (cqi), so a phone twice as wide draws the same
       screen twice as large. Colours are tokens: the ground is the page's, the accent the block's or the primary */
    /* every length is a share of the screen's width, in --ma-as-u: 1cqi unless something around the screen sets more (a
       widget draws its rows larger for its size than a phone does). Read where it is used, so it is the screen's own
       width wherever it is set */
    :where(.ma-appscreen) {
      /* --ma-as-tint and --ma-as-on-tint, set around the screen, win over the block: a watch face lifts its accent */
      --ma-as-accent: var(--ma-as-tint, var(--ma-block-ground, var(--primary, var(--ma-ink))));
      --ma-as-on-accent: var(--ma-as-on-tint, var(--ma-block-text, var(--primary-foreground, var(--ma-panel))));
      --ma-as-line: color-mix(in oklab, var(--foreground, currentColor) 12%, transparent);
      --ma-as-soft: color-mix(in oklab, var(--foreground, currentColor) 6%, var(--background, transparent));
      container-type: inline-size;
      display: flex;
      flex-direction: column;
      gap: 0;
      block-size: 100%;
      min-block-size: 100%;
      overflow: hidden;
      background: var(--background, var(--ma-panel));
      color: var(--foreground, var(--ma-ink));
      font-family: var(--ma-font-body, system-ui, sans-serif);
      line-height: 1.3;
    }
    /* in PhoneFrame it fills the screen: the screen is a size container, and its content's height is only a minimum,
       which a percentage cannot resolve against */
    :where(.ma-phone__screen .ma-appscreen) { min-block-size: 100cqb; }
    /* the block, when one is named, colours the accent only: the screen stays on the page's ground */
    :where(.ma-appscreen[data-ma-block]) { background: var(--background, var(--ma-panel)); color: var(--foreground, var(--ma-ink)); }
    :where(.ma-appscreen *) { box-sizing: border-box; }
    :where(.ma-appscreen ul) { margin: 0; padding: 0; list-style: none; }
    :where(.ma-appscreen svg) { display: block; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; }
    :where(.ma-appscreen__status) { display: flex; justify-content: space-between; align-items: center; padding: calc(4 * var(--ma-as-u, 1cqi)) calc(7 * var(--ma-as-u, 1cqi)) calc(1 * var(--ma-as-u, 1cqi)); font-size: calc(3.6 * var(--ma-as-u, 1cqi)); font-weight: 650; }
    :where(.ma-appscreen__glyphs) { display: flex; gap: calc(1 * var(--ma-as-u, 1cqi)); }
    :where(.ma-appscreen__glyphs i) { inline-size: calc(3.6 * var(--ma-as-u, 1cqi)); block-size: calc(2.2 * var(--ma-as-u, 1cqi)); border-radius: calc(0.6 * var(--ma-as-u, 1cqi)); background: currentColor; opacity: 0.85; }
    :where(.ma-appscreen__glyphs i:last-child) { inline-size: calc(5.6 * var(--ma-as-u, 1cqi)); border-radius: calc(1 * var(--ma-as-u, 1cqi)); }
    :where(.ma-appscreen__head), :where(.ma-appscreen__rows) { font-size: calc(4.2 * var(--ma-as-u, 1cqi)); }
    :where(.ma-appscreen__head) { display: grid; gap: calc(0.6 * var(--ma-as-u, 1cqi)); padding: calc(5 * var(--ma-as-u, 1cqi)) calc(6 * var(--ma-as-u, 1cqi)) calc(3 * var(--ma-as-u, 1cqi)); }
    :where(.ma-appscreen__eyebrow), :where(.ma-appscreen__small) { display: block; font-size: calc(3.3 * var(--ma-as-u, 1cqi)); opacity: 0.72; }
    :where(.ma-appscreen__title) { font-family: var(--ma-font-display, inherit); font-size: calc(8 * var(--ma-as-u, 1cqi)); font-weight: 700; letter-spacing: -0.02em; line-height: 1.05; }
    :where(.ma-appscreen__rows) { display: grid; gap: calc(3.5 * var(--ma-as-u, 1cqi)); align-content: start; flex: 1; padding: calc(1 * var(--ma-as-u, 1cqi)) calc(6 * var(--ma-as-u, 1cqi)) calc(4 * var(--ma-as-u, 1cqi)); min-block-size: 0; }
    :where(.ma-appscreen__text) { display: block; font-weight: 600; }
    :where(.ma-appscreen__big) { display: block; font-family: var(--ma-font-display, inherit); font-size: calc(9 * var(--ma-as-u, 1cqi)); font-weight: 700; letter-spacing: -0.03em; line-height: 1; font-variant-numeric: tabular-nums; }
    :where(.ma-appscreen__note) { display: flex; align-items: center; gap: calc(1.4 * var(--ma-as-u, 1cqi)); margin-block-start: calc(1 * var(--ma-as-u, 1cqi)); font-size: calc(3.3 * var(--ma-as-u, 1cqi)); font-weight: 600; }
    /* a note is the text's colour beside a dot of the accent: an accent light enough to fill a ring is too light for text */
    :where(.ma-appscreen__note)::before { content: ''; flex: none; inline-size: calc(1.8 * var(--ma-as-u, 1cqi)); block-size: calc(1.8 * var(--ma-as-u, 1cqi)); border-radius: 50%; background: var(--ma-as-accent); }
    :where(.ma-appscreen__figure) { display: grid; gap: calc(1 * var(--ma-as-u, 1cqi)); padding: calc(4.5 * var(--ma-as-u, 1cqi)); border-radius: calc(5 * var(--ma-as-u, 1cqi)); background: var(--ma-as-soft); }
    :where(.ma-appscreen__ringrow) { display: flex; gap: calc(4 * var(--ma-as-u, 1cqi)); align-items: center; }
    :where(.ma-appscreen__ring) { inline-size: calc(22 * var(--ma-as-u, 1cqi)); block-size: calc(22 * var(--ma-as-u, 1cqi)); flex: none; rotate: -90deg; }
    :where(.ma-appscreen__ring circle) { stroke: var(--ma-as-line); stroke-width: 5; }
    :where(.ma-appscreen__ring .ma-appscreen__arc) { stroke: var(--ma-as-accent); }
    :where(.ma-appscreen__checks) { display: grid; gap: calc(2.4 * var(--ma-as-u, 1cqi)); }
    :where(.ma-appscreen__checks li) { display: grid; grid-template-columns: auto 1fr auto; gap: calc(3 * var(--ma-as-u, 1cqi)); align-items: center; padding: calc(3 * var(--ma-as-u, 1cqi)) calc(3.5 * var(--ma-as-u, 1cqi)); border-radius: calc(3.5 * var(--ma-as-u, 1cqi)); background: var(--ma-as-soft); }
    :where(.ma-appscreen__box) { display: grid; place-items: center; inline-size: calc(6 * var(--ma-as-u, 1cqi)); block-size: calc(6 * var(--ma-as-u, 1cqi)); border-radius: 50%; box-shadow: inset 0 0 0 calc(0.6 * var(--ma-as-u, 1cqi)) var(--ma-as-line); }
    :where(.ma-appscreen__box svg) { inline-size: calc(4 * var(--ma-as-u, 1cqi)); block-size: calc(4 * var(--ma-as-u, 1cqi)); stroke-width: 2.4; opacity: 0; }
    :where(.ma-appscreen__checks li[data-done] .ma-appscreen__box) { background: var(--ma-as-accent); box-shadow: none; color: var(--ma-as-on-accent); }
    :where(.ma-appscreen__checks li[data-done] .ma-appscreen__box svg) { opacity: 1; }
    :where(.ma-appscreen__checks li[data-done] .ma-appscreen__text) { text-decoration: line-through; text-decoration-color: color-mix(in oklab, currentColor 45%, transparent); }
    :where(.ma-appscreen__bars) { display: flex; align-items: end; gap: calc(2 * var(--ma-as-u, 1cqi)); block-size: calc(30 * var(--ma-as-u, 1cqi)); padding: calc(3 * var(--ma-as-u, 1cqi)); border-radius: calc(4 * var(--ma-as-u, 1cqi)); background: var(--ma-as-soft); }
    :where(.ma-appscreen__bars span) { display: grid; grid-template-rows: 1fr auto; gap: calc(1 * var(--ma-as-u, 1cqi)); flex: 1; block-size: 100%; }
    :where(.ma-appscreen__bars i) { align-self: end; block-size: calc(var(--ma-as-h) * 100%); min-block-size: calc(1.5 * var(--ma-as-u, 1cqi)); border-radius: calc(1.5 * var(--ma-as-u, 1cqi)); background: color-mix(in oklab, var(--ma-as-accent) 40%, transparent); transform-origin: bottom; }
    :where(.ma-appscreen__bars span[data-on] i) { background: var(--ma-as-accent); }
    :where(.ma-appscreen__bars b) { font-size: calc(2.8 * var(--ma-as-u, 1cqi)); font-weight: 500; text-align: center; opacity: 0.72; }
    :where(.ma-appscreen__card) { display: grid; gap: calc(1 * var(--ma-as-u, 1cqi)); padding: calc(4.5 * var(--ma-as-u, 1cqi)); border-radius: calc(5 * var(--ma-as-u, 1cqi)); background: var(--ma-as-soft); }
    :where(.ma-appscreen__card[data-ma-block]) { background: var(--ma-block-ground); color: var(--ma-block-text); }
    :where(.ma-appscreen__tag) { justify-self: start; padding: calc(0.6 * var(--ma-as-u, 1cqi)) calc(2 * var(--ma-as-u, 1cqi)); border-radius: 99px; background: var(--ma-as-accent); color: var(--ma-as-on-accent); font-size: calc(2.8 * var(--ma-as-u, 1cqi)); font-weight: 650; }
    :where(.ma-appscreen__chips) { display: flex; flex-wrap: wrap; gap: calc(2 * var(--ma-as-u, 1cqi)); }
    :where(.ma-appscreen__chips span) { padding: calc(1.4 * var(--ma-as-u, 1cqi)) calc(3.4 * var(--ma-as-u, 1cqi)); border-radius: 99px; box-shadow: inset 0 0 0 calc(0.5 * var(--ma-as-u, 1cqi)) var(--ma-as-line); font-size: calc(3.4 * var(--ma-as-u, 1cqi)); font-weight: 550; }
    :where(.ma-appscreen__chips span[data-on]) { background: var(--ma-as-accent); color: var(--ma-as-on-accent); box-shadow: none; }
    :where(.ma-appscreen__map) { position: relative; block-size: calc(40 * var(--ma-as-u, 1cqi)); overflow: hidden; border-radius: calc(5 * var(--ma-as-u, 1cqi)); background: var(--ma-as-soft); }
    :where(.ma-appscreen__map svg) { inline-size: 100%; block-size: 100%; }
    :where(.ma-appscreen__roads) { stroke: var(--ma-as-line); stroke-width: 3; }
    :where(.ma-appscreen__route) { stroke: var(--ma-as-accent); stroke-width: 2.4; stroke-dasharray: 100 100; }
    :where(.ma-appscreen__map circle) { fill: var(--ma-as-accent); stroke: var(--background, #fff); stroke-width: 1.4; }
    :where(.ma-appscreen__maplabel) { position: absolute; inset-inline-start: calc(3 * var(--ma-as-u, 1cqi)); inset-block-end: calc(3 * var(--ma-as-u, 1cqi)); padding: calc(1 * var(--ma-as-u, 1cqi)) calc(2.6 * var(--ma-as-u, 1cqi)); border-radius: 99px; background: var(--background, #fff); font-size: calc(3 * var(--ma-as-u, 1cqi)); font-weight: 650; }
    :where(.ma-appscreen__player) { display: grid; grid-template-columns: auto 1fr; gap: calc(2.4 * var(--ma-as-u, 1cqi)) calc(3 * var(--ma-as-u, 1cqi)); align-items: center; padding: calc(4 * var(--ma-as-u, 1cqi)); border-radius: calc(5 * var(--ma-as-u, 1cqi)); background: var(--ma-as-soft); }
    :where(.ma-appscreen__play) { display: grid; place-items: center; inline-size: calc(10 * var(--ma-as-u, 1cqi)); block-size: calc(10 * var(--ma-as-u, 1cqi)); border-radius: 50%; background: var(--ma-as-accent); color: var(--ma-as-on-accent); }
    :where(.ma-appscreen__play svg) { inline-size: calc(4.6 * var(--ma-as-u, 1cqi)); block-size: calc(4.6 * var(--ma-as-u, 1cqi)); fill: currentColor; stroke: none; }
    :where(.ma-appscreen__track) { grid-column: 1 / -1; block-size: calc(1.4 * var(--ma-as-u, 1cqi)); border-radius: calc(1 * var(--ma-as-u, 1cqi)); background: var(--ma-as-line); overflow: hidden; }
    :where(.ma-appscreen__track i) { display: block; block-size: 100%; inline-size: calc(var(--ma-as-p) * 100%); background: var(--ma-as-accent); }
    :where(.ma-appscreen__chat) { display: grid; gap: calc(2 * var(--ma-as-u, 1cqi)); }
    :where(.ma-appscreen__chat span) { max-inline-size: 78%; padding: calc(2.4 * var(--ma-as-u, 1cqi)) calc(3.4 * var(--ma-as-u, 1cqi)); border-radius: calc(4 * var(--ma-as-u, 1cqi)) calc(4 * var(--ma-as-u, 1cqi)) calc(4 * var(--ma-as-u, 1cqi)) calc(1 * var(--ma-as-u, 1cqi)); background: var(--ma-as-soft); font-size: calc(3.6 * var(--ma-as-u, 1cqi)); }
    :where(.ma-appscreen__chat span[data-from='me']) { justify-self: end; border-radius: calc(4 * var(--ma-as-u, 1cqi)) calc(4 * var(--ma-as-u, 1cqi)) calc(1 * var(--ma-as-u, 1cqi)) calc(4 * var(--ma-as-u, 1cqi)); background: var(--ma-as-accent); color: var(--ma-as-on-accent); }
    :where(.ma-appscreen__list) { display: grid; gap: 0; }
    :where(.ma-appscreen__list li) { display: grid; grid-template-columns: auto 1fr auto; gap: calc(3 * var(--ma-as-u, 1cqi)); align-items: center; padding-block: calc(2.6 * var(--ma-as-u, 1cqi)); border-block-end: calc(0.3 * var(--ma-as-u, 1cqi)) solid var(--ma-as-line); }
    :where(.ma-appscreen__avatar) { display: grid; place-items: center; inline-size: calc(8.5 * var(--ma-as-u, 1cqi)); block-size: calc(8.5 * var(--ma-as-u, 1cqi)); border-radius: calc(2.6 * var(--ma-as-u, 1cqi)); background: var(--ma-as-soft); font-weight: 700; }
    :where(.ma-appscreen__value) { font-weight: 650; font-variant-numeric: tabular-nums; }
    :where(.ma-appscreen__days) { display: grid; gap: calc(1.6 * var(--ma-as-u, 1cqi)); }
    :where(.ma-appscreen__week) { display: grid; grid-template-columns: repeat(7, 1fr); gap: calc(1.6 * var(--ma-as-u, 1cqi)); }
    :where(.ma-appscreen__week i) { aspect-ratio: 1; border-radius: calc(2 * var(--ma-as-u, 1cqi)); background: var(--ma-as-soft); box-shadow: inset 0 0 0 calc(0.4 * var(--ma-as-u, 1cqi)) var(--ma-as-line); }
    :where(.ma-appscreen__week i[data-on]) { background: var(--ma-as-accent); box-shadow: none; }
    :where(.ma-appscreen__button) { display: grid; place-items: center; min-block-size: calc(11 * var(--ma-as-u, 1cqi)); border-radius: 99px; background: var(--ma-as-accent); color: var(--ma-as-on-accent); font-weight: 650; }
    :where(.ma-appscreen__tabs) { display: flex; justify-content: space-around; padding: calc(2.4 * var(--ma-as-u, 1cqi)) calc(4 * var(--ma-as-u, 1cqi)) calc(6 * var(--ma-as-u, 1cqi)); border-block-start: calc(0.3 * var(--ma-as-u, 1cqi)) solid var(--ma-as-line); background: var(--ma-as-soft); }
    :where(.ma-appscreen__tabs span) { display: grid; justify-items: center; gap: calc(0.6 * var(--ma-as-u, 1cqi)); font-size: calc(2.6 * var(--ma-as-u, 1cqi)); font-weight: 550; opacity: 0.72; }
    :where(.ma-appscreen__tabs span[data-on]) { opacity: 1; font-weight: 700; }
    /* the current tab's icon takes the accent; its word stays the text's colour, which reads on any accent */
    :where(.ma-appscreen__tabs span[data-on] svg) { color: var(--ma-as-accent); }
    :where(.ma-appscreen__tabs svg) { inline-size: calc(6 * var(--ma-as-u, 1cqi)); block-size: calc(6 * var(--ma-as-u, 1cqi)); stroke-width: 1.8; }
    :where(.ma-appscreen__clock) { display: grid; justify-items: center; gap: calc(1 * var(--ma-as-u, 1cqi)); padding-block: calc(6 * var(--ma-as-u, 1cqi)) calc(4 * var(--ma-as-u, 1cqi)); text-align: center; }
    :where(.ma-appscreen__date) { font-size: calc(4 * var(--ma-as-u, 1cqi)); font-weight: 600; opacity: 0.8; }
    :where(.ma-appscreen__time) { font-family: var(--ma-font-display, inherit); font-size: calc(22 * var(--ma-as-u, 1cqi)); font-weight: 650; letter-spacing: -0.04em; line-height: 0.95; font-variant-numeric: tabular-nums; }
    :where(.ma-appscreen__notice) { display: grid; grid-template-columns: auto 1fr; gap: calc(3 * var(--ma-as-u, 1cqi)); align-items: start; padding: calc(3.5 * var(--ma-as-u, 1cqi)); border-radius: calc(5 * var(--ma-as-u, 1cqi)); background: var(--ma-as-soft); box-shadow: 0 0 0 calc(0.3 * var(--ma-as-u, 1cqi)) var(--ma-as-line); }
    :where(.ma-appscreen__icon) { display: grid; place-items: center; inline-size: calc(8.5 * var(--ma-as-u, 1cqi)); block-size: calc(8.5 * var(--ma-as-u, 1cqi)); border-radius: calc(2.4 * var(--ma-as-u, 1cqi)); background: var(--ma-as-accent); color: var(--ma-as-on-accent); font-weight: 700; }
    /* without chrome: a widget or a watch face, the rows edge to edge */
    :where(.ma-appscreen[data-bare] .ma-appscreen__rows) { padding: calc(6 * var(--ma-as-u, 1cqi)); gap: calc(4 * var(--ma-as-u, 1cqi)); }
    :where(.ma-appscreen[data-bare] .ma-appscreen__head) { padding: calc(6 * var(--ma-as-u, 1cqi)) calc(6 * var(--ma-as-u, 1cqi)) 0; }
    /* once, as the screen is drawn: the ring fills, the bars grow, the route draws, the checks and the days tick in */
    @media (prefers-reduced-motion: no-preference) {
      :where(.ma-appscreen__arc) { animation: ma-as-ring 1.1s var(--ma-ease-out, ease-out) both; animation-delay: calc(var(--ma-as-at, 0s) + 0.3s); }
      :where(.ma-appscreen__bars i) { animation: ma-as-grow 0.7s var(--ma-ease-out, ease-out) both; animation-delay: calc(var(--ma-as-at, 0s) + 0.25s + var(--ma-as-j, 0) * 60ms); }
      :where(.ma-appscreen__route) { animation: ma-as-route 1.4s var(--ma-ease-out, ease-out) both; animation-delay: calc(var(--ma-as-at, 0s) + 0.4s); }
      :where(.ma-appscreen__checks li[data-done] .ma-appscreen__box), :where(.ma-appscreen__week i[data-on]) { animation: ma-as-pop 0.4s var(--ma-ease-out, ease-out) both; animation-delay: calc(var(--ma-as-at, 0s) + 0.4s + var(--ma-as-j, 0) * 90ms); }
      :where(.ma-appscreen__track i) { animation: ma-as-track 1.2s var(--ma-ease-out, ease-out) both; animation-delay: calc(var(--ma-as-at, 0s) + 0.3s); }
      /* inside a Reveal that has not arrived, they wait for it (a paused animation's delay does not run either) */
      :where([data-ma-js] .ma-reveal:not(.ma-in) .ma-appscreen :is(.ma-appscreen__arc, .ma-appscreen__bars i, .ma-appscreen__route, .ma-appscreen__box, .ma-appscreen__week i, .ma-appscreen__track i)) { animation-play-state: paused; }
    }
  }
  @keyframes ma-as-ring { from { stroke-dasharray: 0 100; } }
  @keyframes ma-as-grow { from { scale: 1 0; } }
  @keyframes ma-as-route { from { stroke-dashoffset: 100; } }
  @keyframes ma-as-pop { from { scale: 0.6; opacity: 0; } }
  @keyframes ma-as-track { from { inline-size: 0; } }
</style>

Props

PropTypeDefaultDescription
rowsRow[]noneThe rows, top to bottom. Each has a kind: figure { label, value, note? }, ring { value 0–1, label, text }, checks { items: { text, done?, meta? }[] }, bars { values, labels?, highlight? }, card { title, text?, tag?, block? }, chips { items, active? }, map { label?, seed? }, player { title, meta?, progress 0–1 }, chat { messages: { from: me | them, text }[] }, list { items: { title, meta?, value? }[] }, days { label?, days: a string of x (done) and . (not) }, button { text }, clock { time, date? }, notice { app, title, text?, time? }.
titlestringnoneThe screen's title, large, under the status bar.
eyebrowstringnoneA small line over the title: a date, a greeting.
tabs{ label: string; icon: Icon }[]noneUp to five tabs at the foot. icon is one of home, chart, list, user, search, plus, bell, book, moon, map, chat or wallet.
activenumber0The tab marked as current.
block1 | 2 | 3 | 4 | 5noneThe colour block the accent takes (the ring, the done checks, the chips, the buttons); unset, the theme's primary. A card can name its own block.
timestring'9:41'The clock in the status bar.
labelstringnoneWhat the screen shows, for a screen reader. Unset, the screen is decoration and hidden from assistive technology.
chromebooleantrueThe status bar. false draws a widget or a watch face: the rows edge to edge, at the size of the box.

Reduced motion

The screen is drawn as it ends: the ring full to its value, the bars grown, the route drawn, the checks ticked.

With ClientRouter

CSS only; a fresh page draws the screen again.

Why it is built this way

Replaces: App screenshots on a landing page · Device mockups with static screenshots. See the migration table.

A bar chart whose bars grow from the baseline one after another when it scrolls into view; hovering a bar shows its value.

every browserscroll

A browser window around a screenshot or any content: the three lights, an address bar with a lock, optional tabs with one active.

every browseralways

Gauge

Data

A ring, a three-quarter arc or a half circle that fills to a value as it scrolls into view, the number counting up in the middle with it and a label under it.

newly · Chrome 91 · Firefox 128 · Safari 17scroll

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

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