Skip to content

Chapter 27: CSS Is a Runtime

CSS is usually described as styling.

That’s true in the same way JavaScript is scripting — technically accurate, but too small. Modern CSS is an adaptive runtime for presentation. It responds to document structure, component context, viewport, container size, user preferences, theme variables, application state, and environment. The CSS that runs in a 2025 browser has capabilities the CSS of 2015 didn’t — and most of those capabilities are runtime capabilities, in the precise sense that they evaluate and re-evaluate as the application changes.

A JavaScript runtime executes behavior. A CSS runtime resolves presentation. The two runtimes coexist in every browser. Frontend code that treats CSS as a static stylesheet — something the build pipeline produces, the browser loads once, and the application then mostly ignores — is leaving an enormous amount of capability on the table.

If modern frontend starts from the browser we have now, CSS has to be treated as one of its central runtime systems. That’s what this chapter argues, with examples of what CSS as a runtime means in practice.

The word runtime has a specific meaning worth spending a paragraph on.

A runtime, in language design, is the system that executes the language’s constructs at the time the program runs (as opposed to at compile time, build time, or write time). Java’s runtime is the JVM. JavaScript’s runtime is V8 (or JavaScriptCore, or SpiderMonkey). Python’s runtime is CPython. The runtime takes the program’s source — or some intermediate representation — and produces behavior in response to the program’s inputs.

CSS, until roughly 2015, was largely a build-time language by this definition. A preprocessor like Sass or Less compiled the source into a flat stylesheet at build time. The browser loaded the flat stylesheet and applied it. Most of the interesting computation happened during compilation. Once the stylesheet was loaded, what you saw was what you got — modulo a small set of pseudo-classes (:hover, :focus) that responded to user interaction.

Modern CSS is different. CSS custom properties resolve at runtime, in the same cascade pass that resolves selectors. Container queries evaluate against the current container’s size, recomputing when the size changes. :has() selectors evaluate against the document’s current state, recomputing as the DOM changes. Cascade layers re-resolve when stylesheets are added or removed. View Transitions execute as animated state changes between two snapshots of the rendered output. CSS now has the same kind of continuous, input-responsive evaluation model that JavaScript runtimes have — just for presentation instead of behavior.

This is what CSS is a runtime means. The chapter’s argument is that modern frontend architecture should use the runtime for what it can do, instead of pushing the same responsibilities into JavaScript.

The cleanest example of CSS as runtime is custom properties.

Preprocessor variables (the $primary-color and @primary-color of Sass and Less) are replaced at build time. The compiled CSS has the literal value where the variable used to be; the variable itself doesn’t exist in the browser. CSS custom properties (--primary-color) are different. They exist at runtime. They live in the cascade. They can be set, read, overridden, and inherited dynamically:

:root {
--color-accent: oklch(60% 0.2 250);
}
button {
background: var(--color-accent);
}

Change --color-accent on a parent element, and every button underneath updates immediately. No JavaScript involved. The cascade does the work.

[data-theme="danger"] {
--color-accent: oklch(55% 0.22 30);
}

Wrap a region in data-theme="danger", and that region’s buttons become the danger color. The region above the boundary uses the default. The CSS runtime resolves each var() call against the cascade at the point where it’s evaluated.

This is more than convenience. It means a boundary in the DOM can define a presentation context, and components inside the boundary inherit that context automatically. The same architectural pattern as the DOM context tree (Chapter 21) and the CSS cascade work the same way: containment in the tree means inheritance of context.

<kit-boundary surface="preview" data-theme="danger">
<kit-button>Delete</kit-button>
</kit-boundary>

The DOM carries the theme via the data-theme attribute. The CSS resolves the theme via the custom properties scoped to that selector. The component doesn’t have to know which theme it’s in. The pattern is identical to React’s <ThemeProvider> — but it’s a platform-native pattern, not a framework one.

CSS custom properties have one historical limitation: they’re typed as strings. A var(--gap) could be 1rem or nonsense or anything else; the cascade doesn’t validate it.

The @property rule, shipped in major browsers between 2021 and 2023, adds type-safety and animation support to custom properties:

