Skip to content

CSS, Layers, and Container Queries

CSS, the language people are routinely told to “stop fighting and just use a runtime for”, has changed more in five years than the previous fifteen. Here’s the short list of what’s now native and broadly supported.

Specificity used to be the cause of most CSS suffering. @layer makes it controllable.

@layer reset, tokens, base, components, utilities, overrides;
@layer reset { /* normalize */ }
@layer tokens { :root { --color-accent: oklch(57% 0.18 248); } }
@layer base { body { font-family: system-ui; } }
@layer components { .card { padding: 1rem; } }
@layer utilities { .mt-4 { margin-top: 1rem; } }
@layer overrides { /* anything here wins */ }

Selectors in later layers always beat selectors in earlier layers — independent of specificity score. Want utility classes that beat component styles? Order the layers. No !important.

Kitsune publishes the convention kit.reset, kit.tokens, kit.base, kit.components, kit.utilities, app.overrides so app code always wins over kit defaults.

Media queries respond to the viewport. Container queries respond to a parent element’s size.

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

The .card style now responds to whether its grid container is at least 32rem wide. Drop the same component into a sidebar and a main column; it adapts to each.

This is what makes “true reusable components” possible without prop-drilling responsive variants.

/* Style a card differently if it contains a footer */
.card:has(> footer) { padding-block-end: 0; }
/* Style a form differently if it has any invalid input */
form:has(:invalid) button[type=submit] { opacity: 0.5; }
/* Hide a sidebar header if the sidebar is empty */
.sidebar > header:has(+ :empty) { display: none; }

This is enormous. It removes most of the JavaScript people write to add and remove classes based on DOM structure.

Perceptually uniform colors and runtime mixing.

:root {
--accent: oklch(57% 0.18 248);
--accent-dim: color-mix(in oklch, var(--accent), transparent 70%);
--accent-hover: color-mix(in oklch, var(--accent), white 10%);
}

You can compute hover, disabled, and dim variants from a single token without precomputing a palette.

.card {
padding: 1rem;
& h2 { margin: 0 0 0.5rem; }
&:hover { border-color: var(--accent); }
& + & { margin-top: 0.5rem; }
}

This is the entire reason most teams reached for Sass. It’s now native. Vite, esbuild, and every modern bundler understand it without a preprocessor.

:root { color-scheme: light dark; }
body {
background: light-dark(white, oklch(20% 0 0));
color: light-dark(black, oklch(95% 0 0));
}

Add color-scheme: light dark to a root element, then use light-dark() to declare both modes inline. The browser applies the right one based on the user’s preference. No JavaScript theme switcher unless you want manual override.

Kitsune theme tokens are CSS custom properties. The cascade applies them. Components consume them. There’s no styling runtime.

:root {
--kit-color-accent: oklch(57% 0.18 248);
--kit-radius-md: 0.5rem;
--kit-space-3: 0.75rem;
}

Inside a component:

:host {
background: var(--kit-button-bg, var(--kit-color-accent));
border-radius: var(--kit-radius-md);
}

Theme switching is applyTheme(brandTheme) setting custom properties on the root element. Container queries handle responsive variants. light-dark() handles dark mode. The browser does all of it.

When you don’t need a CSS-in-JS runtime, you don’t need any of the build-time machinery, type-generation, or runtime-evaluation that comes with one. You ship 0 KB of styling runtime and the cascade does its job.

Next: ARIA, Focus, and Accessibility →