/ 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.
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.
| Responsibility | L2 (machine) | L3 (headless) | L4 (component) | L5 (atelier) |
|---|---|---|---|---|
| FSM (state transitions) | Kumiki | Kumiki | Kumiki | Kumiki |
| Svelte reactivity bridge | You | You* | Kumiki | Kumiki |
| <button> element | You | You | Kumiki | Copied |
| ARIA attrs (aria-pressed, …) | You | Kumiki | Kumiki | Kumiki |
| data-state output | You | Kumiki | Kumiki | Kumiki |
| Click / key handling | You | Kumiki | Kumiki | Kumiki |
| Accessible name (aria-label) | You | You | You | Kumiki |
| Styling | You | You | You | Copied |
* 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'schildsnippet 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-*,classpass-through, and thechildsnippet at Layer 4. - Architecture — the full five-layer model.
- Composition — adding optional features via
with*wrappers.