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.