Unreleased software — APIs may change without notice.

toolbox

GitHub
Guide

Authoring your own components

How to build a component with the system. We build a calendar — and the whole of it is composition, stages, and the public API. Nothing else.


The method

Authoring a component is six steps. They never change:

  1. Pick the layout structure. What shape is this thing.
  2. Choose the compositions. Which layout primitives build that shape, and hold it across every screen size.
  3. Separate content from stages. Decide what is a painted region and what is just content sitting on it.
  4. Place the stages. Put .stage on the tags that need a back-paint. Stages auto-nest, so a stage inside a stage reads as forward of it.
  5. Apply the API. Use @scope to set the public tokens — --bg, --fg, --type, --hue and the rest — on the tags, locally and without classes.
  6. Wire state with ARIA. Selected, current, pressed — these are attribute selectors on ARIA state, not classes.

Two things to hold onto before we start. Layout lives in the markup, as classes. The composition primitives are how you build shape — always. And @scope is never a layout tool. It cannot make a layout decision the primitives don't already give you. Its job is applying the public API to tags — locally, without inventing a class to hang it on.


Step 1 — the structure

A calendar is a header — a month label between previous and next controls — above a grid of day cells, seven across, one column per weekday.

That is two shapes: a three-part header bar, and a fixed seven-column grid. Both are things the system already builds.


Step 2 — the compositions

The header is left / centre / right — that is .lcr. The grid is always seven columns, never more or fewer; that is the explicit-size primitive .ngrid with --cols: 7. (Its responsive sibling .grid would be wrong here — a calendar must not reflow to six columns or eight.)

Wrap the two in a .column so they stack. The whole layout, in markup:

<div class="column">
  <div class="lcr">
    <button class="icon-btn" aria-label="previous month">‹</button>
    <strong>March 2026</strong>
    <button class="icon-btn" aria-label="next month">›</button>
  </div>
  <div class="ngrid" style="--cols: 7">
    <!-- 7 weekday labels, then the day cells -->
  </div>
</div>

No custom CSS. The shape is three composition classes — .column, .lcr, .ngrid — and it already holds at every width.


Step 3 — content versus stages

Look at what should paint a surface. The calendar as a whole is a panel — it sits on the page as its own region, so it is a stage. Each day cell is also a small surface the eye lands on, distinct from the gaps around it — so each day is a stage too.

The weekday labels and the month header are not surfaces. They are content — text resting on the stages. They get no .stage.


Step 4 — place the stages, with semantic tags

Add .stage to the calendar panel and to each day cell. Stages auto-nest, so the day cells — stages inside the panel stage — render one step forward of it. The depth ramp is automatic.

Choose tags by meaning. The calendar is a <section>. A weekday label is a quiet inline abbreviation — a <small>. A day cell genuinely is a date, so it is a <time> with a machine-readable datetime — correct for the content, and it gives a screen reader a real date. Two distinct tags, each right for what it holds, and — usefully — each targetable by structure later.

<section class="column stage">          <!-- the calendar panel -->
  <div class="lcr">…</div>
  <div class="ngrid" style="--cols: 7">
    <small>Mo</small> … <small>Su</small>          <!-- labels: content -->
    <time datetime="2026-03-01" class="stage">1</time>  <!-- each day: a stage -->
    <time datetime="2026-03-02" class="stage">2</time>
    …
  </div>
</section>

Step 5 — apply the API with @scope

The structure is done. What is left is tuning: the weekday labels want smaller, quieter text; the day cells want their own size; days spilling in from a neighbouring month want dimmer ink. Every one of those is a public token — --type, --fg — set on a tag.

Setting them inline, style="--type: -2; --fg: -0.5" repeated on seven labels and forty-something cells, is repetition. Instead, one @scope block applies the tokens by structure. A @scope rule is a <style> that lives inside the element it styles — and there is exactly one per component, on its outermost tag:

<section class="column stage">
  <style>
    @scope {
      small  { --type: -2; --fg: -0.5; }   /* weekday labels */
      time   { --type: -1; }               /* day cells      */
      .muted { --fg: -0.3; }               /* neighbouring month */
    }
  </style>

  <div class="lcr">…</div>
  <div class="ngrid" style="--cols: 7">…</div>
</section>

