GitHub

/ Foundations — 06

Layers by example: Toggle

The same Toggle, written at Layer 2, 3, 4, and 5 — observed side by side. Each step down the stack hands you a little more control and a little more responsibility.

Kumiki's layers all expose the same behaviour at different levels of abstraction. Drop one layer and you take over more of the DOM, ARIA, and event plumbing — but you also gain freedom to choose the structure. Climb one layer and you write less code at the cost of accepting Kumiki's structural choices.

We use Toggle as the worked example. The behaviour is simple (press to flip), but the implementation is present in all four layers, which makes it ideal for a side-by-side comparison.

pressed = false

1. Layer 4 — Compound component (the default entry point)

The shortest path. Toggle.Root renders the <button>, manages ARIA, data-state, keyboard, and SSR. You're responsible for two things: receiving state via bind:pressed, and supplying an aria-label (or visible label).

<script lang="ts">
  import { Toggle } from '@kumiki/components';
  let pressed = $state(false);
</script>

<Toggle.Root bind:pressed aria-label="Mute">
  {pressed ? 'Muted' : 'On'}
</Toggle.Root>

Resulting DOM:

<button type="button" aria-pressed="false" data-state="off" id="toggle-…">
  On
</button>

Pick this when: 90% of cases. The native <button> works, you don't need to override the wrapping structure.

2. Layer 3 — Headless attachment

For when you need to choose the element yourself (an <a>, a <div role="button">, a custom wrapper). createToggle() returns a {@attach}-compatible factory that you spread on whatever element you like.

<script lang="ts">
  import { createToggle } from '@kumiki/headless/toggle';

  const t = createToggle({ initial: false });

  // controller.pressed is a plain getter. To display it as text you must
  // subscribe and mirror into $state. (Not needed if CSS handles everything.)
  let pressed = $state(t.pressed);
  $effect(() => t.subscribe(({ context }) => (pressed = context.pressed)));
</script>

<button {@attach t.root} aria-label="Mute" class="my-btn">
  {pressed ? 'Muted' : 'On'}
</button>

On mount, {@attach t.root} writes the DOM-side attributes (type, aria-pressed, data-state, id) and wires click + key (Space / Enter) listeners. What's added to your responsibilities: picking the element, styling, the visible label, and (only if needed) a subscribe to mirror state into reactive locals.

Pick this when: Layer 4's fixed structure (<button>) doesn't fit. For example, when you need the toggle inside a <label>, or wrapped in a custom higher-order shell.

3. Layer 2 — Pure machine

No Svelte at all — a pure-TypeScript finite state machine. You write the DOM, ARIA, events, and keyboard yourself.

<script lang="ts">
  import { createToggleMachine } from '@kumiki/machines/toggle';

  const m = createToggleMachine({ initial: false });
  let pressed = $state(m.context.pressed);
  m.subscribe(({ context }) => (pressed = context.pressed));
</script>

<button
  type="button"
  aria-pressed={pressed ? 'true' : 'false'}
  data-state={pressed ? 'on' : 'off'}
  aria-label="Mute"
  onclick={() => m.send({ type: 'TOGGLE' })}
>
  {pressed ? 'Muted' : 'On'}
</button>

The reason to drop to Layer 2 is usually not UI — it's logic reuse:

  • Validate Toggle logic on the server (SvelteKit server routes / Workers).
  • Write pure FSM unit tests in Vitest (no jsdom, ~20μs per transition).
  • Visualise transitions in stately.ai/viz (machine.toJSON() emits XState-compatible JSON).
  • Embed in non-Svelte hosts (vanilla JS, Web Components, another framework).

4. Layer 5 — Atelier (copy-paste styled variants)

The CLI copies sources into your repo. After copying they are your code — edit freely.

# Tailwind v4 variant
npx kumiki add toggle --variant=tailwind

# Vanilla CSS variant
npx kumiki add toggle --variant=vanilla

Files added:

src/lib/components/Toggle.svelte   # styled wrapper around Layer 4's Toggle.Root

Use it like any other Svelte component:

<script lang="ts">
  import Toggle from '$lib/components/Toggle.svelte';
  let pressed = $state(false);
</script>

<Toggle bind:pressed>Mute</Toggle>

Pick this when: you want a working visual baseline without writing CSS first. Layer 5 ships under 0.x.x-preview during the v1.0 series, so for stability-sensitive projects prefer Layer 4 + your own styling.

Responsibility matrix

What you write at each layer. "Kumiki" = library handles it; "You" = your code.

ResponsibilityL2 (machine)L3 (headless)L4 (component)L5 (atelier)
FSM (state transitions)KumikiKumikiKumikiKumiki
Svelte reactivity bridgeYouYou*KumikiKumiki
<button> elementYouYouKumikiCopied
ARIA attrs (aria-pressed, …)YouKumikiKumikiKumiki
data-state outputYouKumikiKumikiKumiki
Click / key handlingYouKumikiKumikiKumiki
Accessible name (aria-label)YouYouYouKumiki
StylingYouYouYouCopied

* The Svelte reactivity bridge is only needed at L3 if you display controller.pressed as text. If CSS handles everything via data-state, no subscribe is required.

Choosing a layer — decision tree

  • Need it working with styles right now?Layer 5 (npx kumiki add). Note: preview-tagged during v1.0.
  • Standard <button> is fine, you'll style it yourself?Layer 4 (<Toggle.Root>). The default entry point.
  • Need to choose the element type or structure yourself?Layer 3 ({@attach t.root}). If Layer 4's child snippet covers your need, stay at Layer 4 — it's less code.
  • Run outside Svelte / validate on the server / want just the FSM?Layer 2 (createToggleMachine).

What to read next

  • Styling — how to use data-*, class pass-through, and the child snippet at Layer 4.
  • Architecture — the full five-layer model.
  • Composition — adding optional features via with* wrappers.