@property --color-accent {
syntax: '<color>';
inherits: true;
initial-value: oklch(60% 0.2 250);
}

This declares --color-accent as a color-typed property with a default value. Invalid values get rejected. The browser knows the type, so it can animate changes to the property — a smooth transition from one color to another, computed by the CSS runtime, without JavaScript involvement.

The same applies to other types — <length>, <number>, <percentage>, <integer>, <angle>, <time>. A registered custom property of type <length> can be animated between 0rem and 4rem, with the browser interpolating the values continuously. Before @property, this kind of animation required JavaScript to manually update the property at 60 frames per second. After @property, the CSS runtime handles it.

For application architecture, this matters because most theming, design-token, and adaptive-presentation work can now happen entirely in CSS. The application sets values; the CSS runtime resolves them, animates them, and propagates them through the cascade. JavaScript is involved only when the application’s behavior changes — not when its presentation responds to a behavior change.

Container queries were covered architecturally in Chapter 12. The CSS-runtime angle is worth surfacing here.

A container query is a runtime evaluation. The browser observes the container’s size continuously. When the size crosses a threshold declared in @container, the styles inside the query block apply or unapply. The component’s layout adapts in real time, with no JavaScript involvement.

.card {
container-type: inline-size;
}
@container (min-width: 40rem) {
.card-content {
display: grid;
grid-template-columns: 1fr 2fr;
}
}

The reactivity is provided by the platform. The application doesn’t need a ResizeObserver. It doesn’t need a layout-effect hook. It doesn’t need to coordinate between component state and the rendered DOM. The container queries are declarative reactivity — the application declares the layout invariants, and the CSS runtime maintains them as conditions change.

This is the same architectural shape as the rest of the platform’s modern affordances. Storage events propagate state changes (Chapter 25). Form constraint validation runs continuously (Chapter 24). Container queries propagate layout changes. The runtime handles the continuous work; the application declares the invariants.

The :has() pseudo-class, shipped across browsers in 2022–2023, is one of the most underappreciated recent CSS additions.

Before :has(), CSS selectors could match an element based on its own properties and the properties of its ancestors. They couldn’t match an element based on the properties of its descendants. A parent element couldn’t react to changes in its children.

:has() changes that. The selector .card:has(:invalid) matches a card that contains any invalid form field. The selector form:has(input:focus) matches a form whose any input is currently focused. The selector article:has(img) matches an article that contains an image — a particularly useful pattern for layout adjustments.

.field:has(input:invalid) {
border-color: red;
}
form:has(:invalid) button[type="submit"] {
opacity: 0.5;
pointer-events: none;
}

The first rule highlights any field containing an invalid input. The second disables the submit button if any field in the form is invalid. Both of these used to require JavaScript — observe form validity, toggle classes on the form and its fields, coordinate the toggles. Now they’re three lines of CSS.

This is the architectural significance of :has(). Cross-element state propagation — when one element’s state affects another element’s styling — was the historical reason most CSS state had to be coordinated through JavaScript. The application would observe state changes and apply class names to the affected elements. :has() lets CSS do this directly. The application’s behavior doesn’t have to care about presentation; the CSS runtime resolves presentation against the current state.

CSS specificity has, historically, been one of the field’s most persistent pain points. A rule defined in a design system would be overridden by a rule with one more class selector somewhere else. The team would add !important to keep the design-system rule winning. Other rules would add !important to override the design-system rule. The cascade would devolve into a specificity arms race, with each generation of styles adding selectors to win over the previous.

Cascade layers, shipped in 2022, give CSS an explicit ordering mechanism:

@layer reset, tokens, base, components, utilities, overrides;

Rules in later layers always win over rules in earlier layers, regardless of selector specificity. The team can decide that tokens always wins over reset, that components always wins over base, that overrides always wins over utilities. The decision is explicit and documentable.

For application architecture, this means a design system can declare its layer ownership:

@layer kit.tokens;
@layer kit.components;
@layer app.overrides;

The design system’s tokens and components are in clearly-named layers. The application’s overrides have their own layer, which always wins. The team doesn’t need to fight specificity wars to override the design system; they just write their CSS in their layer.

