GitHub

/ 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.

pressed = false

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.

AttributeValuesWhere it appears
data-stateopen / closed / opening / closing / on / offDialog, Toggle, Tooltip, Popover
data-orientationhorizontal / verticalTabs, RadioGroup, Slider
data-sidetop / right / bottom / leftFloating-positioned elements
data-directionltr / rtlRTL inversion
data-disabled(empty string)Disabled state
data-checkedtrue / false / mixedCheckbox / Toggle / Switch
data-component / data-component-hostcombobox / dialog / …Identifies the component root element
data-component-parttitle / 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:

  1. Use Tailwind / UnoCSS / a global stylesheet (recipes 1, 5). Not scoped to begin with, so the problem doesn't exist. First-line recommendation.
  2. Pass a class to 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 to app.css).
  3. CSS Custom Properties (recipe 3). They cascade past Svelte scoping. Best for theme propagation.
  4. :global(...) piercing. Last resort.
    <style>
      .my-combo :global([data-component-part='item'][data-highlighted]) {
        background: var(--ds-accent-subtle);
      }
    </style>
    Svelte 5 also supports the:global { ... } block syntax.

Recommended stack

LayerToolUse
Product baselineGlobal CSS [data-component*] selectorsReset-style rules shared across every Dialog
Design-system partsclass pass-through to subcomponentsStructural styling for <MyDialog>
Variant / themeCSS Custom PropertiesBrand colours, dark / light switching
State differencesdata-state selectors (or Tailwind data-[state=open]:)Open / closed, selected, disabled, hover
Element swapchild snippetRender <a> or <MyButton> as the root element

What to read next