Chapter 50: Styling With Tokens, Cascade, and Containers
A component library is also a styling system.
The Kit components from the previous chapters all have their own CSS, scoped to their shadow DOMs. That works for the components individually. It doesn’t, on its own, give the application a coherent visual system — consistent colors across components, predictable spacing, a way to theme the whole application from a single boundary, a way to override design-system defaults when the application needs to.
The styling system this chapter builds uses the CSS-as-runtime capabilities from Chapter 27 — custom properties for tokens, cascade layers for ownership, container queries for component reactivity, the ::part() and ::slotted() mechanisms for cross-shadow-boundary styling. The system is web-native. No CSS-in-JS library. No build-time token transformer. No JavaScript style management. The platform’s CSS runtime does the work.
After this chapter, the Kit library has a complete component-and-styling system. Chapter 51 then connects the styling system to the broader design-systems argument (Brad Frost, atomic design, Style Dictionary, Shadcn-style component-copying).
Design Tokens as Custom Properties
Section titled “Design Tokens as Custom Properties”The foundation is the design token — a named value that the design system uses across components. Colors. Spacing. Border radii. Font families. Typography scales. Shadow definitions. Animation timings. Each token is a named variable that components and applications reference instead of hardcoding values.
In Kit’s styling system, every token is a CSS custom property:
:root { /* Color */ --kit-color-accent: oklch(60% 0.2 250); --kit-color-danger: oklch(55% 0.22 30); --kit-color-success: oklch(60% 0.2 150);
--kit-surface: white; --kit-on-surface: black; --kit-surface-subtle: oklch(96% 0 0); --kit-border: oklch(85% 0 0);
--kit-color-focus: var(--kit-color-accent);
/* Spacing scale */ --kit-space-1: 0.25rem; --kit-space-2: 0.5rem; --kit-space-3: 0.75rem; --kit-space-4: 1rem; --kit-space-5: 1.5rem; --kit-space-6: 2rem; --kit-space-7: 3rem;
/* Border radius */ --kit-radius-sm: 0.125rem; --kit-radius-md: 0.25rem; --kit-radius-lg: 0.5rem; --kit-radius-pill: 999px;
/* Typography */ --kit-font-body: system-ui, sans-serif; --kit-font-mono: ui-monospace, monospace; --kit-font-size-sm: 0.875rem; --kit-font-size-md: 1rem; --kit-font-size-lg: 1.125rem;
/* Shadow */ --kit-shadow-sm: 0 1px 2px rgba(0,0,0,0.05); --kit-shadow-md: 0 4px 12px rgba(0,0,0,0.08); --kit-shadow-lg: 0 16px 48px rgba(0,0,0,0.16);
/* Animation */ --kit-duration-fast: 150ms; --kit-duration-normal: 250ms; --kit-duration-slow: 400ms;}Components consume tokens by name:
class KitButton extends LitElement { static styles = css` button { padding: var(--kit-space-2) var(--kit-space-4); border-radius: var(--kit-radius-md); background: var(--kit-color-accent); color: var(--kit-on-accent, white); font-family: var(--kit-font-body); font-size: var(--kit-font-size-md); box-shadow: var(--kit-shadow-sm); transition: opacity var(--kit-duration-fast) ease; } `}When the application overrides the tokens — anywhere in the cascade — the components adopt the new values automatically:
[data-theme="brand"] { --kit-color-accent: oklch(60% 0.25 320); /* magenta */ --kit-radius-md: 0.75rem; /* rounder */}<section data-theme="brand"> <kit-button>Now I'm a magenta button with rounder corners</kit-button></section>The custom properties propagate through the cascade across the shadow boundary (this is the platform’s design — custom properties inherit through shadow DOM specifically because they’re meant to support cross-boundary theming). The components don’t have to know about the theme. The application doesn’t have to know about the components’ internal structure. The cascade does the work.
Component-Local Token Layering
Section titled “Component-Local Token Layering”Inside components, a second layer of indirection helps. A component declares its own tokens that derive from the design-system tokens:
class KitButton extends LitElement { static styles = css` :host { --_button-padding-y: var(--kit-button-padding-y, var(--kit-space-2)); --_button-padding-x: var(--kit-button-padding-x, var(--kit-space-4)); --_button-radius: var(--kit-button-radius, var(--kit-radius-md)); --_button-bg: var(--kit-button-bg, var(--kit-color-accent)); --_button-color: var(--kit-button-color, var(--kit-on-accent, white)); }
button { padding: var(--_button-padding-y) var(--_button-padding-x); border-radius: var(--_button-radius); background: var(--_button-bg); color: var(--_button-color); } `}The component exposes a public token API (--kit-button-padding-y, --kit-button-bg, etc.) that the application can override per-component. The defaults fall through to the design-system tokens (--kit-space-2, --kit-color-accent). The component’s internal styles use private tokens (prefixed with --_ by convention, which is the Lit team’s recommendation for private variables).
The pattern means:
- The application can override a single button’s appearance without changing the design system:
<kit-button style="--kit-button-bg: red">. - The application can change the design system’s
--kit-color-accentand every button updates. - The application can override the design system at a boundary (
[data-theme="brand"]) and every component in the boundary updates. - The component’s internal styles aren’t broken by changes to the cascade because the private tokens isolate the component’s logic from the public API.
This is the same architectural pattern as the runtime’s events-and-commands distinction (Chapter 37) applied to styling: a public surface the application can decorate, and a private implementation that depends on the public surface but isn’t directly addressable.
Cascade Layers for Ownership
Section titled “Cascade Layers for Ownership”Multiple sources of styling exist in any non-trivial application. The design system’s reset. The design system’s components. The application’s own styles. Page-specific overrides. Component-specific overrides. Without explicit ordering, the cascade’s specificity rules decide who wins, and the result is usually the specificity arms race Chapter 27 described.
Cascade layers fix this:
@layer kit.reset, kit.tokens, kit.base, kit.components, app.base, app.components, app.overrides;Each layer is named explicitly. Rules in later layers always win over earlier layers, regardless of selector specificity. The team can decide:
kit.reset— design-system reset (margin: 0 on common elements, box-sizing, etc.)kit.tokens— design tokens declared on:rootkit.base— design-system typography, default styles for unstyled elementskit.components— Kit component stylesapp.base— application-wide typography and reset overridesapp.components— application’s own componentsapp.overrides— application’s per-page or per-context overrides
An application’s stylesheet imports the design system into its layers:
@import url('kit-styles.css') layer(kit.components);
@layer app.base { body { font-family: 'Inter', var(--kit-font-body); }}
@layer app.overrides { /* Overrides win regardless of how specific the Kit selector was */ kit-button[variant="primary"] { text-transform: uppercase; }}The application’s app.overrides layer always wins. The Kit components in kit.components can be overridden cleanly. The team doesn’t need !important. The cascade is predictable.
For Kit’s own internals, the styles are declared inside @layer kit.components. Lit’s static styles declaration accepts a regular CSS string; declaring the layer requires either:
- Importing the styles through a stylesheet with
@import url(...) layer(...), or - Wrapping the component’s CSS in
@layer kit.components { ... }.
The Lit team has been working on more ergonomic patterns for this; the current approach is the second option:
static styles = css` @layer kit.components { button { /* ... */ } }`This makes the component’s styles part of the named layer, so application overrides in app.overrides win predictably.
Container Queries Inside Components
Section titled “Container Queries Inside Components”Chapter 12 (Mobile Web → Responsive → Adaptive) introduced container queries as the architectural shift from viewport-based to container-based layout. Kit components use container queries internally to adapt their layouts:
class KitCard extends LitElement { static styles = css` :host { container-type: inline-size; container-name: card; display: block; }
.layout { padding: var(--kit-space-4); background: var(--kit-surface); border-radius: var(--kit-radius-lg); }
/* Default: single column */ .layout { display: grid; gap: var(--kit-space-3); }
/* When the card has enough width: side-by-side */ @container card (min-width: 32rem) { .layout { grid-template-columns: 1fr 2fr; gap: var(--kit-space-5); } } `
render() { return html` <div class="layout"> <div class="media"><slot name="media"></slot></div> <div class="content"><slot></slot></div> </div> ` }}
customElements.define('kit-card', KitCard)The card adapts to its container’s width. If the card is in a narrow sidebar, the content stacks. If the card is in a wider column, the media and content sit side by side. The same component handles both cases without the application having to know about the card’s internal layout decisions.
This is the architectural pattern container queries enable. Each component owns its layout invariants. The application composes components without needing to coordinate their internal layouts. The page becomes a container; the section is a container; the card is a container. Each container is a layout context, and components inside each context adapt to it.
Cross-Shadow Styling: ::part() and ::slotted()
Section titled “Cross-Shadow Styling: ::part() and ::slotted()”Components use Shadow DOM for style encapsulation. The encapsulation is generally what you want — the component’s internal styles don’t leak out, and external styles don’t accidentally affect internals. But for some legitimate styling needs, the application has to reach into the component’s internals. The platform provides two mechanisms.
::part() lets a component expose specific internal elements as styleable from outside:
class KitButton extends LitElement { render() { return html` <button part="button" ...> <slot></slot> </button> ` }}/* Application stylesheet */kit-button::part(button) { text-transform: uppercase; letter-spacing: 0.05em;}The component’s <button> is exposed via the button part. The application’s CSS can target it through ::part(button). The component remains in control of what’s exposed; only elements with a part attribute are reachable, and only by the specific name.
::slotted() lets a component style the elements that have been slotted into it:
/* Inside the kit-button shadow DOM */::slotted(kit-icon) { font-size: 1.1em;}The component’s CSS targets icon elements that the application has placed into its default slot. The styling applies only to slotted content, not to the component’s own internals.
Both mechanisms are platform features. They preserve the shadow-DOM encapsulation while opening specific surfaces the component author has decided to make styleable. The pattern is opt-in styling extension, which fits the decoration-versus-replacement principle (Chapter 33) — the component decorates the platform’s element model, the application can decorate the component’s styling, the surface is opt-in at each level.
Theme Boundaries
Section titled “Theme Boundaries”Putting the pieces together, a Kit theme is a set of token overrides applied at a boundary:
@layer kit.tokens { /* Light theme — default */ :root { --kit-surface: white; --kit-on-surface: black; --kit-color-accent: oklch(55% 0.2 250); /* ... */ }
/* Dark theme — applied via attribute */ [data-theme="dark"] { --kit-surface: oklch(20% 0 0); --kit-on-surface: oklch(95% 0 0); --kit-color-accent: oklch(70% 0.18 250); /* ... */ }
/* System preference — applied via media query */ @media (prefers-color-scheme: dark) { :root { --kit-surface: oklch(20% 0 0); --kit-on-surface: oklch(95% 0 0); --kit-color-accent: oklch(70% 0.18 250); } }
/* High-contrast — honored automatically */ @media (forced-colors: active) { :root { --kit-color-accent: LinkText; --kit-surface: Canvas; --kit-on-surface: CanvasText; } }}The application can:
- Force a theme:
<html data-theme="dark">. - Let the system choose via
prefers-color-scheme. - Apply a theme to a section:
<section data-theme="dark">...</section>. - Mix themes (rare but possible):
<section data-theme="dark"> ... <section data-theme="light"> ....
The components inside each themed region pick up the right tokens. The Chapter 25 storage pattern (the application’s preference stored in localStorage and reflected as data-theme on the root) plus this token system gives the application a complete user-toggleable theming story without JavaScript style management.
What This System Doesn’t Include
Section titled “What This System Doesn’t Include”A few things the styling system deliberately omits.
No build-time token transformer. Style Dictionary, Tokens Studio, Specify, and similar tools transform token definitions from a single source (often JSON) into per-platform output (CSS, iOS, Android, etc.). For applications that ship to multiple platforms, these tools earn their place. For web-only applications, the tokens are CSS custom properties from the start; no transformation is required.
No CSS-in-JS. The component styles live in static styles = css\…“. They’re scoped via Shadow DOM. They’re loaded once per component class. They’re amenable to standard CSS tooling (linters, formatters, optimizers). The CSS-in-JS pattern that React popularized has been falling out of favor in recent years (Emotion’s main maintainer has publicly noted the trade-offs aren’t worth it for most applications); the platform-first approach skips the layer.
No utility-class system on top. Tailwind, Tachyons, and similar utility-class systems are an alternative to design tokens — they give the developer a vocabulary of single-purpose classes that compose into a design. The two systems can coexist (Tailwind classes on the application’s own markup, tokens for Kit components), but the architecture in this chapter doesn’t include utility classes as a primary surface.
No CSS Modules. Shadow DOM provides the scoping CSS Modules give you, natively, without a build step.
The omissions are deliberate. The platform’s own primitives — tokens via custom properties, scope via Shadow DOM, ownership via cascade layers, reactivity via container queries — handle the work these alternatives were built to solve.
Bridge to Design Systems
Section titled “Bridge to Design Systems”The next chapter (Chapter 51) closes Part V by connecting the styling system to the broader design systems discipline. Brad Frost’s atomic design. Style Dictionary’s token philosophy. Storybook’s component-development workflow. The Shadcn-style component-copying distribution model. Each of these is a piece of how working design systems get built, and the styling system this chapter built is the foundation they sit on.
After Chapter 51, Part V is complete. The Kit component library is fully sketched — components, forms, styling, design-system integration. Part VI then ships the maintained version as Kitsune.
Exercise: Build the Theme System
Section titled “Exercise: Build the Theme System”Implement the styling system from this chapter:
- Define the token set on
:rootwith at least colors, spacing, radii, and typography. - Set up cascade layers:
kit.reset,kit.tokens,kit.base,kit.components,app.overrides. - Update at least two Kit components (
kit-button,kit-card) to use tokens. - Add a dark theme at
[data-theme="dark"]that overrides the relevant tokens. - Add a
prefers-color-scheme: darkblock that defaults to dark when the user prefers it. - Add a
forced-colorsblock for Windows High Contrast Mode. - Add a container query to
kit-cardthat switches layout at a defined width.
Then test:
- Toggle
data-theme="dark"on<html>and verify all components adapt. - Apply
data-theme="dark"to a single section; verify only that section’s components change. - Resize a
kit-card’s container; verify the layout adapts. - Use
::part()from application CSS to override a specific component’s internals. - Try the page in Windows High Contrast Mode (or use the dev-tools emulator); verify the components remain usable.
Then integrate with Chapter 25’s storage pattern:
- Add a theme toggle button that writes the chosen theme to
localStorage. - On page load, read the stored theme and apply it to
<html>. - Listen for the storage event so other tabs update when the theme changes.
Reflect on:
- What did CSS handle without JavaScript? (Theme inheritance, container-based layout, system preference detection.)
- How did theme context flow? (Through the cascade, via custom-property inheritance across shadow boundaries.)
- What styling API did Kit components expose? (Public custom properties for theming,
::part()for advanced overrides, slot-based content composition.) - If the design system added a new token tomorrow, what would change? (One CSS rule defining the default; the components that adopt the new token get an update.)
The styling system is small in code and large in capability. The platform’s CSS runtime does most of the work. The architecture’s job is to make the work legible — token names, layer ordering, container scopes — so the system stays predictable as it grows.