fuz_css
CSS with more utility 🪴
semantic-first CSS framework and design system: styled HTML defaults, themes derived from a few knobs, and utility classes generated from what you use
npm i -D @fuzdev/fuz_css
introduction #
fuz_css is a semantic-first CSS framework and design system. Semantic HTML looks finished with zero classes: headings, links, buttons, forms, tables, and callouts come styled, and block elements space themselves. Themes are a handful of knobs, not a stylesheet: hues, intents, lightness curves, and scales re-derive every color and size in plain CSS, and the contrast, gamut, and ramp-order checks the shipped themes pass are an API for checking your own. See the themes and try the editor.
The Vite plugin generates one stylesheet with the theme variables, base styles, and utility classes your code uses, for Svelte, React, Preact, Solid, and plain HTML. Without a build step, link the package's two plain stylesheets. For companion Svelte components see fuz_ui.
Design details #
- plain CSS
- exports a reset stylesheet with semantic defaults that styles HTML elements, and also exports the underlying data, helpers, and types for open-ended usage
- supports themes as sets of style variables, baked in at build time or swapped at runtime; dark mode isn't a theme but a color scheme any theme can support
- supports optional utility classes with four types (token, keyword, composite, CSS-literal) and modifiers for responsive, state, color-scheme, and pseudo-elements
- uses its own concept of style variables, a specialization of CSS custom properties and design tokens that integrate with the other systems (e.g. the reset stylesheet and token classes use variables, and themes are groups of variables)
- the stylesheets work with any framework and plain HTML; utility class generation supports
HTML/JS/TS, Svelte, and JSX - see the utility class framework support, and for the
companion Svelte integration see
ThemeRootin fuz_ui - see the comparison to alternatives to understand fuz_css relative to TailwindCSS and UnoCSS
Usage #
npm i -D @fuzdev/fuz_css Add the Vite plugin, then import its virtual module once, from your root layout or entry file. It generates the theme variables, base styles, and utility classes your code uses:
// vite.config.ts
import {vite_plugin_fuz_css} from '@fuzdev/fuz_css/vite_plugin_fuz_css.ts';
export default defineConfig({plugins: [sveltekit(), vite_plugin_fuz_css()]});
// src/routes/+layout.svelte, or your entry file
import 'virtual:fuz.css'; See setup for SvelteKit, React, Preact, Solid, and plain HTML, and for linking the stylesheets without a build step.
fuz_css sets the root font size to 62.5%, so 1rem is 10px - see root font size.
Browser support #
The shipped CSS needs Chrome or Edge 120, Firefox 118, or Safari 16.2 - Safari 16.4 if you
use responsive modifier classes like md:, which emit media range syntax.
There are no fallbacks. Every rule sits in a cascade layer
( @layer), the color scales are computed in CSS with oklch(), calc(), and pow(), and color-mix() drives
button fills, shadows, and the placeholder color. pow() sets the Chrome and
Firefox floors: a browser without it keeps the page background and color scheme (the ends of
each scale don't use it) but loses every stop between, which carry the text, border, and
accent colors. A browser without cascade layers drops every rule, leaving unstyled HTML.
setup #
The Vite plugin is the recommended setup. It generates one stylesheet holding the theme variables, the base styles for the elements you use, and the utility classes you use, and it updates with HMR as you edit. It works with SvelteKit, Svelte, React, Preact, Solid, and plain HTML/TS/JS. Without a build step, link the package's two plain stylesheets instead.
npm i -D @fuzdev/fuz_css Working setups for each framework live in the repo's examples.
SvelteKit #
// vite.config.ts
import {defineConfig} from 'vite';
import {sveltekit} from '@sveltejs/kit/vite';
import {vite_plugin_fuz_css} from '@fuzdev/fuz_css/vite_plugin_fuz_css.ts';
export default defineConfig({
plugins: [sveltekit(), vite_plugin_fuz_css()],
}); Import the virtual module once, from the root layout:
// src/routes/+layout.svelte
import 'virtual:fuz.css';Vite with React, Preact, or Solid #
JSX needs a parser plugin, which you install yourself: acorn-jsx, passed through the acorn_plugins option.
npm i -D @fuzdev/fuz_css acorn-jsx // vite.config.ts
import {defineConfig} from 'vite';
import react from '@vitejs/plugin-react';
import jsx from 'acorn-jsx';
import {vite_plugin_fuz_css} from '@fuzdev/fuz_css/vite_plugin_fuz_css.ts';
export default defineConfig({
plugins: [react(), vite_plugin_fuz_css({acorn_plugins: [jsx()]})],
}); Preact is the same with @preact/preset-vite. For Solid, list the fuz_css plugin
before solid(), as the Solid example does:
plugins: [vite_plugin_fuz_css({acorn_plugins: [jsx()]}), solid()], Then import the virtual module in your entry file:
// src/main.tsx
import 'virtual:fuz.css'; Supported JSX patterns:
className="..."andclass="..."- static stringsclassName={clsx(...)}- utility function callsclassName={cond ? "a" : "b"}- ternary and logical expressionsclassList={{active: cond}}- Solid's classList- usage tracking: variables in
className,class, andclassListare tracked back to their definitions, within one file
// variable tracking works in JSX too
const styles = 'box hover:shadow_lg';
const Component = () => <div className={styles} />; The acorn_plugins option accepts any Acorn-compatible plugin, so other syntax
extensions can be supported the same way.
Vite with Svelte or plain HTML #
Svelte and plain HTML/TS/JS need no parser plugin. With the Svelte plugin outside SvelteKit, list the fuz_css plugin first, as the Svelte example does:
// vite.config.ts
import {defineConfig} from 'vite';
import {svelte} from '@sveltejs/vite-plugin-svelte';
import {vite_plugin_fuz_css} from '@fuzdev/fuz_css/vite_plugin_fuz_css.ts';
export default defineConfig({
plugins: [vite_plugin_fuz_css(), svelte()],
}); // src/main.ts
import 'virtual:fuz.css';Without a build step #
The package ships two plain stylesheets, the base style.css and theme.css with every variable, which work in any page, and jsDelivr serves them from npm:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fuzdev/fuz_css@0.71.1/dist/theme.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fuzdev/fuz_css@0.71.1/dist/style.css" /> That's the full semantic styling and every style variable, including the builtin classes like selected and palette_a. Utility classes need the plugin, since
they're generated from your source, and so does baking a theme in; without it, write the variables into your own
CSS. Pin a version in the URL so a release can't change your page.
TypeScript #
Declare the virtual module once, in a dedicated declaration file like src/fuz_css.d.ts (not a SvelteKit app.d.ts, which is a module):
// src/fuz_css.d.ts
declare module 'virtual:fuz.css';Utility-only mode #
The virtual:fuz.css import alone is all most projects need. Projects managing
their own theme or base styles can turn the generated ones off and import the full
stylesheets, so neither layer ships twice:
// vite.config.ts
vite_plugin_fuz_css({
base_css: null,
variables: null,
}),
// entry file - full package CSS, only used utilities
import '@fuzdev/fuz_css/style.css'; // all base styles
import '@fuzdev/fuz_css/theme.css'; // all variables
import 'virtual:fuz.css';Root font size #
Every rem length on the page is measured against that root, including ones that come
from elsewhere: a third-party component sized for a 16px rem renders at 62.5% of what
its author intended. Size your own lengths with the style variables, and check any rem-based CSS
you bring in. A @media query is the exception, since its rem is the
browser's initial font size: the sm: breakpoint's 40rem is 640px.
Coming from Tailwind, whose spacing scale assumes a 16px rem (p-4 is 1rem, 16px): here 16px is 1.6rem, and the spacing tokens like p_lg carry the scale, so pasted Tailwind lengths come out smaller.
/* with fuz_css's root, 1rem is 10px */
.card { padding: 1rem; } /* 10px, not 16px */
.card { padding: var(--space_lg); } /* the spacing scale */Plugin options #
The plugin needs Vite 6 or later. It extracts classes from files as Vite processes them,
including from node_modules dependencies, and in dev it pre-scans your sources
and the root index.html at startup so the first page load is fully styled. List
it after any plugin that rewrites CSS in its transform hook.
acorn_plugins- required for JSX frameworks, e.g.acorn-jsxadditional_classes- classes to always include (for dynamic patterns that can't be statically extracted)exclude_classes- classes to exclude from outputclass_definitions- custom class definitions to merge with defaults; can define new classes or override existing ones (see composite classes). A callback receives the defaults and returns the whole set, so() => my_definitionsuses only your ownclass_interpreters- custom interpreters for dynamic class generation; an array replaces the default interpreters, and a callback receives them and returns the ones to use (most users don't need this)filter_file- custom filter for which files to process. Receives(id: string, root: string)and returnsboolean, e.g.(id) => !id.includes('/fixtures/')on_error-'log'or'throw'; defaults to'throw'in CI (CIset to anything but empty,false, or0),'log'otherwiseon_warning-'log','throw', or'ignore'; defaults to'log'cache_dir- cache location; defaults to.fuz/cache/csscache_salt- a string folded into the cache key; change it when only an acorn plugin's options change, since the key already covers file content, the plugins, and the installed versions of fuz_css and its parsersprescan- dev-only scan of sources at server startup;true(default) scanssrcunder the Vite root,falsedisables, or pass an array of directoriesbase_css- customize or disable base styles; set tonullfor utility-only mode, or provide a string to replace the defaults or a callback to modify them (see what gets included)variables- customize or disable theme variables; set tonullfor utility-only mode, or provide an array to replace the defaults or a callback to modify themtheme- a theme to bake into the generated CSS, overlaid ontovariableslast-wins by nameadditional_elements- elements to always include styles for (for runtime-created elements), or'all'to include all base stylesadditional_variables- variables to always include in theme output, or'all'to include all theme variables
Framework support #
The stylesheets work with any framework and plain HTML. The utility class generator's detection support varies by framework:
| framework | detection | notes |
|---|---|---|
| plain HTML | full | static class="..." attributes, script variables |
| Svelte | full | all patterns including class: directives and array/object syntax |
| React / JSX | full | with acorn-jsx plugin - className |
| Preact | full | with acorn-jsx plugin - class |
| Solid | full | with acorn-jsx plugin - class, classList |
| Vue JSX | full | with acorn-jsx plugin - class |
| Vue SFC, Angular, etc. | none | template syntax not parsed; use clsx/cx/cn in
JS/TS |
The additional_classes option is an escape hatch for classes that can't be statically detected. Angular and Vue's single-file components aren't supported; we could revisit this if there's demand.
Svelte-first #
The extractor parses and analyzes the AST to understand Svelte's class syntax. Supported constructs:
- attributes:
class="...",class={[...]},class={{...}}(identifier and string-literal keys),class:name - expressions: logical (
&&,||,??), ternaries, template literals (complete tokens only -`color_a_50 ${base}`extractscolor_a_50, but`color_${hue}_50`cannot be extracted; use@fuz-classesoradditional_classes) - Svelte 5 runes:
$derived()and$derived.by()for class variables - utility calls:
clsx(),cn(),cx(),classNames()with nested arrays, objects, and utility calls - scripts: both
<script>and<script module>, with naming convention and usage tracking
api #
Browse the full api docs.
examples #
The example repos demonstrate the classes system using the Vite plugin:
- vite-svelte - Svelte 5
- vite-react - React 19
- vite-preact - Preact
- vite-solid - Solid
semantic #
fuz_css styles HTML elements in its reset stylesheet, so semantic markup gets themed and color-scheme-aware styling automatically - utility classes optional. The goal is to be accessible and attractive out of the box, minimal yet extensible.
Layers and specificity #
All opinionated styles live in the fuz.base cascade layer, so your unlayered
styles and the generated utility classes override them by layer order alone. Their selectors
are also wrapped in :where() for zero specificity, so overrides stay easy even
inside the fuz layers. The exceptions are the document-wide rules: :root, body, [hidden], and ::selection.
/* any styles you apply will override these */
:where(a:not(.unstyled)) {
color: var(--link_color);
font-weight: 700;
}
:where(button:not(.unstyled)) {
background-color: var(--button_fill);
border-radius: var(--border_radius, var(--control_radius));
}.unstyled escape hatch #
Add the .unstyled builtin class to opt out of
decorative styling while keeping reset normalizations. Works for both decorative containers
and interactive elements like links, buttons, inputs, and summary.
<a href="/home">styled link</a>
<a href="/home" class="unstyled">unstyled link</a> <button>styled button</button>
<button class="unstyled">unstyled button</button>
Document flow by default #
Block elements get margin-bottom via :not(:last-child), creating
natural vertical rhythm without trailing margins.
:where(
:is(p, ul, ...[many others])
:not(:last-child):not(.unstyled)
) {
margin-bottom: var(--flow_margin, var(--space_lg));
} The --flow_margin variable is unset by default, falling back to var(--space_lg). Size composite classes like .sized_sm and .sized_lg set --flow_margin to adjust vertical rhythm for all flow
elements and headings.
Headings add a top margin too, a larger multiple of --flow_margin. A heading that
opens a padded or bordered box - .panel, .pane, aside, blockquote, fieldset, dialog, li, td, or th - drops it, so it sits on the box's padding instead of
stacking a gap on it.
For elements not in the flow list, use the .mb_flow and .mt_flow composite classes to get the same size-responsive spacing. Use .mb_lg when you
want a fixed value that ignores size composites.
Flex containers reset flow margins #
The .row layout composite resets margins on its direct children. Flow margins
make less sense in horizontal flex layout - for spacing prefer gap utilities like .gap_md and gap: var(--space_sm) instead.
:where(.row > *) {
margin: 0;
}Interaction and ARIA states #
Links and form controls show focus as an outline ring, and only the ring - focus adds no fill.
The ring follows :focus-visible, so keyboard focus and a click into a text field
or select show it, while a mouse click on a link, button, or checkbox doesn't leave it behind.
The base styles read ARIA state attributes on the elements they apply to:
aria-pressed="true"on abutton, or on any.selectableor.menuitem, takes the.selectedlook, and stays clickable like.deselectablearia-currenton a link (any value butfalse) takes the selected link lookaria-disabled="true"on a button or form control takes the disabled look with no hover or active states, but keeps its focus ring and pointer events, so it stays reachable by keyboard and can explain why it's disabled; on a.deselectable.menuitemor.selectable, it drops the pointer cursoraria-invalid="true"on aninput,textarea, orselectturns its border and focus ring negative
Under forced colors (Windows High Contrast), the states painted only by a background are
restated in system colors: a checked radio, a range, and the edges of .panel and .pane in the text color, and selection - selected and pressed buttons, .menuitem, .selectable - as a Highlight border and
inner band that becomes a double Highlight ring when focused, while a focus ring
alone stays the text color. Labels keep the system's colors, and so do icons drawn in currentColor. Selects get their native arrow back.
Element-specific docs #
See the related docs for specifics:
- buttons - button states, colors, variants
- elements - links, lists, tables, code, details
- forms - inputs, labels, checkboxes, selects
- typography - headings, fonts, text styles
themes #
Themes #
A theme is a set of knob values, not a stylesheet: plain data naming variables, where a handful of high-leverage knobs (hue angles, chroma_scale, the lightness curves - see colors) reshape
everything derived from them. Selecting a theme applies it to this whole website and loads its
knobs into the editor below.
base - The defaults: a blue accent, warm-tinted grays on an untinted page, serif headings over sans text, every knob at its starting value.
A heading
Body text with a link and inline code.
accent positive negative caution info
Contrast is a modifier, not a theme: the low and high contrast modifiers compose over
any theme with compose_themes (flatten + last-wins), so every theme gets both
variants for free. The OS prefers-contrast: more preference maps onto the same
variables the high contrast modifier sets, so it reaches every theme with no modifier picked.
Color scheme #
Light and dark are color schemes within a theme ( color-scheme), not
separate themes: each variable carries a base value that applies in both and an
optional dark value that replaces it in dark mode, so any theme can implement
dark mode. The scheme follows prefers-color-scheme by
default. To set it manually, add the dark or light class to the root html element, or use a component like ColorSchemeInput (the picker at the top of this page) from the companion Svelte library fuz_ui.
A theme can instead take a single-scheme stance with scheme: 'light' | 'dark':
its one appearance renders in both color schemes, and color-scheme is pinned to
match. The phosphor and marquee themes are dark-only this way, since a CRT and a lit sign have
no daytime appearance. Every other theme is dual-scheme, parchment included, whose dark
appearance is the same page by candlelight.
A stanced theme stays plain data. What the stance implies, the scheme-adaptive defaults
re-slotted into its one appearance, is computed where the theme renders rather than stored in
it, so a saved theme never carries a stale copy: resolve_theme_stance(theme) in theme_stance.ts returns the theme with its scheme_mirror beside it, ready for render_theme_style(resolved.theme, resolved).
Applying a theme #
A theme applies at build time or at runtime, and most projects want build time. Every theme on
this page ships as its own module under themes/, and themes.ts holds the registry and the contrast modifiers.
Pass a theme to the Vite plugin and its values bake into the generated CSS - no runtime rendering, no JavaScript shipped, and the output stays tree-shaken:
// vite.config.ts
import {vite_plugin_fuz_css} from '@fuzdev/fuz_css/vite_plugin_fuz_css.ts';
import {phosphor_theme} from '@fuzdev/fuz_css/themes/phosphor.ts';
export default defineConfig({plugins: [vite_plugin_fuz_css({theme: phosphor_theme})]}); The theme's variables replace the defaults by name, so it composes with the variables option, and a stance mirrors the configured variables. They also render
into the fuz.theme.baked cascade layer, above the OS preference mappings and with color-scheme pinned for a stance, so a baked theme behaves the same as one
applied at runtime.
For runtime switching - a picker, or a theme loaded per user - use ThemeRoot from fuz_ui, which renders the theme to a <style> element in the fuz.theme layer. The two paths compose:
a runtime theme overrides a baked one, since a layer's direct styles outrank its sublayers.
Outside Svelte, render_theme_style in theme.ts does
the same rendering for any framework.
Knobs take effect at the root: the derived color stops compute on :root and
descendants inherit the result, so setting a knob like --chroma_scale on an
element changes nothing below it. Set knobs through a theme, and when a subtree needs its own
color, set the variable its styles read there instead.
Theme editor #
Drag a knob and the whole page rethemes live. Extreme values can make the page hard to read,
which is an honest signal, not a bug. Every edit updates a temporary "unsaved"
theme in the picker above; it survives navigating away and back, but copy the Theme object from the output below to keep it.
edits write to the light scheme's slots
--hue_neutral--hue_accent--hue_positive--hue_negative--hue_caution--hue_infoColor
shade text accent positive negative caution info --hue_neutral--hue_accent--hue_positive--hue_negative--hue_caution--hue_infochroma character & surface colors (per-slot multipliers and micro-surface colors)
palette hues (the letter slots - moving these makes the theme palette-tier)
palette_a palette_b palette_c palette_d palette_e palette_f palette_g palette_h palette_i palette_j Shape
border width & radius tokens (escape hatches - pin individual tokens)
Density
space tokens (escape hatches - pin individual tokens)
Depth
backdrop (the dialog and fullscreen backdrop dim)
Typography
line height tokens (escape hatches - pin individual tokens)
Motion
duration tokens (escape hatches - pin individual tokens)
Decoration
Gates
validate_theme and check_theme run live against the draft - the same
lint and gamut/monotonicity/contrast gates the shipped themes are held to in CI.
all gates pass (544 checks)
Output
The copyable Theme object, and the CSS it renders - the variables the theme sets,
plus the scheme-stance mirror when the theme is single-scheme.
theme object
rendered CSS
Validating and compiling themes #
validate_theme(theme) in theme_validate.ts is the
structural lint. Unknown variable names are errors, and everything else it flags is an
advisory warning: type and range mismatches on the knob-tier variables, a dark slot under a
single-scheme stance, and intent bindings that put the accent beside a status hue or drop a
palette slot's chroma character. It returns an array of issues - empty means the theme is
structurally sound.
check_theme(theme) in theme_check.ts runs the gamut,
ramp-monotonicity, and contrast gates against the theme's resolved values. It reports rather
than throws, returning {ok, entries, unchecked}. The contrast gates measure
the pairings the default styles make on the page background - body and subtle text, links,
borders, fills, and colored labels, including a .palette_a button's label on its
own tinted fill at rest, hovered, and pressed. They follow the role variables those styles
paint through, so a theme that sets border_color to var(--text_60) has its borders measured at text_60, in an entry named border_color vs shade_00.
A value a gate depends on but can't evaluate lands in unchecked instead of
passing unread: a knob that doesn't resolve to a number, or a color stop or role set to
anything other than an oklch(L C H) numeric literal or an exact var() reference to another gated color. ok is true only when every
entry passes and nothing is unchecked, which suits a CI or test assertion:
import {test, assert} from 'vitest';
import {check_theme} from '@fuzdev/fuz_css/theme_check.ts';
import {my_theme} from './my_theme.ts';
test('my theme clears the accessibility gates', () => {
assert.isTrue(check_theme(my_theme).ok);
}); compile_theme(theme) in theme_compile.ts is for
themes that move hues or palette lightness - monochrome, rotated, or dark-only. It recomputes
the per-stop sRGB gamut caps from the theme's actual hues and the lightness each stop resolves
to, returning the corrected _palette_chroma_NN caps beside the theme rather than
in it: {theme, scheme_mirror, caps, report, issues}, which renders as render_theme_style(compiled.theme, compiled). The theme comes back untouched, so
compiling again gives the same caps. Chroma pushed past the caps on purpose
(chroma_scale above 1) still clips afterward, and the report says so. A theme
baked in at build time gets its caps recomputed automatically, with its gate failures reported
as build warnings and its validate_theme issues as build errors and warnings.
fuz_css gates its own themes with these functions in its test suite, every theme × contrast-modifier composition included, and declares what an exemplar knowingly gives up, like marquee's clipped gamut, as exact exceptions. The editor above runs the same lint and gates live on every edit.
variables #
Style variables, or just "variables" in fuz_css, are CSS custom properties that can be grouped into a theme. Each variable has a base value that applies in both color-schemes, a dark value that
replaces it in dark mode, or both. They're design tokens with an API.
The goal of the variables system is to provide runtime theming that's efficient and ergonomic for both developers and end-users. Variables can be composed in multiple ways:
- by CSS classes, both utility and component
- by other variables, both in calculations and to add useful semantics (e.g.
button_fill_hoverusesshade_50but can be themed independently) - in JS like the Svelte components in fuz_ui
Variables also provide an interface that's generally secure for user-generated content, if you're into that kind of thing.
The result is a flexible system that aligns with modern CSS to deliver high-capability UX and DX with low overhead.
Most color variables are derived: knobs feed ramp stops and ramp stops
feed color stops, all computed in pure CSS
(calc()/pow()/oklch()). Scale knobs like radius_scale derive other families the same way, and a derived token or color
stop can still be pinned as an escape hatch. The ramp stops in between are internal
(--_palette_lightness_30), so a theme can't set them. See themes for setting the rest.
In bundled mode, only the variables your code uses are emitted, along with any they depend on. The full theme.css stylesheet ships every variable, for utility-only mode and direct imports.
export type Theme = {
name: string;
/** What the theme looks like, for theme pickers. */
summary?: string;
variables: StyleVariable[];
/** Single-scheme themes render one appearance in both color schemes. */
scheme?: 'dual' | 'light' | 'dark';
};
export type StyleVariable = {
name: string;
base?: string;
dark?: string;
summary?: string;
}; Both are @fuzdev/fuz_schema schemas, so the same names validate at runtime: s.parse(Theme, value) reports what's wrong, parse_theme(value) returns a theme or null, and validate_theme(theme) in theme_validate.ts runs the full lint.
Hooks #
Hooks are the contextual surface: custom properties the base styles and classes read but don't
declare as variables, so they have no :root default. Set one on an element to
retune what reads it there, with a literal class like --flow_margin:0 or in a
component's styles - the sized_* composites work this way, setting --flow_margin and --font_size for their subtree. Where nothing sets
a hook, its read falls back to the value listed. A local hook is declared by the rule
that reads it, so a value reaches it only from a rule on the same element.
Hooks aren't theme variables: from a theme's root, an inherited hook retunes every element
that reads it and a local one reaches nothing, so validate_theme warns on a theme
that sets one. Themes turn the knobs instead. The catalog is style_hooks.ts.
| hook | read by | fallback |
|---|---|---|
--flow_margin | block elements, headings, .mb_flow, .mt_flow | var(--space_lg) |
--font_size | headings, .heading, button, input[type='checkbox']::before, .chip | inheritvar(--font_size_xl3)revert-layer |
--icon_size | .menuitem .icon | var(--icon_size_md) |
--menuitem_padding | .menuitem | var(--space_xs3) var(--space_xs) |
--menuitem_min_height | .menuitem | var(--input_height_compact) |
--chip_padding_x | .chip | var(--space_xs) |
--border_radius | a, aside, code, summary, input, textarea, select, button, .pane, .panel, .chip | var(--border_radius_xs3)var(--border_radius_xs)var(--control_radius) |
--fill | button | var(--shade_50)var(--shade_60) |
--button_fill | button, .selectable | local |
--button_fill_hover | button, .selectable | local |
--button_fill_active | button, .selectable | local |
--button_text_color | button | local |
--button_border_color | button | local |
--button_border_color_hover | button | local |
--button_border_color_active | button | local |
--button_min_height | button, button.inline, p button | var(--input_height)var(--input_height_compact) |
--shadow | button | var(--button_shadow) |
--select_arrow_size | select | local |
--thumb_size | input[type='range'] | local |
--thumb_background_color | input[type='range'] | local |
--checkbox_content | input[type='checkbox']::before | local |
--checkbox_content_empty | input[type='checkbox'] | '' |
--checkbox_content_checked | input[type='checkbox'] | '✓' |
--overflow | input[type='checkbox']::before | visible |
--clickable_transform | .clickable | scale3d(1, 1, 1) |
--clickable_transform_focus | .clickable | scale3d(1.07, 1.07, 1.07) |
--clickable_transform_hover | .clickable | scale3d(1.1, 1.1, 1.1) |
--clickable_transform_active | .clickable | scale3d(1.2, 1.2, 1.2) |
--clickable_transform_origin | .clickable | center |
--clickable_transition_duration | .clickable | 0s |
--shadow_color | .shadow_*, .shadow_top_*, .shadow_bottom_*, .shadow_inset_* | var(--shadow_color_umbra) |
--shadow_alpha | .shadow_*, .shadow_top_*, .shadow_bottom_*, .shadow_inset_* | var(--shadow_alpha_30)var(--shadow_alpha_40)var(--shadow_alpha_50)var(--shadow_alpha_60)var(--shadow_alpha_70) |
All 511 style variables #
classes #
fuz_css has two categories of CSS classes: utilities and builtins. Builtins are baked into the main stylesheet; utility classes have four types, are optional, and are generated by the Vite plugin - see setup to install it.
Utility classes complement semantic styles and style variables. Use them to compose styles across
component boundaries, or when you prefer classes to the <style> tag and style attribute. They're optional and generated on-demand to include only what you
use.
Compared to TailwindCSS and UnoCSS, fuz_css utility classes follow the grain of semantic HTML rather than being foundational to the design, and the DSL is currently more limited, with interpreters providing a programmatic escape hatch - see the comparison below.
Compared to the <style> tag, classes:
- offer shorthand for style variables (
p_lgvspadding: var(--space_lg)) - compose across component boundaries, avoiding fragile
:global()selectors - let you avoid noisy class names like
foo-wrapperandbar-inner
Compared to the style attribute, classes:
- support powerful modifiers for responsive widths, interaction states (like hover), and dark mode
- provide more control over specificity
- compose ergonomically with libraries like clsx, which Svelte supports natively
For cases where classes lack clear advantages, style and <style> are simpler and avoid generating class definitions, which can bloat your builds when overused.
Class detection #
The extractor scans your source files and extracts class names using three automatic mechanisms, plus manual hints for edge cases:
1. Direct extraction from class attributes
String literals and expressions in class contexts are extracted directly:
class="..."- static stringsclass={[...]}- array syntax (for clsx-compatible frameworks like Svelte)class={{...}}- object syntax (for clsx-compatible frameworks like Svelte)class={cond ? 'a' : 'b'}- ternary expressionsclass={(cond && 'a') || 'b'}- logical expressionsclass:name- class directives (Svelte)clsx(),cn(),cx(),classNames()- utility function calls
2. Naming convention
Variables ending with class, classes, className, classNames, class_name, or class_names (case-insensitive) are always extracted, regardless of where they're used:
// extracted because of naming convention
const buttonClasses = 'palette_d font_size_lg';
const buttonClass = active ? 'active' : null;
const snake_class = 'snake';
const turtle_class_name = 'turtle'; 3. Usage tracking
Variables used in class attributes are tracked back to their definitions, even if they don't follow the naming convention:
<script>
const styles = 'some-class'; // tracked from class={styles}
const variant = 'other-class'; // tracked from clsx()
</script>
<div class={styles}></div>
<button class={clsx('palette_d', variant)}></button> Usage tracking works for variables inside clsx(), arrays, ternaries, and logical
expressions within class attributes. Note that standalone clsx() calls outside
class attributes don't trigger tracking - use the naming convention for those cases.
4. Manual hints
For dynamically constructed classes that can't be statically analyzed, use the @fuz-classes comment:
// @fuz-classes opacity:50% opacity:75% opacity:100%
const opacity_classes = [50, 75, 100].map((n) => `opacity:${n}%`);
/* @fuz-classes color_a_50 color_b_50 color_c_50 */
const color = get_dynamic_color(); A common case is iterating over variant arrays to generate demos or UI. The extractor sees class="shadow_alpha_{variant}" but can't resolve what variant will be at runtime:
<script>
import {shadow_alpha_variants} from '@fuzdev/fuz_css/variable_data.ts';
// @fuz-classes shadow_alpha_00 shadow_alpha_05 shadow_alpha_10 ... shadow_alpha_100
</script>
{#each shadow_alpha_variants as variant}
<div class="shadow_alpha_{variant}">...</div>
{/each} Alternatively, use the additional_classes option in your Vite plugin config:
vite_plugin_fuz_css({
additional_classes: ['opacity:50%', 'opacity:75%', 'opacity:100%'],
}); Use exclude_classes to filter out false positives from extraction. This also suppresses warnings for these classes, even if they were explicitly annotated:
vite_plugin_fuz_css({
exclude_classes: ['some:false:positive'],
}); Element hints
Similar to @fuz-classes, use @fuz-elements to declare elements that
should be included even when they can't be statically detected:
// @fuz-elements dialog
const el = document.createElement('dialog'); CSS variable detection
CSS variables are detected via simple regex scan of var(--name patterns in all
source files. Only theme variables are included; unknown variables are silently ignored. This
approach catches usage in component props like size="var(--icon_size_xs)" that AST-based extraction would miss.
When variable names are constructed at runtime (e.g. with template literals), use @fuz-variables to explicitly include them:
<script>
import {shade_scale_variants} from '@fuzdev/fuz_css/variable_data.ts';
// @fuz-variables shade_min shade_00 shade_05 shade_10 ... shade_100 shade_max
</script>
{#each shade_scale_variants as variant}
<div style:background="var(--shade_{variant})">...</div>
{/each} 5. Build-time limitations
Class and element detection happens at build time via static analysis. Content created
dynamically at runtime (document.createElement(), innerHTML,
framework hydration) won't be detected.
Use additional_elements to force-include element styles for runtime-created elements:
vite_plugin_fuz_css({
additional_elements: ['dialog', 'details', 'datalist'],
});What gets included #
Detection finds the classes, elements, and variables your source uses (see class detection above), and resolution turns that into CSS. Bundled mode (the Vite plugin's default) combines three layers into a single output, each trimmed to what your code uses:
- style variables as
:rootcustom properties (the full version of which is theme.css) - the reset stylesheet's base styles for the HTML elements you use (the full version of which is style.css)
- used utility classes (always generated on demand; there's no full version)
Sections are emitted in that order inside @layer cascade layers:
defaults in fuz.base, OS user-preference mappings (like prefers-contrast) in fuz.preferences, theme overrides in fuz.theme, and utilities in fuz.utilities, so utilities beat the
reset by layer order (not specificity) and your own unlayered styles beat everything. The
exceptions are two !important declarations, which a layer makes outrank unlayered
styles too: [hidden], and the prefers-reduced-motion mapping that
clears the --duration_* variables so a theme can't re-enable motion.
If you use @layer for your own styles, declare fuz's layers first so yours come
later and win: @layer fuz.base, fuz.preferences, fuz.theme, fuz.utilities, app;.
Without that line, fuz's layers are declared when its CSS loads, after any of yours, and beat
them.
Inside fuz.utilities, composites (box, panel, sized_md, and the other multi-property ones) render in a fuz.utilities.composites sublayer - the single-property shortcuts like circular and mb_flow order with the token classes - so a token or
literal class always wins over a composite it overlaps: panel shadow_md gets the
medium shadow, at every breakpoint. Within each, the order comes from what the classes mean,
not their names (two literals of one property still conflict by name): unmodified classes
first, then media queries - min-width breakpoints ascending, max-width ones descending, then feature queries like print: - then
color-scheme classes, and within those, shorthands before longhands (p_md before pl_lg) and states in link-visited-focus-hover-active order. So p_sm md:p_md lg:p_lg steps up as the viewport widens, however the classes are
written.
Base styles
A rule from the reset stylesheet is kept when any element or class in its selector is
detected. Kept rules are emitted in source order, tree-shaking unused rules. A top-level @media, @supports, or @container rule is kept or
dropped whole, by the elements and classes of the rules inside it.
Some rules are always included regardless of detection:
- the universal reset (
*),:rootand:host,html, andbody - rules whose selector can't be matched against what's detected: one naming no element or
class (like
::selectionand[hidden]), or an escaped or non-ASCII name (like.md\:flex) - every other at-rule, like
@keyframesand@font-face
For apps that use dynamic HTML patterns, element detection may have false negatives, omitting
styles that you actually need. The reliable fix is to ship the full reset with additional_elements: 'all'. Reach for an explicit additional_elements list
only when you want to keep the base CSS minimal and know exactly which extra tags appear at
runtime.
// ship the full base reset, not just detected tags
vite_plugin_fuz_css({
additional_elements: 'all',
}); The base_css option replaces
the reset stylesheet with a string, or transforms it with a callback that receives the default
CSS. Either form takes any CSS the parser accepts and is tree-shaken by the rules above. All
of it lands in fuz.base, a callback's additions included, so it sits below themes
and utilities; put overrides that must win in your own stylesheet.
vite_plugin_fuz_css({
// tree-shaken with the rest: ships when a `.prose` class is used
base_css: (css) => css + '\n.prose { max-width: 65ch; }',
}); The generator owns the layering, so an @layer of your own in base_css is an error (base_css_layer), as are @import and @namespace, which are invalid inside a layer
(base_css_unsupported_at_rule). An error never removes CSS: the rule ships as
written, and the error names it and its line. Only a top-level @charset is left
out, since an encoding marker means nothing in a string.
Variables
A style variable is included when:
- a kept base-style rule uses it,
- a generated utility class uses it,
- your source references
var(--name)directly, e.g. in astyleattribute, a<style>block, or an imported CSS file (including a dependency's stylesheet), or - it's force-included via additional_variables.
Dependencies resolve transitively, so pulling in a variable also pulls in any it's built from
via var(), and both light and dark values are
kept together, so switching color-scheme at runtime never hits a missing variable. The
complete set ships in theme.css for utility-only mode and direct
imports; bundled mode trims it to what you use.
The variables option is the
whole set to draw from. If a kept base-style rule references a variable the defaults define
but your set lacks (with null, an empty array, or a partial set), generation
reports the error undefined_theme_variables, since a var(--name) with no fallback would resolve to nothing. It never fires for a reference with a fallback, a
name the base styles declare themselves (in the same rule, or in a top-level rule whose
selector is exactly :root, :host, html, body, or *), or a custom property of your own.
Define the missing variables, or for bundled base styles over a theme stylesheet you import separately, declare the default set defined elsewhere:
import {default_variables} from '@fuzdev/fuz_css/variables.ts';
vite_plugin_fuz_css({
variables: null,
exclude_variables: default_variables.map((v) => v.name),
}); Forcing and excluding
When static analysis can't see something, or sees too much, override resolution with the CssGeneratorBaseOptions: additional_classes, additional_elements, and additional_variables force-include (the
element and variable options also accept 'all'), while exclude_classes, exclude_elements, and exclude_variables drop items from the output. Excluding a variable that's still
referenced by included styles logs a warning, since the dropped var(--name) would
otherwise resolve to nothing.
Utility class types #
Token classes #
Token classes are technically composite classes with a close relationship to style variables: each maps design tokens to CSS properties. They're generated programmatically from variant data, making them predictable and systematic. The composites documented below are hand-written and typically represent higher-level semantic concepts. For raw CSS values, use literal classes instead.
<p class="pl_xl3 color_g_50">some token classes</p> some token classes
Token classes use snake_case because style variables are designed for optional
use in JS (imported from variables.ts, but costing nothing
otherwise), so each name is consistent across both JS and CSS, instead of converting between kebab-case and camelCase. This also makes token classes visually
distinct from literal classes; we find this improves
readability.
Spacing
See layout.
.p_{xs5-xl15}.p_0.pt_{xs5-xl15}.pt_0.pr_{xs5-xl15}.pr_0.pb_{xs5-xl15}.pb_0.pl_{xs5-xl15}.pl_0.px_{xs5-xl15}.px_0.py_{xs5-xl15}.py_0.m_{xs5-xl15}.m_0.m_auto.mt_{xs5-xl15}.mt_0.mt_auto.mr_{xs5-xl15}.mr_0.mr_auto.mb_{xs5-xl15}.mb_0.mb_auto.ml_{xs5-xl15}.ml_0.ml_auto.mx_{xs5-xl15}.mx_0.mx_auto.my_{xs5-xl15}.my_0.my_auto.gap_{xs5-xl15}.column_gap_{xs5-xl15}.row_gap_{xs5-xl15}.top_{xs5-xl15}.right_{xs5-xl15}.bottom_{xs5-xl15}.left_{xs5-xl15}.inset_{xs5-xl15}
Sizing
See layout.
.width_{xs5-xl15}.height_{xs5-xl15}.width_atmost_{xs-xl}.width_atleast_{xs-xl}.height_atmost_{xs-xl}.height_atleast_{xs-xl}
Colors
See colors, shading, and typography.
.color_{a-j}_{00-100}.bg_{a-j}_{00-100}.accent_{00-100}.positive_{00-100}.negative_{00-100}.caution_{00-100}.info_{00-100}.bg_accent_{00-100}.bg_positive_{00-100}.bg_negative_{00-100}.bg_caution_{00-100}.bg_info_{00-100}
.text_min.text_max.text_{00-100}.shade_min.shade_max.shade_{00-100}.darken_{00-100}.lighten_{00-100}
Typography
See typography.
.font_family_sans.font_family_serif.font_family_mono.font_size_{xs-xl9}.line_height_{xs-xl}.icon_size_{xs-xl3}
Borders
See borders.
.border_color_{00-100}.border_{a-j}_{00-100}.border_width_{1-9}.border_radius_{xs3-xl}.border_top_left_radius_{xs3-xl}.border_top_right_radius_{xs3-xl}.border_bottom_left_radius_{xs3-xl}.border_bottom_right_radius_{xs3-xl}.outline_width_{1-9}.outline_width_focus.outline_width_active.outline_color_{00-100}.outline_{a-j}_{00-100}
Shadows
See shadows.
.shadow_{xs-xl}.shadow_top_{xs-xl}.shadow_bottom_{xs-xl}.shadow_inset_{xs-xl}.shadow_inset_top_{xs-xl}.shadow_inset_bottom_{xs-xl}.shadow_color_umbra.shadow_color_highlight.shadow_color_glow.shadow_color_shroud.shadow_{a-j}_{00-100}.shadow_alpha_{00-100}
Composite classes #
Composites let you name and reuse patterns, extending the class system with your own vocabulary. They have four forms: raw CSS declarations, compositions of other classes, a combination of both, or full rulesets as an escape hatch for multi-selector patterns (child selectors, sibling combinators, etc.).
Four definition forms
All four of these produce the same CSS output for .centered, with the ruleset
form additionally demonstrating child selectors (> * + *) which can't be
expressed with the other forms. A composes or ruleset definition
is a composite, rendered in the composites sublayer beneath token and literal classes. A declaration alone is single-purpose and orders with the token classes, even
when it sets several properties, unless it sets composite: true, as form 1
does.
import type {CssClassDefinition} from '@fuzdev/fuz_css/css_class_generation.ts';
export const custom_composites: Record<string, CssClassDefinition> = {
// 1. `declaration` only - custom CSS properties
centered: {
composite: true, // without `composes`, a declaration is single-purpose by default
declaration: `
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
`,
},
// 2. `composes` only - compose existing token/composite classes
centered: {
composes: ['box', 'text-align:center'],
},
// 3. `composes` + `declaration` - compose then extend
centered: {
composes: ['box'],
declaration: 'text-align: center;',
},
// 4. `ruleset` - full CSS with multiple selectors (not composable)
centered: {
ruleset: `
.centered {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
/* child selectors, pseudo-classes on children, etc */
.centered > * + * {
margin-top: var(--space_md);
}
`,
},
}; Generated CSS:
.centered {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
} And the ruleset form (4) includes the additional selector:
.centered > * + * {
margin-top: var(--space_md);
} Nesting
Composites can compose other composites, enabling layered abstractions. Resolution is
depth-first: nested composes are fully resolved before the parent's declaration is appended. Circular references are detected and produce an error.
What composes can reference
The composes property resolves referenced classes and combines their
declarations. When both composes and declaration are present, the
explicit declaration comes last (winning in the cascade for duplicate properties).
- token classes (
p_lg,color_a_50) - resolved to their declarations - composites with
declaration- the declaration is included - composites with
composes- recursively resolved - unmodified CSS literals (
text-align:center,margin:0~auto,--my-var:value) - parsed and included as declarations
Not allowed: Composites with ruleset cannot be referenced in composes because they define their own selectors. Modified classes (like hover:opacity:80% or md:p_lg) cannot be used in composes arrays because they require wrapper selectors (apply them directly in
markup instead). The composes property merges declarations into a single rule,
but multi-selector patterns like .clickable:hover { ... } cannot be
inlined. These limitations may be revisited in the future; feedback is welcome in the discussions.
Modifiers
Composites support modifiers like any other class. For composes and declaration composites, declarations are combined and
wrapped. For ruleset composites, modifiers are applied to each selector (with
smart conflict detection):
<!-- hover:foo resolves foo's `composes`, applies :hover -->
<div class="hover:foo md:dark:foo md:clickable"> Registering composites
Register custom composites with the Vite plugin:
// vite.config.ts
import {custom_composites} from './src/lib/composites.ts';
vite_plugin_fuz_css({
class_definitions: custom_composites,
}), See the plugin options for more details.
Builtin composites
Composable (can be used in composes arrays):
.box- centered flex container.row- horizontal flex row.column- vertical flex column.ellipsis- text overflow ellipsis.pane- pane container.panel- panel container.icon_button- icon button styling.pixelated- crisp pixel-art rendering.circular- 50% border-radius.sized_xs- smallest sizing, cascading to children.sized_sm- smaller sizing, cascading to children.sized_md- default sizing, cascade reset.sized_lg- larger sizing, cascading to children.sized_xl- largest sizing, cascading to children.mb_flow- flow-aware margin-bottom.mt_flow- flow-aware margin-top
Ruleset-based (multi-selector, cannot be used in composes arrays):
.selectable- selectable element styling.clickable- clickable element styling.plain- plain/reset styling.menuitem- menu item styling.chevron- chevron indicator.chip- chip/tag styling
Keyword classes #
A CSS value keyword that names its property on its own works as a bare class: flex is display: flex, sticky is position: sticky. The CSS spec is the namespace - a keyword gets a class when
no other mainstream property shares it, so a shared keyword like center, nowrap, or none stays a literal (align-items:center). Hyphenated names are CSS's own words
(inline-flex, line-through), while multi-word names fuz_css coins
join with underscores (p_md, icon_button).
- display:
.flex.inline-flex.grid.inline-grid.inline-block.block.flow-root - position:
.relative.absolute.fixed.sticky - text-transform:
.uppercase.lowercase.capitalize - font-style:
.italic.oblique - font-weight:
.bold.bolder.lighter - text-decoration-line:
.underline.overline.line-through - fuz_css:
.hidden(display: none)
<p class="uppercase">uppercase</p>
<span class="hover:underline">underlined while hovered</span>
<div class="relative md:flex"> uppercase
Each sets one declaration, orders with the token and literal classes by its property, and
takes modifiers like any class. The decoration keywords set the text-decoration-line longhand, so an element's decoration color and style carry
through. The composites row, column, and box stay as they are - flex containers with a
direction and alignment - and flex is plain display: flex with
neither.
.hidden
hidden isn't a CSS keyword: it mirrors the HTML hidden attribute
and sets display: none. It exists for the modifiers the attribute can't take,
and static hiding is the attribute's job. The attribute wins over every class (the main
stylesheet sets it with a layered !important), so <div class="flex" hidden> stays hidden, while the class is
normal-strength and loses to an unlayered display of your own.
The class orders late among the rules it ties with: after any other display class at the
same media query and state, a display:* literal included, so toggling it works
in a class list. A media query still outranks it.
<!-- toggled: hidden wins over the literal beside it -->
<div class={['display:flex', {hidden: !open}]}>
<!-- hidden below md, a row from md up -->
<nav class="max-md:hidden row">
<!-- hidden until md, then flex -->
<nav class="hidden md:flex">
<nav class="display:none md:display:flex">
<!-- left out of printouts -->
<aside class="print:hidden"> One pattern doesn't work: hidden md:row stays hidden at every width. Composites
render in a sublayer beneath every single-purpose class, modified or not, so the unmodified hidden beats md:row. Put the modifier on the single-purpose class
instead: max-md:hidden row.
Literal classes #
Fuz supports an open-ended CSS-literal syntax: property:value. Any CSS property
and value works, offering arbitrary styles without a DSL.
<!-- basic syntax: property:value -->
<div class="display:flex justify-content:center">
<!-- multi-value properties use ~ for spaces -->
<div class="margin:1px~3rem">
<!-- numeric values -->
<div class="opacity:50% font-weight:700 z-index:100">
<!-- arbitrary CSS values -->
<div class="width:calc(100%~-~20px)">
<!-- custom properties -->
<div class="--foo-bg:#abc"> The ~ character represents a space in class names (since CSS classes can't
contain spaces). Use it for multi-value properties like margin:1px~auto.
Custom properties work directly: --my-var:value sets the property on the
element. This is useful for scoped variables or passing values to child components.
Modifiers #
Modifiers prefix any class type (token, keyword, composite, or literal) to apply styles conditionally based on viewport, state, or color scheme. This is what makes utility classes more powerful than inline styles.
Responsive modifiers
Mobile-first breakpoints:
| Prefix | Width | CSS |
|---|---|---|
sm: | 40rem (640px) | @media (width >= 40rem) |
md: | 48rem (768px) | @media (width >= 48rem) |
lg: | 64rem (1024px) | @media (width >= 64rem) |
xl: | 80rem (1280px) | @media (width >= 80rem) |
2xl: | 96rem (1536px) | @media (width >= 96rem) |
<!-- stack on mobile, row on medium screens and up -->
<div class="display:flex flex-direction:column md:flex-direction:row">
<!-- hide on mobile -->
<nav class="display:none md:display:flex">
<!-- max-width variant -->
<div class="max-md:display:none">
<!-- arbitrary breakpoints -->
<div class="min-width(800px):color:red max-width(600px):color:blue"> State modifiers
Pseudo-class modifiers for interaction and form states:
<button class="hover:opacity:80% focus:outline-color:blue">
<input class="disabled:opacity:50% invalid:border-color:red">
<li class="first:font-weight:bold odd:background-color:lightgray"> Available state modifiers include:
- interaction:
hover:focus:focus-visible:focus-within:active:link:visited:any-link:target: - form:
autofill:blank:disabled:enabled:checked:indeterminate:required:optional:valid:invalid:user-valid:user-invalid:in-range:out-of-range:placeholder-shown:read-only:read-write:default: - structural:
first:last:only:first-of-type:last-of-type:only-of-type:odd:even:empty:nth-child(N):nth-last-child(N):nth-of-type(N):nth-last-of-type(N): - UI states:
fullscreen:modal:open:popover-open: - media:
playing:paused:
Color-scheme modifiers
Apply styles in dark or light mode:
<div class="shadow_lg dark:shadow_sm">
<div class="color:black light:color:gray"> dark: and light: apply when the root element has the dark or light class (fuz_ui's theme state sets one), and when it has
neither they follow the OS prefers-color-scheme, so they
work without JS. They add no specificity (the root selector is wrapped in :where()), so a hover: class still wins while hovering in dark mode.
Pseudo-element modifiers
Style generated content and element parts:
<span class="before:content:'→' before:margin-right:0.5rem">
<input class="placeholder:opacity:50%"> available: before: after: cue: first-letter: first-line: placeholder: selection: marker: file: backdrop:
Media feature modifiers
Accessibility and context-aware styles:
<div class="motion-reduce:animation:none">
<nav class="print:display:none"> available: print: motion-safe: motion-reduce: contrast-more: contrast-less: portrait: landscape: forced-colors:
Combining modifiers #
Combined modifiers follow a canonical order enforced with errors that guide you. Multiple
states must be alphabetical (focus:hover: not hover:focus:)
because both generate equivalent CSS; canonical ordering prevents duplicates.
[media:][ancestor:][...state:][pseudo-element:]class - media - one of
md:,lg:,print:, etc - ancestor - one of
dark:orlight:(likelyrtl:/ltr:in the future) - state - any of
hover:,focus:,disabled:, etc, sorted alphabetically - pseudo-element - one of
before:,after:,placeholder:, etc
<!-- media + ancestor + state -->
<div class="md:dark:hover:opacity:83%">
<!-- media + state + pseudo-element -->
<div class="md:hover:before:opacity:100%">
<!-- multiple states must be alphabetical -->
<button class="focus:hover:outline:2px~solid~blue"> Generated CSS for md:dark:hover:opacity:83%:
@media (width >= 48rem) {
:where(:root.dark) .md\:dark\:hover\:opacity\:83\%:hover {
opacity: 83%;
}
}
@media (width >= 48rem) and (prefers-color-scheme: dark) {
:where(:root:not(.light)) .md\:dark\:hover\:opacity\:83\%:hover {
opacity: 83%;
}
}Builtin classes #
fuz_css's main stylesheet provides styles for base HTML elements using style variables, acting as a modern CSS reset that adapts to dark mode. It includes CSS classes that provide common generic functionality - these are called builtin classes.
.unstyled
Default list (styled):
<ul>
<li>1</li>
<li>2</li>
</ul> - 1
- 2
With .unstyled:
<ul class="unstyled">
<li>a</li>
<li>b</li>
</ul> - a
- b
The .unstyled class lets fuz_css provide solid default element styles with a
simple opt-out:
:where(:is(ul, ol, menu):not(.unstyled)) {
padding-left: var(--space_xl4);
} This strategy supports semantic hooks for theming:
:where(:is(ul, ol, menu):not(.unstyled)) {
padding-left: var(--list_padding_left, var(--space_xl4));
} See the specific docs sections for more about .unstyled.
Other builtin classes
Custom interpreters #
Interpreters dynamically
generate CSS for class names that aren't in the static definitions (which can be extended via class_definitions, or replaced with a callback that ignores the defaults). The
default CSS-literal syntax is implemented as an interpreter,
which you can extend or replace. Modifiers aren't - the generator
strips them from every class name before resolving it, so they apply to interpreted classes
too.
For advanced use cases, you can define custom interpreters that generate CSS from arbitrary
class name patterns. This is similar to UnoCSS's dynamic rules, which also use regex +
function patterns. An interpreter has a regex pattern and an interpret function that returns a definition shaped like a static one - a declaration, a ruleset, or composes - or null to pass:
import type {CssClassDefinitionInterpreter} from '@fuzdev/fuz_css/css_class_generation.ts';
// Example: grid-cols-N classes like "grid-cols-4"
// Unlike composites, interpreters can parameterize values
const grid_cols_interpreter: CssClassDefinitionInterpreter = {
pattern: /^grid-cols-(\d+)$/,
interpret: (matched) => {
const n = parseInt(matched[1]!, 10);
if (n < 1 || n > 24) return null;
return {declaration: `grid-template-columns: repeat(${n}, minmax(0, 1fr));`};
},
}; This generates grid-cols-1 through grid-cols-24 on-demand, something
that would require 24 separate composite definitions, and md:grid-cols-4 works
with no extra code. Return composite: true alongside a multi-property declaration
to render it in the composites sublayer. Note the classes for this example could also be
created as composites with a helper function - fuz_css uses this strategy internally to create
its token classes in css_class_definitions.ts.
Register with the Vite plugin:
vite_plugin_fuz_css({
class_interpreters: (defaults) => [grid_cols_interpreter, ...defaults],
}) The interpreter context provides access to class_definitions, css_properties (for validation), and diagnostics (for
errors/warnings). This enables full programmatic control over class-to-CSS generation.
Compared to alternatives #
TailwindCSS and UnoCSS are utility-first frameworks where classes have primacy. fuz_css is semantic-first: utilities complement HTML defaults rather than being the primary styling mechanism.
| TailwindCSS | UnoCSS | fuz_css | |
|---|---|---|---|
| primary syntax | DSL-first | config-first | token DSL + CSS literals |
| multi-property | @apply, plugins | shortcuts | composites |
| arbitrary values | DSL (bg-[#abc]) | any (presets) | CSS syntax (background:#abc) |
| detection | regex | regex | AST (more capable, slower) |
| token source | CSS (@theme) | JS/TS config | TS variables (importable) |
| extensibility | plugins | rules, variants, presets | interpreters |
Where TailwindCSS names a class after the CSS keyword, the keyword classes match it: a TailwindCSS project's flex, hidden, or sticky resolves to the same CSS. Its
own names don't, and generate nothing without a warning, so flex items-center justify-between gets display: flex alone - the
fuz_css spelling is flex align-items:center justify-content:space-between.
fuz_css's modifier system is less expressive than TailwindCSS's variants. Missing:
parent/sibling/descendant state (group-hover:, peer-invalid:, has-checked:), arbitrary variants ([&.is-dragging]:), child
selectors (*:), container queries (@md:), data/ARIA variants, and
more. When you need these patterns, fuz_css currently expects you to use rulesets or <style> tags, but the API is still a work in progress, and a more powerful
and potentially more TailwindCSS-aligned system is on the table.
For extensibility, all three frameworks allow custom class-to-CSS mappings. UnoCSS's dynamic rules use regex + function patterns similar to fuz_css interpreters, plus separate variants for modifiers. TailwindCSS uses JS plugins and UnoCSS has the more mature extensibility story; fuz_css offers comparable power with interpreters but it's still evolving - feedback is welcome!
fuz_css fits best when you prefer semantic HTML with styled defaults. Design tokens are
defined in TypeScript, naturally adapt to dark mode, and can be imported in TS for typesafe
runtime access. The tradeoffs include a more limited DSL and more verbose literal syntax,
which nudges you toward <style> tags, tokens when appropriate, or
composites for repeated patterns.
fuz_css is still early in development. Your input and feedback is appreciated in
the GitHub issues for bugs and discussions for everything else.
colors #
fuz_css's colors are derived: a handful of high-leverage knobs produce every color variables in pure CSS, in the oklch colorspace, adapting to the color-scheme automatically. OKLCH lightness is perceptually uniform (equal lightness reads equally light in every hue), so rotating a hue knob is safe: contrast and visual weight hold.
Hues use letters so themes can reassign colors without breaking semantics ("a" is blue by
default but could be any color). Meaning attaches through the intent knobs layered on top: --hue_accent (links, focus, selection, selected states - what other systems call
"primary") defaults to --hue_a, --hue_negative to --hue_c, and so on. Retarget an intent to move just that meaning; rotate a letter
to move the palette.
Hue variables #
Hue variables contain a single OKLCH hue angle. Because lightness and chroma are shared across all hues at each stop, the scales are interchangeable: setting a hue alone is enough, no per-hue tuning required. The one deliberate exception is the per-slot chroma multiplier - the brown slot ships muted because no hue angle renders brown at full palette chroma.
Hue variables are also useful to construct custom colors not covered by the palette. For
example, fuz_css's selection color derives from --hue_accent (try selecting some
text - same hue!).
Hue variables are the same in both light and dark modes (non-adaptive).
- 250blue · default accent
- 144green · default positive
- 29red · default negative
- 296purple
- 98yellow
- 60brown · default neutral
- 358pink
- 41orange · default caution
- 210cyan · default info
- 180teal
Palette variables #
Each hue has intensity variants from subtle to bold (00, 05, 10, 20, ..., 80, 90, 95, 100),
with 50 at the middle of the ramp. The 60 variant is the text-safe stop: links, the labels of chips, and a selected .palette_X button's fill use it. .palette_X buttons label at 70, one step further, since their
own tint deepens behind the label as it's hovered and pressed. check_theme gates
all of these pairings at AA.
Palette variables work for both text and backgrounds through utility classes: .color_a_50 sets the text color and .bg_a_50 the background, and the intent scales below do the same. The shade and text scales are split by role
instead, one for surfaces and one for text.
Palette stops are adaptive: they switch between light and dark ramps based on color scheme. There are no absolute variants; for a color that doesn't adapt, write the literal color or define one custom property.
Intent variables #
Intent scales name colors by meaning: --accent_NN, --positive_NN, --negative_NN, --caution_NN, and --info_NN, with the
palette's stops, derived through the same ramps from each intent's hue knob
(--hue_accent and the rest). Each hue binds to a palette letter by default, so an
intent scale matches its letter's until a theme rebinds it: accent → a, positive → b, negative → c, caution → h, info → i.
Reach for an intent when the color carries meaning, like an error or a success, and for a
letter when it's only a color, so a theme can move the meaning without recoloring every use of
the letter. The classes follow the palette's: .positive_50 sets the text color
and .bg_positive_50 the background. Border, outline, and shadow classes exist
only for the letters, so reach an intent there with a literal like border-color:var(--negative_50).
Color knobs #
The knobs are the theme API, and the first few carry most themes:
- intent hues -
--hue_accent,--hue_positive,--hue_negative,--hue_caution,--hue_info- each pointing a meaning at a palette letter (var(--hue_c)) or a literal angle, and deriving its full stop scale (--accent_00…--accent_100) --hue_neutral+--neutral_chroma- the temperature and strength of every surface, text, border, and shadow tint (the neutral intent, whose scales are the shade and text ramps)--chroma_scale- one multiplier from grayscale (0) through calm (1) to vivid (above 1, deliberately clipping the weakest hues)- lightness ramps -
--palette_lightness_00/_100/_curve(and the same trio forshade_andtext_): the endpoint stops plus a curve exponent bending the ramp between them, per color scheme - chroma curve -
--palette_chroma_min/_maxand--chroma_curve: a mid-peaked curve, clamped per stop by gamut caps computed from the worst hue - per-slot chroma multipliers -
--palette_a_chroma_scale…--palette_j_chroma_scaleand intent twins (--accent_chroma_scale, …), each multiplying one slot's chroma under--chroma_scale. A binding shares only the hue angle, so an intent bound to the muted brown slot needs its twin set too, andvalidate_themewarns when it isn't --hue_a…--hue_j- the palette letters' angles. Rotating one recolors every use of that letter, so registered themes leave them alone and move intents instead
The intermediate values between the knobs and the color stops are internal, spelled with a
leading underscore (--_palette_lightness_30, --_palette_chroma_50,
…). A theme can't set them - it steers the ramps through these knobs, and can still pin an
individual color stop like --palette_a_50.
// a warm, slightly vivid theme in three moves
const warm_theme: Theme = {
name: 'warm',
variables: [
{name: 'hue_neutral', base: '55'},
{name: 'neutral_chroma', base: '0.03'},
{name: 'chroma_scale', base: '1.15'},
],
};shading #
fuz_css offers a shading model built on adaptive style variables that respond to the color-scheme. Adaptive means the underlying values change between light and dark modes to maintain consistent prominence: low numbers stay subtle, high numbers stay strong. Each theme can implement light mode, dark mode, or both.
Light mode's starting point is plain white documents (like paper) where we subtract light to create contrast and shape. Black shadows on white make natural sense; white glows are near invisible.
Dark mode's starting point is a lightless void where we add light. Elements emit light to fill the darkness. White glows make natural sense; black shadows are near invisible.
The shade scale #
The shade scale is the primary system for backgrounds and surfaces. All numbered shades
(shade_00 through shade_100) are tinted using the theme's hue_neutral and neutral_chroma for visual cohesion. The scale also
includes two untinted extremes (shade_min and shade_max) for maximum
contrast needs.
Key values #
shade_min: untinted surface-side extreme (white in light mode, black in dark mode); used for input backgroundsshade_00: the base backgroundshade_05: very subtle: hover states on surface, code backgroundsshade_10: subtle elevation: panels, cards, asideshade_20: more elevated: active/pressed statesshade_30: default border intensityshade_100: maximum tinted contrastshade_max: untinted contrast-side extreme: black in light mode, white in dark mode; rarely needed
Adaptive alpha overlays (ink/paper) #
The ink and paper variables provide alpha-based overlays that adapt
to the color scheme. Unlike the opaque shade scale, these stack when nested and are used by
composites like .panel and .chip.
--ink_NN(a wash toward the text color) - darkens in light mode, lightens in dark mode; use for elevated surfaces like panels, cards, and hover states--paper_NN(a wash toward the ground) - lightens in light mode, darkens in dark mode; use for surfaces that blend toward the background
In light mode, ink is the same as darken and paper is
the same as lighten. In dark mode, they're swapped.
The overlays are variables, not token classes, so reach them with a literal class
(background-color:var(--ink_10)) or a <style> block. The bg_ classes (.bg_a_50, .bg_positive_50) are opaque.
ink (toward the text color) #
Adaptive alpha overlays that add contrast with the surface.
paper (toward the ground) #
Adaptive alpha overlays that reduce contrast with the surface.
Stacking behavior #
Unlike the opaque shade scale, alpha overlays stack when nested. Each layer adds more contrast:
<div class="background-color:var(--ink_10) p_sm">
<div class="background-color:var(--ink_10) p_sm">
<div class="background-color:var(--ink_10) p_sm">
<div class="background-color:var(--ink_10) p_sm">
...
</div>
</div>
</div>
</div> This is useful for nested UI elements like cards within cards, or hover states inside
elevated containers. Composites like .panel, .chip, and .menuitem use var(--ink_10) by default for this stacking behavior.
Darken/lighten alpha overlays #
The non-adaptive darken_NN and lighten_NN variables provide
consistent darkening or lightening regardless of color scheme. These are the underlying
primitives that ink and paper reference.
Darken #
Alpha overlays that always add black.
Lighten #
Alpha overlays that always add white.
Visual balance #
Both scales are tuned for visual balance, not mathematical spacing: 0%, 3%, 6%, 12%, 21%, 32%, 45%, 65%, 80%, 89%, 96%, 98%, 100%. This provides visually even steps across the range.
When to use which #
Use --ink_NN when you need stacking behavior or are building
nested UI:
/* elevated panel (stacks when nested) */
background-color: var(--ink_10);
/* hover state (stacks on any background) */
background-color: var(--ink_10);
/* active/pressed state */
background-color: var(--ink_20); Use shade_NN when you need explicit, predictable opaque
surfaces:
/* base page background */
background-color: var(--shade_00);
/* opaque border */
border-color: var(--shade_30);
/* input backgrounds (untinted for contrast) */
background-color: var(--shade_min); The composites (.panel, .chip, .menuitem) use --ink_NN for stacking. The page background uses shade_00 as the
opaque base. A .panel reads its fill from --panel_fill (--ink_10 by default), so a theme can make panels opaque cards instead.
Text colors #
For text colors, see the text scale (text_00 through text_100). Both scales use the same "prominence"
semantics for light and dark modes: low numbers are subtle, high numbers are strong. They're
separate scales because text and backgrounds have different contrast requirements.
buttons #
The <button> element is styled by default without adding classes. Classes
like .selected and .plain and .palette_a modify the
base style.
Buttons have a .selected state that can be used for various UI purposes, like
showing a selected item in a menu. Instead of having two distinct styles of buttons with
outlined and filled variants, fuz_css makes outlined buttons the default, and selected buttons
are filled. There's also the .deselectable modifier class for buttons that remain
clickable when selected. Themes can customize this behavior.
A toggle button's aria-pressed="true" takes the same fill and stays clickable,
like .deselectable. aria-disabled="true" takes the disabled look but
keeps the button focusable. Focus shows as the outline ring alone, for keyboard focus
(:focus-visible), so a clicked button doesn't stay highlighted - a popover or
menu trigger wanting a held look while open can style its own [aria-expanded='true'].
<button>a button</button> Colorful buttons #
.palette_a through .palette_j color a button with a palette hue: its
border, focus ring, and fill tint take stop 60, and its label stop 70, which holds AA contrast
on the tint at rest, hovered, and pressed.
<button class="palette_a"> <button class="palette_b"> <button class="palette_c"> <button class="palette_d"> <button class="palette_e"> <button class="palette_f"> <button class="palette_g"> <button class="palette_h"> <button class="palette_i"> <button class="palette_j"> With disabled attribute #
<button disabled>
:|
</button> With .selected #
.selected<button class="selected">...</button> .selected buttons with .deselectable continue to be clickable when
selected:
<button class="selected deselectable">
...
</button>With .plain and .icon_button #
.plain and .icon_button<button class="plain">
+
</button> <button class="icon_button">
+
</button> <button class="plain icon_button">
+
</button> .selected variants
.plain strips the fill, border, and shadow from buttons that are neither selected
nor pressed at rest (the fill and shadow return on hover), so a selected or pressed plain
button looks like any other selected button.
<button class="plain selected">
+
</button> <button class="icon_button selected">
+
</button> <button class="plain icon_button selected">
+
</button> .selected and .deselectable variants
<button class="plain selected deselectable">
+
</button> <button class="icon_button selected deselectable">
+
</button> <button class="plain icon_button selected deselectable">
+
</button> Size composites #
The size composite classes .sized_xs, .sized_sm, .sized_md, .sized_lg, and .sized_xl scale buttons up and down by overriding
custom properties for font, height, and padding.
<button class="sized_xs">sized_xs</button>
<button class="sized_sm">sized_sm</button>
<button class="sized_md">sized_md</button>
<button class="sized_lg">sized_lg</button>
<button class="sized_xl">sized_xl</button> They compose with other classes like .plain and .icon_button:
Set on a container and children inherit the sizing:
<div class="sized_xs">...</div> chips #
The .chip class creates a small inline label or tag, useful for displaying
metadata, categories, or status indicators. Chips work on any element but are commonly used
with <span> and <a>.
Chips have color variants (.palette_a through .palette_j) that tint
both the text and background. Links (a.chip) have slightly bolder text.
<span class="chip">a chip</span> a chip<a class="chip">a link chip</a> a link chipColorful chips #
<span class="chip palette_a"> .chip.palette_a a.chip.palette_a<span class="chip palette_b"> .chip.palette_b a.chip.palette_b<span class="chip palette_c"> .chip.palette_c a.chip.palette_c<span class="chip palette_d"> .chip.palette_d a.chip.palette_d<span class="chip palette_e"> .chip.palette_e a.chip.palette_e<span class="chip palette_f"> .chip.palette_f a.chip.palette_f<span class="chip palette_g"> .chip.palette_g a.chip.palette_g<span class="chip palette_h"> .chip.palette_h a.chip.palette_h<span class="chip palette_i"> .chip.palette_i a.chip.palette_i<span class="chip palette_j"> .chip.palette_j a.chip.palette_jSize composites #
The size composite classes .sized_xs, .sized_sm, .sized_md, .sized_lg, and .sized_xl scale chips up and down, adjusting font and
padding.
<span class="chip sized_xs">sized_xs</span>
<span class="chip sized_sm">sized_sm</span>
<span class="chip sized_md">sized_md</span>
<span class="chip sized_lg">sized_lg</span>
<span class="chip sized_xl">sized_xl</span> Set on a container and children inherit the sizing:
<div class="sized_xs">...</div> Without a size context - a size composite, a font_size_* class, or a heading - a
chip keeps its element's own size, so <small class="chip"> stays small:
<span class="chip">span.chip</span>
<small class="chip">small.chip</small> elements #
fuz_css applies default styles to semantic HTML elements in its reset stylesheet. The styles use variables and include appropriate spacing, so plain HTML gets
user-friendly styling and theme integration automatically. The defaults are low specificity
using :where so they're easy to override, and you can opt out by adding .unstyled to an element.
#
Paragraph elements are unstyled except for spacing. Divs are totally unstyled.
p
p
p
p
This paragraph has no bottom margin because default spacing is omitted for the :last-child of all otherwise-spaced elements, streamlining the common case. This
has some unfortunate edge cases that can usually be solved by adding .mb_flow.
Coupling markup structure to styles like this may be something we change, feedback is welcome.
#
a link with .selected
a link with .unstyled
#
code
code with .unstyled
#
a pre is
preformatted
text#
Click this summary to see the rest of the details
The children of the details excluding the summary.
<details>
<summary>
Click this <code>summary</code>
to see the rest of the <code>details</code>
</summary>
<p>The children of the <code>details</code> excluding the <code>summary</code>.</p>
<Code code={'...'} />
</details>details and summary with .unstyled
unstyled details content#
blockquote
blockquote with .unstyled#
.unstyled#
#
<header>header</header> #
<footer>footer</footer> #
<section>section</section> Sections have a bottom margin, except for the last in the list.
ul #
ul- a
- b
- see
ul with .unstyled
- a
- b
- see
ol #
ol- one
- two
- etc
ol with .unstyled
- one
- two
- etc
menu #
menumenu with .unstyled
#
Tables get collapsed borders, aligned headers, cell padding, and a row hover. Table styles are still basic, and cell padding doesn't yet respond to the size composites.
<table>
<thead>
<tr>
<th>th</th>
<th>th</th>
<th>th</th>
</tr>
</thead>
<tbody>
<tr><td>td</td><td>td</td><td>td</td></tr>
<tr><td>td</td><td>td</td><td>td</td></tr>
<tr><td>td</td><td>td</td><td>td</td></tr>
</tbody>
</table> | th | th | th |
|---|---|---|
| td | td | td |
| td | td | td |
| td | td | td |
<table class="width:100%">
...
</table> | th | th | th |
|---|---|---|
| td | td | td |
| td | td | td |
| td | td | td |
forms #
Form elements have basic default styles that can be omitted with .unstyled.
A single select draws its arrow in the text color, so it follows the color scheme
and the disabled look; list boxes (multiple, or a size above 1) have
none. aria-invalid="true" turns a control's border and focus ring negative, and aria-disabled="true" gives it the disabled look while keeping it focusable. A
disabled checkbox or radio takes the default cursor, though a label wrapping it
still shows the pointer.
<input aria-invalid="true" />
<input aria-disabled="true" /> #
<form>
<fieldset>
<legend>
a <MdnLink path="Web/HTML/Element/legend" />
</legend>
<label>
<div class="title">
username
</div>
<input
bind:value={username}
placeholder=">"
/>
</label>
...
</fieldset>
...
</form> form with range input #
form with range inputform with checkboxes #
form with checkboxes<label class="row"> with .disabled as needed: <label class="row disabled">form with radio inputs #
form with radio inputsSize composites #
The size composite classes .sized_xs, .sized_sm, .sized_md, .sized_lg, and .sized_xl scale inputs and buttons, adjusting height
and padding. Apply directly or on a container to cascade to children.
<input class="sized_xs" />
<input class="sized_sm" />
<input />
<input class="sized_lg" />
<input class="sized_xl" /> Set on a container and children inherit the sizing:
<form class="sized_xs">...</form> typography #
h1
paragraph
h2
paragraph
h3
paragraph
h4
paragraph
h5
paragraph
h6
paragraphs
paragraphs
paragraphs
p with some small text
p sub p sup p
show code
Font families #
These three are the stacks, and each keeps meaning what it says. Body text renders in --font_family (default var(--font_family_sans)), so a theme that
wants serif or mono body text retargets it rather than redefining a stack. Headings take --heading_font_family (default var(--font_family_serif)), so "one
family everywhere" is deliberately two knobs, and --heading_letter_spacing (default normal) sets their tracking, where an em value follows each
heading's size.
Font sizes #
Two knobs set the sizes. --font_size_scale multiplies every size, body text
(md) and the smaller sizes included, so a theme can enlarge reading text while small text keeps its
proportion to it. The sizes above md also step by one ratio, --type_scale_ratio, which flattens or opens up the whole heading hierarchy
without moving body text.
62.5%, so 1rem is 10px at the
browser's default 16px. Body text is 1.6rem. It's a percentage, so it still follows
the reader's browser font size setting.Every rem length on the page is measured against that root, including ones that come
from elsewhere: a third-party component sized for a 16px rem renders at 62.5% of what
its author intended. Size your own lengths with the style variables, and check any rem-based CSS
you bring in. A @media query is the exception, since its rem is the
browser's initial font size: the sm: breakpoint's 40rem is 640px.
Coming from Tailwind, whose spacing scale assumes a 16px rem (p-4 is 1rem, 16px): here 16px is 1.6rem, and the spacing tokens like p_lg carry the scale, so pasted Tailwind lengths come out smaller.
/* with fuz_css's root, 1rem is 10px */
.card { padding: 1rem; } /* 10px, not 16px */
.card { padding: var(--space_lg); } /* the spacing scale */ Font weights #
Font weight values can be any integer from 1 to 1000.
There are no variables for font-weight but there are utility classes.
Text colors #
The text scale (text_00 through text_100) provides tinted neutral
colors optimized for text legibility. The scale uses "prominence" semantics for light and dark
modes: low numbers are subtle, high numbers are strong. This matches the shade scale pattern.
text_00- surface-side endpoint: essentially invisible on surfacetext_10-text_30- very subtle/faint text: watermarks, hintstext_50- disabled text:text_disabledtext_80- default body text:--text_colortext_90-text_100- high emphasis/headingstext_min/text_max- knockout text (pure white/black without tint)
The text scale is separate from the shade scale because text and backgrounds have different
contrast requirements. Use text_* for text colors and shade_* for
backgrounds. For colored text, use color_a_50 etc.
Line heights #
Icon sizes #
--font_size_ variables, --icon_ variables are in px not rem, so they're insensitive to browser font size18px32px48px80px128px192px256pxSize composites #
The size composite classes .sized_xs, .sized_sm, .sized_md, .sized_lg, and .sized_xl set --flow_margin, scaling the
vertical spacing between flow elements and headings. Apply on a container to cascade to
children.
<div class="sized_lg">
<h3>heading</h3>
<p>paragraph</p>
<p>paragraph</p>
</div> .sized_xs
Paragraph with size-composite flow margins between elements.
Another paragraph with the matching spacing.
.sized_sm
Paragraph with size-composite flow margins between elements.
Another paragraph with the matching spacing.
.sized_md
Paragraph with size-composite flow margins between elements.
Another paragraph with the matching spacing.
.sized_lg
Paragraph with size-composite flow margins between elements.
Another paragraph with the matching spacing.
.sized_xl
Paragraph with size-composite flow margins between elements.
Another paragraph with the matching spacing.
--flow_margin, not
the text size. Paragraph text keeps its fixed size, and headings keep their own sizes to
preserve hierarchy; only controls like buttons and chips read the composite's --font_size. A bare .heading is the exception: it reads the current --font_size, so .heading.sized_lg or a --font_size literal tiers it. Scaling headers and prose with size composites is under consideration,
design feedback is welcome.Reset with .sized_md #
.sized_mdThe .sized_md composite class resets sizing to
the defaults. Use it inside a sized container to restore normal sizing for a subtree.
<div class="sized_sm">
<p>small text</p>
<div class="sized_md">
<p>back to normal</p>
</div>
</div> small region
Everything here is small.
normal nested inside
This region is back to default sizing despite the parent
having .sized_sm.
borders #
Border variables integrate with the theme system and adapt to color scheme. Alpha borders are tuned for visual balance: dark mode uses higher alpha because light-on-dark has lower perceived contrast.
Border style #
--border_style is global - asides, blockquotes, and form fields all read it.
Buttons take --button_border_style instead (defaulting to var(--border_style)), and swap to --button_border_style_active while
pressed. Buttons are the only element with a pressed state, so only they get the pair.
That pair makes beveled chrome expressible, since border-style's inset and outset derive their light and dark edges from the border
color. Raised buttons that press in, over sunken fields:
variables: [
{name: 'border_style', base: 'inset'}, // sunken fields
{name: 'button_border_style', base: 'outset'}, // raised buttons
{name: 'button_border_style_active', base: 'inset'} // that press in
] The guestbook theme is built on exactly this.
Tinted alpha borders #
The border_color_NN variables provide tinted alpha borders that integrate with
the theme. They use hue_neutral for cohesion.
Opaque borders with shades #
border_shade_NN family.For opaque borders, use shade variables directly. This avoids alpha transparency but requires inline styles or custom classes:
/* inline style */
border-color: var(--shade_30);
/* or set the contextual variable */
--border_color: var(--shade_30);Border colors #
Classes like .border_a_50 apply the palette variables
(--palette_a_50) to borders. The intensity controls the color's
prominence.
Border widths #
Outlines #
Each border utility class has a corresponding outline variant (like outline_b_50, outline_width_4, and outline-style:solid). The letterless classes
differ, though: border_color_50 is the translucent border ramp, while outline_color_50 is the opaque shade ramp. There are also two special outline
variables:
Focus rings draw with --outline_style (default solid), so a theme
can dot or dash every ring at once.
Border radius #
Border variables with token classes.
Each tier is the base radius times --radius_scale, floored at --border_radius_min. Buttons and form fields other than checkboxes read --control_radius (the sm tier by default), so a theme can round its
controls apart from its panels:
Custom values #
Border literal classes for open-ended values:
shadows #
fuz_css provides four semantic shadow types that build on the light model in the shading docs: umbra for natural depth, highlight for rim lighting, glow for light emphasis, and shroud for dark overlays.
Umbra #
Umbras are adaptive shadows that darken in light mode and lighten in dark mode. This is the
default shadow behavior, creating natural depth perception in both color schemes. In light
mode umbra is untinted (pure black); in dark mode it's tinted toward hue_neutral.
shadow_alpha Highlight #
Highlights are adaptive shadows that lighten in light mode and darken in dark mode. In light
mode highlight is tinted using hue_neutral; in dark mode it's untinted (pure
black).
shadow_alpha Glow #
Glows are non-adaptive shadows that lighten in both light and dark mode. Glow colors are
tinted using the theme's hue_neutral.
shadow_alpha Shroud #
Shrouds are non-adaptive shadows that darken in both light and dark mode. Unlike glow, shroud is untinted (pure black) because dark shadows typically don't benefit from tinting. This asymmetry is intentional but subject to change.
shadow_alpha Surface shadows #
Three surfaces read their shadow from a variable a theme can retarget: buttons from --button_shadow (with _hover and _active), a floating .pane from --pane_shadow, and an embedded .panel from --panel_shadow, which is none unless a theme lifts its panels. A
shadow shape class like shadow_md on the same element still wins, but a shadow
color or alpha class alone doesn't tint them, since the variables name their shadow colors
outright.
Colored shadows #
Use shadow_{letter}_{intensity} classes to apply colored shadows. The
intensity controls the color's prominence; 60 is a fine starting point for visible colored
shadows.
shadow_a_60 #
shadow_alpha .shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60.shadow_a_60shadow_b_60 #
shadow_alpha .shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60.shadow_b_60shadow_c_60 #
shadow_alpha .shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60.shadow_c_60shadow_d_60 #
shadow_alpha .shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60.shadow_d_60shadow_e_60 #
shadow_alpha .shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60.shadow_e_60shadow_f_60 #
shadow_alpha .shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60.shadow_f_60shadow_g_60 #
shadow_alpha .shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60.shadow_g_60shadow_h_60 #
shadow_alpha .shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60.shadow_h_60shadow_i_60 #
shadow_alpha .shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60.shadow_i_60shadow_j_60 #
shadow_alpha .shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60.shadow_j_60layout #
fuz_css provides space and distance variables for
consistent sizing across your UI. Space variants work with utility classes like .p_md and .gap_lg.
Space variables #
- =
- =
- =
- =
- =
- =
- =
- =
- =
- =
- =
- =
- =
- =
- =
- =
- =
- =
- =
- =
- =
- =
- =
Space variants are used in classes like .p_md for padding, margin, other forms of spacing like gap, positioning,
dimensions, etc.
Distance variables #
- =
- =
- =
- =
- =
Distance variants have classes like .width_atmost_sm and .width_atleast_md.