This is the architectural lift cascade layers provide. Style ownership is declared explicitly. The team knows which layer their rules belong in. Conflicts resolve by layer, not by selector contortions. CSS scales to large codebases without the specificity death spiral that the field has been working around for decades.

The CSS Working Group hasn’t stopped adding to the cascade.

@scope, shipped in Chrome in 2023 and reaching cross-browser support through 2024–2025, lets a rule be scoped to a region of the DOM:

@scope (.card) {
h2 { font-weight: 600; }
p { line-height: 1.6; }
}

The h2 and p rules apply only to elements inside .card. This is the platform-native version of what CSS Modules, BEM naming conventions, and CSS-in-JS libraries have been doing for years — providing local scope for component styles. With @scope, no build tooling is required.

@scope can also exclude descendants, with to:

@scope (article) to (.share-buttons) {
/* applies inside article, but not to .share-buttons */
}

The flexibility is useful for nested component scenarios where a parent shouldn’t accidentally style the internals of a child.

Other newer cascade tools include @starting-style (for the first frame of a CSS transition, useful for entry animations), interpolate-size (for animating to and from auto sizes), and a steady stream of additions emerging from the CSS WG. The platform’s cascade has been a moving target for the past five years, and the trend is toward more programmatic capabilities — closer to a real runtime than to the static-stylesheet model of the early 2000s.

Modern CSS can respond to more application state than most developers realize.

button:disabled { opacity: 0.5; }
input:invalid { border-color: red; }
details[open] { background: #f0f0f0; }
form:has(input:invalid) { /* see above */ }
@media (prefers-reduced-motion: reduce) { /* honor the user's preference */ }
@media (prefers-color-scheme: dark) { /* dark mode */ }
@media (forced-colors: active) { /* high-contrast modes on Windows */ }
@media (any-pointer: coarse) { /* touch devices */ }

Every state handled in CSS is one less state that JavaScript has to coordinate for presentation alone. The form fields are styled invalid because they are invalid; no JavaScript class-toggling is required. The dialog is styled open because it has the open attribute; no JavaScript class-toggling is required. The user’s reduced-motion preference is respected without the application needing to read the preference and apply different animation logic.

This doesn’t eliminate behavioral state in JavaScript. The application still has to decide when the form is submitted, when the dialog opens, when the menu expands. The decision is JavaScript’s. The presentation of the decision is CSS’s. Separating responsibilities this way produces simpler JavaScript and more legible CSS.

The CSS-runtime view has accessibility implications worth naming directly.

CSS can preserve focus indication or destroy it. The default browser focus ring is the platform’s most reliable accessibility affordance for keyboard users. Removing it with outline: none and not providing a replacement is one of the most common accessibility regressions in the field. :focus-visible (shipped in 2022, refined since) gives the application precise control — apply a focus indicator when keyboard focus arrives, suppress it when mouse focus arrives, all without breaking keyboard users.

CSS can honor prefers-reduced-motion or ignore it. Users who experience motion sickness or vestibular issues set this preference at the operating-system level. A responsible CSS runtime checks for it and disables non-essential animations:

@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}

CSS can maintain accessible contrast or destroy it. Color choices need to clear WCAG contrast ratios. The color-contrast() function (still emerging in 2025) can automatically choose between candidate colors based on contrast against a background. The Chapter 29 accessibility chapter goes deeper.

CSS can preserve logical reading order or break it. The order property on flex and grid items lets the visual order diverge from the source order. Screen readers and keyboard navigation follow the source order, not the visual order, so misuse of order produces inconsistent experiences for keyboard and screen-reader users.

The architectural principle: if CSS is a runtime, accessibility is part of its contract. A theme that lacks focus states isn’t complete. A motion system that ignores reduced-motion isn’t complete. A layout system that breaks reading order isn’t complete.

The View Transitions API (Chapter 6, Chapter 26, and Chapter 28 each touch this) is, in the CSS-runtime view, an extension of the cascade into animated state changes.

