Styling with Variables
CSS Vars Framework has no config file, no build step, and no JS-driven theme switcher. The entire visual identity of a site — color, spacing, radius, shadow, motion — lives in one place: the custom properties declared in :root inside @layer theme. Every component, every utility class, every control reads from those tokens instead of hardcoding values. Change a token once, and every place that consumes it updates.
This is the single idea to hold onto: you don't style elements, you set variables, and the framework styles itself.
1. The token model
Tokens are organized into a small number of families. Each family has a job, and — critically — most families don't hold raw values themselves. They hold references to a smaller set of base tokens. This indirection is what makes the framework re-themeable from one edit.
1.1 Base tokens (the only ones you should normally hand-edit)
--color-1 /* primary brand color */
--color-2 /* secondary brand color */
--dark
--grey
--light-grey
--white
--black
--danger
--info
--success
--warning
--luxury
--body-bg
--body-color
--font-family
Everything else in the framework is built on top of these nine or so values. Rebrand a site by changing --color-1 and --color-2 in :root — you will almost never need to touch anything downstream.
1.2 Surface tokens — derived, not raw
--surface-raised /* var(--body-bg) */
--surface-dim /* var(--light-grey) */
--surface-overlay /* color-mix(in srgb, var(--black) 60%, transparent) */
--surface-inverse /* var(--black) */
--surface-hover /* color-mix(in srgb, var(--color-1) 8%, transparent) */
--surface-active /* color-mix(in srgb, var(--color-1) 16%, transparent) */
--surface-selected /* color-mix(in srgb, var(--color-1) 12%, transparent) */
--surface-disabled /* color-mix(in srgb, var(--grey) 30%, transparent) */
These describe states, not colors. --surface-hover isn't "light grey" — it's "8% of the brand color mixed into transparent," so hover states automatically stay on-brand no matter what --color-1 is set to.
Where they're used: --surface-hover/--surface-active feed --control-hover-bg/--control-active-bg (§1.4), which is how every button, pill, and dropdown item gets consistent hover/active feedback without a single hardcoded state color anywhere in the framework. --surface-overlay is what darkens the page behind a modal or light-box.
1.3 Panel tokens — what components actually consume
--panel-bg /* var(--surface-raised) */
--panel-border /* var(--default-border) */
--panel-radius /* var(--default-border-radius) */
--panel-shadow /* var(--box-shadow) */
--panel-padding /* var(--space-dense-2) */
Where they're used: .card, .panel, .modal__inner, .dropdown-menu, .accordion-content, .tabs .tab, and every other "boxy" component read from --panel-*, not from their own hardcoded rules. This is why a .glass modifier works by overriding four variables instead of rewriting five components — see §4. --panel-radius and --panel-shadow in turn trace back to --default-border-radius and --box-shadow, so a single edit to those two structural tokens reshapes every panel-based component at once.
1.4 Control & input tokens
--control-bg / --control-color / --control-radius / --control-padding
--control-hover-bg / --control-hover-color / --control-hover-border
--control-active-bg / --control-active-color
--control-focus-ring
--control-disabled-opacity
--input-bg / --input-color / --input-border / --input-radius
--input-focus-border / --input-focus-shadow
--input-invalid-border / --input-invalid-color
--input-disabled-bg / --input-disabled-opacity
Where they're used: --control-* drives .button, native button, and .pill; --control-focus-ring combines with --control-shadow on the focus state so focused controls get a visible ring without losing their resting shadow. --input-* drives input, select, and textarea; --input-focus-shadow and --input-invalid-* fire on :focus and :invalid/.is-invalid respectively.
Buttons, pills, and form fields are two separate token families on purpose — a button and a text input don't share visual language in most designs, even though both are "interactive."
1.5 Typography tokens
--font-family / --font-weight / --line-height
--heading-font / --heading-font-weight / --heading-color
--heading-line-height / --heading-letter-spacing / --heading-margin
--h1-size … --h6-size
--display-1-size … --display-6-size /* fluid, via clamp() */
--link-color / --link-hover-color / --link-decoration / --link-hover-decoration
Heading sizes are fixed rem values; display sizes are fluid (clamp()-based) for hero copy that needs to scale with viewport width rather than jump at breakpoints.
Where they're used: every h1–h6 and their .h1–.h6 class equivalents read --heading-* for weight, color, line-height, letter-spacing, and margin, and their own --h*-size for font size. .display-1–.display-6 read the --display-*-size clamps directly. --link-* applies to every a element and its :hover state — change --link-color once and every text link in every block, card, and footer updates together.
1.6 Icon & image tokens
--icon-default-size / --icon-default-fill / --icon-default-bg
--image-radius / --image-shadow / --image-border-style
Where they're used: --icon-default-* sizes and colors any inline SVG icon that doesn't set its own dimensions — this is what lets the same icon markup drop into a hero, a card, or a footer and pick up a consistent size and brand-colored fill. --image-* applies globally to every img element (radius, shadow, border), so a site-wide "give every image rounded corners and a soft shadow" edit is two token changes, not a find-and-replace across every <img> tag.
1.7 Spacing tokens
Two independent scales, both fluid via clamp():
--space-1 … --space-4 /* general vertical rhythm, gaps, flow, child-padding */
--space-dense-1 … --space-dense-4 /* tighter scale — panel padding and the m1–m4/p1–p4 utilities */
Plus fixed fractional steps (--space-1-2, and the -1-16/-1-8/-1-4/-1-2/-3-4 utility suffixes) for cases that shouldn't scale with viewport — icon gaps, border widths, fine adjustments.
Where they're used: --space-1–--space-4 back --content-flow, --child-padding, and the .p-t-*/.p-b-*/.child-padding-* utilities. --space-dense-1–--space-dense-4 back --panel-padding and the .m1–.m4/.p1–.p4/.m-h-*/.p-h-*/.m-r-*/.p-l-* utility families — this is why panel padding feels tighter than the rhythm between page sections even though both scales are "spacing tokens."
1.8 Structural tokens
--default-padding / --default-margin / --default-gap
--default-border / --default-border-radius
--box-shadow
--z-floating (100) / --z-overlay (999) / --z-modal (1000)
--duration-fast / --duration-base / --duration-slow / --easing / --easing-in-out
--default-border-radius is the single source of truth for "how rounded is this framework" — panels, controls, and inputs all derive their own radius from it (control and input radii are calc(var(--default-border-radius) * 0.75), slightly tighter than panels).
Where the rest are used:
--default-gap— thegapon flex/grid layout components (e.g. the sidebar/menu row layouts).--z-floating— sidebars, the dropdown menu, notification panel, and the sticky/toggle button;--z-overlay— the light-box overlay and the modal backdrop;--z-modal— the modal itself, always above its own overlay. Keeping these as tokens (rather than ad-hoc numbers per component) is what guarantees a floating sidebar never accidentally out-stacks a modal.--duration-fast+--easing/--easing-in-out— box-shadow/transform transitions on hover (cards, icons) and border/shadow transitions on input focus.--duration-base— sidebar slide transforms, overlay fade-ins, skip-link reveal.--duration-slow— the notification-panel entrance animation.- All three durations collapse to
0sunder@layer overrideswhenprefers-reduced-motion: reduceis set — this is the one place a token is reassigned conditionally rather than by theme.
1.9 Flow tokens — spacing between elements, not around them
--content-flow: var(--space-1);
--page-flow: 0;
--main-flow: clamp(6rem, 3.33vw, 12rem);
--child-padding: var(--space-1);
Flow tokens are the framework's answer to "how much space goes between siblings" — deliberately separate from --default-margin/--default-gap, which govern space around a single element. They're consumed via the adjacent-sibling combinator (* + *), so they only ever add space between elements that already have a preceding sibling — the first child in a container never gets an unwanted top margin.
| Token | Applies to | Selector |
|---|---|---|
--page-flow | Spacing between direct children of <body> — the outermost, page-level sections (header, hero, footer) | body > * + * and the .flow > * + * utility class |
--main-flow | Spacing between direct children of <main> specifically — separated from body because <main>'s own sections (content blocks, article groups) often need a much larger, fluid gap than body's top-level chrome | main > * + * |
--content-flow | Prose rhythm — spacing between elements inside a content container | article > * + *, aside > * + *, details > * + *, blockquote > * + * |
--child-padding | Uniform padding applied to every direct child of a container, via the .child-padding utility class | .child-padding > * |
Practical distinction — three levels of rhythm, not two:
--page-flow(defaults to0) governs the outermost skeleton of the page — the space between<body>'s direct children, typically<header>,<main>,<footer>themselves.--main-flow(fluid,clamp(6rem, 3.33vw, 12rem)) governs the space between the sections inside<main>— since<main>is where most of a page's actual content sections live, this token defaults to a large, viewport-aware gap rather than0, so stacked sections read as distinct without any per-section margin.--content-flow(var(--space-1), small) governs the tightest rhythm — paragraphs, list items, and blockquotes within one of those sections.
Because --page-flow and --main-flow are separate tokens now, you can keep <body>'s direct children flush (e.g. a full-bleed header with no gap above it) while still getting generous, automatic spacing between the sections inside <main> — previously both were the same token, so you couldn't set one without affecting the other.
--child-padding is different in kind from the three flow tokens above: it's not a "space between" token, it's a "same padding on every child" token, meant for grids of equal-looking items (icon lists, stat blocks) where you don't want to write padding on each child individually — apply .child-padding to the parent and set --child-padding once.
2. How to actually change something
Rebrand a whole site
:root {
--color-1: #0b5fff;
--color-2: #ff6b35;
}
Buttons, links, focus rings, hover surfaces, colored-list bullets, gradients — all update, because they're all built from --color-1/--color-2 through the surface and control layers.
Change the "roundness" of everything
:root {
--default-border-radius: 1rem;
}
Cards, modals, dropdowns, buttons, and inputs all move together.
Retheme a single component without touching the CSS file
Scope the override to a container in your HTML:
<div style="--panel-bg: var(--dark); --panel-shadow: none;">
<div class="card">...</div>
</div>
This is the intended extension mechanism — override the variable on an ancestor, don't write new selectors.
Dark mode
Handled by attribute, not a duplicate stylesheet:
<html data-theme="dark">
[data-theme="dark"] reassigns the base color tokens only (--color-1, --body-bg, --light-grey, etc.) — every downstream token (surfaces, panels, controls) picks up the change automatically because of the indirection described in §1.
3. The alpha convention
Any time a color needs transparency, the framework uses:
color-mix(in srgb, var(--color-1) 10%, transparent)
never a -rgb companion variable and rgba(). This is a strict, framework-wide rule (.bga-*, .shadow-color-*, .bg-gradient-*, and every --surface-* token follow it). If you're adding a new alpha-based rule, match this pattern — mixed conventions break the "change one token, everything updates" guarantee.
4. Composition over duplication
This is the architectural rule that makes the token system worth it: a modifier class works by overriding tokens on a scope, not by duplicating a component's ruleset.
Example — glass effect:
@layer utilities-advanced {
.glass {
--panel-bg: color-mix(in srgb, var(--white) 20%, transparent);
backdrop-filter: blur(12px);
}
}
.card.glass, .modal__inner.glass, .dropdown-menu.glass all get the effect for free, because they all already read --panel-bg — .glass never needed to know these components exist.
The same pattern applies anywhere you're tempted to write a new selector: ask first whether the value you want to change is already a token consumed by the component, and override the token instead of the component.
5. Cascade layers — why order isn't cosmetic
@layer theme, base, layout, components, components-advanced, style, utilities, utilities-advanced, overrides;
Layers are declared in the order they win — later layers always beat earlier ones, regardless of selector specificity. This is deliberate:
theme— token declarations only, nothing renders here.base/layout— resets, typography defaults, grid.components/components-advanced— cards, modals, dropdowns, etc.style— theme presets (.brutal-style,.glass-style,.neon-style…) — cosmetic skins.utilities/utilities-advanced— single-purpose overrides (.p-t-2,.glass,.shadow-color-2).overrides— accessibility guarantees (focus-visible rings,prefers-reduced-motion, forced-colors mode) that must always win.
Because utility layers sit after component layers, a utility class will always beat a component's own rule — even a low-specificity one like .p-t-2 — without needing !important. This is why new rules should always go in the layer matching their purpose, not wherever seems convenient: putting a utility in components would let a future component rule silently override it.
6. Utility classes — the token system's escape hatch
Tokens and components cover the default look of a site. Utility classes are how you deviate from that default on a single element, in markup, without writing new CSS. Every utility class in @layer utilities and @layer utilities-advanced does exactly one thing — set one property (or one small tightly-related group, like a shadow's border + box-shadow) — and, because those two layers sit near the end of the cascade order (§5), a utility class always wins over a component's own rule, no matter how specific that rule's selector is.
That's the whole contract: components carry structure and token-driven defaults; utilities carry one-off overrides. If you find yourself reaching for the same utility combination repeatedly on the same kind of element, that's a signal to promote it into a component or a theme preset instead of continuing to hand-stack classes.
The framework ships roughly 500 utility classes across spacing, sizing, layout, color, borders, shadows, typography, and visibility. They're catalogued in full, by category, in Utility Classes Reference — this section only covers the two things worth knowing before you open that page:
- Utilities read tokens wherever a token exists.
.p-t-2doesn't hardcode2rem— it setspadding-top: var(--space-2, ...)..bga-color-1doesn't hardcode a tint — it reads--color-1throughcolor-mix(). This means the "no custom classes" rule (compose HTML with framework classes plus:rootvariable overrides, never inline styles or new CSS) holds even at the utility level: change a token, and every utility built on it updates too. - Two families are the ones you'll reach for daily: the spacing utilities (
.p-*/.m-*, built on--space-dense-*and--space-1–--space-4) and the color/background utilities (.bg-*/.bga-*/.border-*, built on the base color tokens). Everything else — shadows, gradients, sizing, flex/grid helpers, visibility toggles — exists for the cases the component layer doesn't already cover.
7. Quick reference — token families at a glance
| Family | Purpose | Consumed by |
|---|---|---|
Base (--color-1, --body-bg…) | Brand identity | Everything, indirectly |
Surface (--surface-*) | State colors (hover/active/disabled) | Controls, interactive elements |
Panel (--panel-*) | "Boxy" component chrome | .card, .panel, .modal__inner, .dropdown-menu |
Control (--control-*) | Buttons, pills | .button, button, .pill |
Input (--input-*) | Form fields | input, select, textarea |
Typography (--h*-size, --heading-*, --link-*) | Text scale and voice | Headings, .display-*, links |
Icon & image (--icon-default-*, --image-*) | Inline SVG icons, <img> styling | Icon markup, every img |
Spacing (--space-*, --space-dense-*) | Rhythm and gaps | Utilities, flow, panel padding |
Structural (--default-*, --z-*, --duration-*) | Shared framework constants | Resets, layering, motion |
Flow (--page-flow, --main-flow, --content-flow, --child-padding) | Space between siblings, not around elements | body children, main children, prose children, .child-padding |
8. Rule of thumb
Before writing any CSS: is there already a token for this? If yes, override it at the narrowest scope that needs it (:root for the whole site, an inline style attribute for one section, a class in utilities-advanced for a reusable modifier). Only write a new selector when the value genuinely isn't token-driven yet — and if that happens often for the same property, that's a signal the framework needs a new token, not a new one-off rule.