/ Foundations — 07
Styling
Kumiki Layer 4 ships zero bytes of CSS. Why we built it that way, how to dress it up, and how to navigate Svelte's scoped-style limits — five recipes and one pitfall.
Why Kumiki ships no styles
Layer 4 emits only semantic DOM + ARIA + data-* attributes.
Deliberate:
- Bundle budgets: Toggle 1.5 KB / Dialog 3.5 KB / Combobox 4.5 KB don't leave room for CSS.
- Design systems vary: Tailwind / UnoCSS / vanilla CSS / CSS-in-JS — by deciding nothing, we fit all of them.
- Animation is also CSS-driven: we just emit
data-state="open|closed"; you decide between CSS Transitions, View Transitions, or a motion library.
So you compose styling from five techniques. Listed below in order of preference.
Recipe 1: data-* selectors (the canonical state-styling route)
Read Kumiki's emitted attributes via CSS selectors. Battle-tested pattern established by Radix.
| Attribute | Values | Where it appears |
|---|---|---|
data-state | open / closed / opening / closing / on / off | Dialog, Toggle, Tooltip, Popover |
data-orientation | horizontal / vertical | Tabs, RadioGroup, Slider |
data-side | top / right / bottom / left | Floating-positioned elements |
data-direction | ltr / rtl | RTL inversion |
data-disabled | (empty string) | Disabled state |
data-checked | true / false / mixed | Checkbox / Toggle / Switch |
data-component / data-component-host | combobox / dialog / … | Identifies the component root element |
data-component-part | title / close / overlay / … | Identifies subcomponent elements |
/* global stylesheet */
[data-state='on'] { background: var(--ds-accent); color: white; }
[data-state='off'] { background: var(--ds-surface-2); }
[data-state='open'] { animation: fade-in 200ms; }
[data-state='closed'] { animation: fade-out 150ms; } The same in Tailwind / UnoCSS utilities:
<Toggle.Root class="bg-gray-200 data-[state=on]:bg-blue-500 data-[state=on]:text-white" /> Recipe 2: class / style pass-through
Layer 4 subcomponents are thin one-element wrappers that spread ...rest. Anything you pass — class, style, additional data-*,
extra ARIA attributes — lands on the real DOM root.
<Toggle.Root class="ds-toggle" style="--ring-color: var(--ds-accent)">
Mute
</Toggle.Root> Implementation reference: packages/components/src/toggle/Root.svelte declares [key: string]: unknown in its Props type and spreads ...rest directly on its <button>.
Recipe 3: CSS Custom Properties (the canonical theme-propagation route)
Unlike Svelte's scoped CSS, CSS variables flow through normal cascade. Declared on the parent, they reach DOM inside child components — completely bypassing Svelte's scope barrier.
<Combobox.Root style="
--combobox-bg: var(--ds-surface);
--combobox-border: var(--ds-line-strong);
">
<Combobox.Input class="ds-input" />
</Combobox.Root>
<style>
/* MyCombobox.svelte's scoped style — reaches the child's internal <input> */
.ds-input {
background: var(--combobox-bg);
border: 1px solid var(--combobox-border);
}
</style> Use for: brand colours, dark-mode switching, tokens that need to cross component boundaries.
Recipe 4: child snippet — element swap
By default Toggle.Root renders a <button>. The escape hatch for
"I want an <a> here" or "I want my own <MyButton>".
<Toggle.Root bind:pressed>
{#snippet child({ props, state })}
<MyButton {...props} class="brand-btn" disabled={state.disabled}>
{state.pressed ? 'Muted' : 'On'}
</MyButton>
{/snippet}
</Toggle.Root> props is fully typed: type / aria-pressed / aria-disabled / data-state / onclick / onkeydown / id.
Your job is to spread it on your element.
Don't reach for this by default. child is an escape hatch, not the
standard styling path. If a class pass-through covers it, prefer that — and
remember that re-spreading props is on you (forgetting it loses ARIA / event wiring).
Recipe 5: Tailwind / UnoCSS / vanilla CSS
Tailwind v4
<Toggle.Root class="
inline-flex items-center px-3 py-2 rounded-md
bg-gray-200 text-gray-700
data-[state=on]:bg-blue-600 data-[state=on]:text-white
data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed
" /> UnoCSS (default mode)
Identical authoring experience to Tailwind. The data-[state=on]: variant is built
in via @unocss/preset-mini / preset-wind3.
UnoCSS svelte-scoped mode
@unocss/svelte-scoped scans each parent .svelte, then injects the
generated CSS wrapped in :global(...) into that file's <style>. Because the rules are global, utilities you wrote in the parent
reach DOM inside child components without further work.
Vanilla CSS / CSS Modules
/* app.css (global) */
.ds-toggle {
display: inline-flex; align-items: center;
padding: 8px 12px; border-radius: 6px;
background: var(--ds-surface-2); color: var(--ds-ink);
}
.ds-toggle[data-state='on'] {
background: var(--ds-accent); color: white;
}
.ds-toggle[data-disabled] { opacity: 0.5; cursor: not-allowed; } <Toggle.Root class="ds-toggle">Mute</Toggle.Root> Pattern: building a design system on top
Wrap Kumiki in your own <MyToggle> for product-wide reuse.
<!-- src/lib/components/MyToggle.svelte -->
<script lang="ts">
import { Toggle } from '@kumiki/components';
import type { Snippet } from 'svelte';
type Props = {
pressed?: boolean;
'aria-label': string;
children: Snippet;
};
let { pressed = $bindable(false), 'aria-label': ariaLabel, children }: Props = $props();
</script>
<Toggle.Root bind:pressed aria-label={ariaLabel} class="ds-toggle">
{@render children()}
</Toggle.Root>
<style>
/* Recipe 2 — class pass-through means this scoped style reaches the real DOM */
:global(.ds-toggle) {
display: inline-flex;
align-items: center;
padding: 8px 12px;
border-radius: 6px;
background: var(--ds-surface-2);
transition: background 120ms;
}
:global(.ds-toggle[data-state='on']) {
background: var(--ds-accent);
color: white;
}
</style> Consumer side:
<script lang="ts">
import MyToggle from '$lib/components/MyToggle.svelte';
let muted = $state(false);
</script>
<MyToggle bind:pressed={muted} aria-label="Mute">
{muted ? 'Muted' : 'On'}
</MyToggle> Pitfall: Svelte's scoped <style> doesn't propagate into children
A long-standing Svelte constraint: classes defined in a parent .svelte's <style> do not reach DOM elements inside child components.
<!-- Doesn't work as expected -->
<Combobox.Root class="my-combo">
<Combobox.Input />
</Combobox.Root>
<style>
/* .my-combo isn't used in this file's template, so Svelte may strip it,
and the descendant <input> lives in another component's scope. */
.my-combo input { padding: 8px; }
</style> Four ways out:
- Use Tailwind / UnoCSS / a global stylesheet (recipes 1, 5). Not scoped to begin with, so the problem doesn't exist. First-line recommendation.
- Pass a
classto each subcomponent (recipe 2).<Combobox.Input class="ds-input" />lands the class on the child's root element. Inside a parent<style>you'd write:global(.ds-input)(or move the rule toapp.css). - CSS Custom Properties (recipe 3). They cascade past Svelte scoping. Best for theme propagation.
:global(...)piercing. Last resort.
Svelte 5 also supports the<style> .my-combo :global([data-component-part='item'][data-highlighted]) { background: var(--ds-accent-subtle); } </style>:global { ... }block syntax.
Recommended stack
| Layer | Tool | Use |
|---|---|---|
| Product baseline | Global CSS [data-component*] selectors | Reset-style rules shared across every Dialog |
| Design-system parts | class pass-through to subcomponents | Structural styling for <MyDialog> |
| Variant / theme | CSS Custom Properties | Brand colours, dark / light switching |
| State differences | data-state selectors (or Tailwind data-[state=open]:) | Open / closed, selected, disabled, hover |
| Element swap | child snippet | Render <a> or <MyButton> as the root element |
What to read next
- Layers by example — how user-land code differs at Layer 2/3/4/5.
- Composition — adding optional features via
with*wrappers. - i18n & RTL — using
data-directionfor RTL styling.