The application calls document.startViewTransition(() => { /* DOM changes */ }). The browser takes a visual snapshot of the current state, applies the DOM changes, takes another snapshot, and animates between them. The animation is driven by CSS — view-transition-name on elements participating in the transition, CSS for the transition timing and easing, CSS for any custom animation shapes.

The same machinery applies to cross-document navigation when @view-transition { navigation: auto; } is declared. The browser captures the old page, requests the new page, captures the new page, and animates between them. Real navigation, smooth transition, all driven by CSS rules.

This is the CSS runtime at its most ambitious. The application declares the transition contract in CSS; the platform handles the imperative work of capturing, animating, and applying. The application’s JavaScript is responsible for triggering the transition, not for executing it.

Pull all of this together and CSS becomes something different from what the field has been treating it as.

It’s a runtime system, evaluated continuously as the application’s state changes. It carries runtime values through custom properties. It reacts to local size context through container queries. It reacts to relational state through :has(). It scopes itself through cascade layers and @scope. It animates state changes through registered properties and View Transitions. It responds to user preferences through media queries. It owns presentation in a way that frees JavaScript to own behavior.

For application architecture, this changes what JavaScript should be doing. The historical pattern — JavaScript observes state, calculates the presentation, applies the calculation through inline styles or class toggles — pushes presentation work into the language that’s worst at coordinating it. The platform-aware pattern lets CSS handle presentation and lets JavaScript handle the rest. The two runtimes do what each is best at, and the boundary between them becomes clear.

Kitsune’s styling model is CSS-native. Custom properties for tokens. Cascade layers for scoping. Slots and parts for component styling extension points. Container queries for adaptive layouts. State selectors for the application states that have presentation consequences. The framework’s job is to make this ergonomic, not to replace it. The platform’s CSS runtime is the substrate the styling architecture sits on.

The next chapter takes the animation angle in more depth. The View Transitions API is the most recent piece of a longer story — CSS animations, transitions, the Web Animations API, scroll-driven animations, anchor positioning, and the broader move toward declarative motion as a platform capability. The chapter walks through what the platform now provides, why GSAP and Framer Motion and Motion One still earn their keep for complex work, and how the routing-belongs-to-the-server argument’s ergonomic loop finally closes with View Transitions.

Create a card component as plain HTML:

<article class="card">
<h2>Profile</h2>
<p>Update your public information.</p>
<button>Save</button>
</article>

Add CSS that uses the runtime capabilities the chapter described:

  1. Define design tokens as custom properties on :root — color, spacing, radius, font sizes. Use @property to type at least one of them.
  2. Declare cascade layers: @layer tokens, base, components, overrides;. Put your rules in the appropriate layers.
  3. Add a container query so the card switches between a stacked layout (single column) and a horizontal layout (image-and-content side by side) based on its container width.
  4. Add a :focus-visible style for the button.
  5. Honor prefers-reduced-motion for any animations.
  6. Add a parent theme boundary:
<section data-theme="dark">
<article class="card">...</article>
</section>
<section data-theme="brand">
<article class="card">...</article>
</section>

Define data-theme="dark" and data-theme="brand" selectors that override the relevant custom properties. The same .card should appear differently in each theme without any JavaScript.

  1. Add a form:has(input:invalid) .submit-card rule somewhere that affects the card’s styling when a form inside it has invalid input.

After everything works:

  • Which presentation changes required JavaScript? (Hopefully none.)
  • Which were solved by CSS context? (Hopefully most.)
  • How did the parent theme boundary affect children? (Inherited through the cascade.)
  • What accessibility states did the CSS support? (Focus-visible, reduced-motion, hopefully high-contrast through forced-colors.)
  • If you wanted the theme to be user-switchable, where would the toggle live? (In storage — Chapter 25 — with a data-theme attribute applied to the document root that the CSS responds to.)

The goal is to feel that CSS is doing real work. The runtime is evaluating cascades, applying inheritance, responding to size changes, reacting to nested state, and animating transitions, continuously, while the application’s JavaScript stays focused on behavior. Most of the presentation work in a modern application can live entirely in CSS, with JavaScript handling only the parts that actually involve behavior or external state.