The discipline of @scope is four rules:

  • Reach for the core API first. --bg, --fg, --hue, --hue-shift, --hue-lock, --type, --scale — these are the default tools, and most of a @scope block is just these. Other properties are allowed when the component genuinely needs one — the heat chart later uses aspect-ratio to stop empty cells collapsing — but they are the exception, not the habit. If a block fills up with non-token properties, that is the signal to stop and rethink. The one hard line: never display, never a grid-template, never layout. Layout is the primitives — @scope cannot make a layout decision.
  • It targets by structure first. You are inside the component; reach the elements by tag and position — small, time. Distinct semantic tags mean you rarely need anything else.
  • A small focused class only when structure and ARIA can't. "A day from a neighbouring month" is not an ARIA state — ARIA has no honest attribute for it. That is the one case a narrow class earns its place: .muted, used only here. State that is an ARIA concern uses the attribute instead (next step).
  • One block, on the outermost tag. A local component has a single @scope, written on its eldest element, reaching everything inside. Not one per sub-part — one, at the top.

Step 6 — state with ARIA

A calendar has a selected day and a current day. These are states, and a state with an ARIA attribute uses that attribute — not a class. Mark today with aria-current="date" and the chosen day with aria-selected="true", then style those attributes from the same one @scope block, still touching only the API:

@scope {
  small  { --type: -2; --fg: -0.5; }
  time   { --type: -1; }
  .muted { --fg: -0.3; }
  /* today — a louder surface */
  [aria-current="date"]  { --bg: 0.4; }
  /* the selected day — loudest */
  [aria-selected="true"] { --bg: 0.8; --fg: -1; }
}

The rule of thumb, in order: structure, then ARIA, then a small class. A distinction the tags already express needs nothing. A genuine state ARIA can name uses the attribute — accessible by construction, one source of truth. Only a distinction neither can express falls to a focused class.

<time datetime="2026-03-11" class="stage">11</time>
<time datetime="2026-03-12" class="stage" aria-current="date">12</time>
<time datetime="2026-03-13" class="stage" aria-selected="true">13</time>

The result

Composition for the shape, .stage for the surfaces, one @scope block applying the API by structure, ARIA for state. Here it is, running:

March 2026
MoTuWeThFrSaSu

Toggle the theme — today's surface, the selected day, the muted days all hold, because every one is a token, not a fixed colour. The grid is .ngrid, the header is .lcr, the depth ramp between panel and days is automatic. The only authored CSS is one @scope block on the outermost tag — almost all of it core API tokens, applied to tags by structure.


A second component — a heat chart

The same six steps, a different component: a contribution heat chart, the kind that tracks activity over a year. It is worth building because of what its data turns out to be.

Structure and composition (steps 1–2). A heat chart is a fixed grid — seven rows, one per weekday — that reads down each column, a column per week. That is .ngrid again, this time with --rows: 7: the explicit-size primitive, filling column by column.

Content and stages (steps 3–4). The panel is a .stage. Each cell is a .stage too. And here is the point of the whole exercise — a cell's heat is its --bg. A quiet day is --bg: 0 and recedes into the panel; a busy day is --bg: 1 and sits loud and forward. The activity value, 0 to 1, is the surface presence. There is no colour scale to define, no legend to map — the data is already in the system's units.

Applying the API (step 5). This is the instructive difference from the calendar. The per-cell --bg is data — it varies from cell to cell — so it cannot live in a @scope rule, because a rule sets every matching element the same. Per-cell data goes inline in the markup, which is exactly what API-in-markup is for:

<div class="ngrid" style="--rows: 7">
  <div class="stage" style="--bg: 0"></div>     <!-- a quiet day -->
  <div class="stage" style="--bg: 0.75"></div>  <!-- a busy day  -->
  …
</div>

So the heat chart's @scope does almost nothing — the per-cell data carries the colour. It holds a single rule, and that rule is a good illustration of the soft rule on @scope: you reach for the core API first, but other properties are allowed when the component needs them. An empty <div> has no height and would collapse, so the cells are given aspect-ratio: 1. That is not a core token — and it does not need to be. The lesson holds either way: @scope does as much or as little as the component needs, and here, nearly nothing.

State (step 6) — there isn't any. A heat chart has no selected or current cell to style. Not every component has state, and when it doesn't, the ARIA step is simply skipped. The method bends to the component, not the other way around.

Sixteen weeks of it, running — every cell a <div class="stage"> whose only per-cell value is an inline --bg:

Each column is a week; each row a weekday. Quiet days recede, busy days come forward — and the only thing set per cell is --bg.

No colour was chosen anywhere in this component. The heat is surface presence, the surface is a token, the token took the data directly. That is the system working as intended — and a sign that when a component fights you, the question to ask is which primitive is missing, not which class to invent.