Documentation

Installation, layout, component behavior, design tokens, accessibility and browser support. Visual examples live in the component catalogue.

Install

From npm

npm install fertig

Then import it through your bundler:

@import "fertig";

From a CDN

<link rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/fertig@4/fertig.min.css">

No tooling required at all.

The package ships two files. fertig.css is commented source meant to be read and edited, and fertig.min.css is what you serve.

BuildRawGzip
fertig.min.css41.1 KB9.3 KB

Layout

fertig has no .container. Direct children of <body> are the containers, so page structure is layout.

<body>
  <nav>…</nav>       <!-- full-bleed toolbar, contents on the measure -->
  <header>…</header>  <!-- the content canvas starts here -->
  <main>…</main>
  <footer>…</footer>
</body>

<nav> becomes an opaque sticky toolbar. It spans the viewport while its contents stay on the page column, so the bar reads full-width without the text sliding out to the edges.

Frameworks

One wrapper is fine. React, Vue, Svelte and Next render into a mount node, so the shell matches body’s children or the children of a single #root, #app, #__next or [data-fertig] wrapper — no configuration:

<body>
  <div id="root">
    <nav>…</nav>
    <main>…</main>
  </div>
</body>

Deeper than that, or a mount node named something else, and you re-point the shell yourself — it is the only place body > appears:

#shell > * { padding-inline: var(--fertig-g) }
#shell > header, #shell > main, #shell > footer { background: var(--fertig-bg) }

Width

Body-level containers have no maximum width. Add a .max-w-* utility when a page needs a specific measure, or .max-w-prose to cap long-form copy at 38rem — about seventy characters. The --fertig-g page gutter responds from 1.15rem on narrow phones to 1.9rem on larger screens. --fertig-w controls the toolbar content column.

Elements and components

This page is the reference for behavior, tokens, utilities and accessibility. Live previews and copyable markup have one home in the component catalogue, so examples do not drift between pages.

AreaWhat is covered
ContentTypography, lists, code and quotations
ControlsForms, compound fields, input groups, one-time codes, switches and tabs
DisplaySurfaces, cards, items, empty states, tables, badges and loading states
NavigationBreadcrumbs, menus, listboxes and segmented controls
FeedbackTones, tooltips, dialogs and disclosures

Browse the component catalogue

Tabs need a controller

CSS can style an ARIA tab widget, but it cannot update aria-selected, switch the associated panel or manage arrow-key focus. Use a small controller for those behaviors:

document.querySelectorAll('[role=tablist]').forEach(list => {
  const tabs = [...list.querySelectorAll('[role=tab]')];
  const show = tab => tabs.forEach(t => {
    const on = t === tab;
    t.setAttribute('aria-selected', on);
    t.tabIndex = on ? 0 : -1;
    document.getElementById(t.getAttribute('aria-controls')).hidden = !on;
  });
  tabs.forEach((t, i) => {
    t.addEventListener('click', () => show(t));
    t.addEventListener('keydown', e => {
      let next;
      if (e.key === 'ArrowRight') next = tabs[(i + 1) % tabs.length];
      if (e.key === 'ArrowLeft') next = tabs[(i - 1 + tabs.length) % tabs.length];
      if (e.key === 'Home') next = tabs[0];
      if (e.key === 'End') next = tabs.at(-1);
      if (!next) return;
      e.preventDefault();
      show(next);
      next.focus();
    });
  });
  show(tabs.find(t => t.getAttribute('aria-selected') === 'true') || tabs[0]);
});

Classes & utilities

All optional. If you never type a class, nothing breaks.

ClassDoes
.rowFlex row, wrapping, gapped
.gridAuto-fit columns, min 11rem
.cardRaised panel with optional header/footer
.badgeSmall pill label
.mutedSecondary text colour
.centerCentre text
.wideWiden a container to 80rem

Plus .primary for a filled button, which type="submit" already gets.

Layout utilities

A compact set of predictable layout helpers covers common flex, grid, alignment, gap, margin and visibility needs.

.flex  .flex-col  .flex-wrap  .flex-1
.grid-cols-2  .grid-cols-3  .grid-cols-4
.items-start  .items-center  .items-end
.justify-start  .justify-center  .justify-end  .justify-between
.gap-0  .gap-1  .gap-2  .gap-3  .gap-4  .gap-6
.mx-auto  .ms-auto  .me-auto  .w-full  .hidden

Only the layout handful: no colour, type or sizing scale, and no responsive variants, because those want a build step and this file does not have one. The gap steps are 0, .25, .5, .75, 1 and 1.5rem. The margins are flow-relative — ms and me, never ml/mr — so a toolbar still lands correctly in Persian or Arabic. .grid is the auto-fit grid above; adding .grid-cols-3 pins it to three columns.

<p class="row justify-end"><button>Save</button></p>
<div class="grid grid-cols-3 gap-4">…</div>

Tokens

The core tokens cover most theming. Set them in your own :root after the sheet.

TokenDefaultControls
--fertig-acblue-700 / blue-300Accent: links, focus, filled buttons
--fertig-on-ac#f7f8fb / #07071cOff-white or ink text sitting on the accent
--fertig-w72remToolbar content width
--fertig-gclamp(1.15rem, 4vw, 1.9rem)Responsive page gutter
--fertig-r10pxControl radius: fields, buttons, tabs, menu rows. The rest of the scale is calc()ed off it, so setting it on :root rescales the sheet
--fertig-rpcalc(var(--fertig-r) * 1000)Pill radius: badges, switches, meters and joined ends
--fertig-fsystem-ui…Text font
--fertig-fmui-monospace…Code font

Named accents

Seven accents ship as a mapping table over the ramps. Put data-accent on any element and everything inside it — links, focus rings, filled buttons, the caret, the selection — follows. It is scoped, so one section can differ from the rest of the page.

<section data-accent="green">
  <button class="primary">Save</button>
</section>

The values are blue (the default), cyan, green, amber, red, violet and gray. Each pair is measured: the light tone clears WCAG AA as link text on the content surface, the dark tone clears it on the dark ground. For anything else, set --fertig-ac yourself — that is all these rules do.

Try these live on the homepage → Move the accent, the radius and the measure and watch real components restyle, with the contrast measured as you go.

The rest

TokenControls
--fertig-bgPage ground
--fertig-elContent and field surfaces
--fertig-faceControls, panels and code
--fertig-fg / --fertig-mutGraphite/off-white text and secondary text; never absolute black/white on screen
--fertig-bdHairline colour
--fertig-tbOpaque toolbar fill
--fertig-up / --fertig-up2 / --fertig-dnElevation scale
--fertig-a1 / --fertig-a2Shadow alphas, per theme (@property registrations, so they carry the prefix — a registration is global)
--fertig-rwLarge-surface radius: cards, dialogs, menus — calc(var(--fertig-r) * 1.6)
--fertig-rsSmall radius: inline code and chips — calc(var(--fertig-r) * .6)
--fertig-nwThe column the toolbar's contents line up with (follows --fertig-w; <nav class="wide"> re-points it at 80rem)

Recipes

Your brand accent

:root {
  --fertig-ac:    light-dark(#c2410c, #fb923c);
  --fertig-on-ac: light-dark(#f7f8fb, #1a0b02);
}

Sharp corners

:root { --fertig-r: 0 }   /* squares the pills too */

Flat, no depth

:root { --fertig-up: none; --fertig-up2: none; --fertig-dn: none }

A different font

:root {
  --fertig-f:  "Inter", system-ui, sans-serif;
  --fertig-fm: "Berkeley Mono", ui-monospace, monospace;
}

Narrower toolbar column

:root { --fertig-w: 52rem }

Force a theme

<html data-theme="dark">
<!-- or "light"; omit to follow the OS -->

Right-to-left

Set the direction and the sheet mirrors. There is nothing to configure and no second file to load:

<html lang="fa" dir="rtl">

Every inline-direction rule is flow-relative — padding-inline-start, border-inline-start, margin-inline-end, text-align: start, border-start-end-radius — so list markers, blockquote rules, table gutters, breadcrumb separators, the segmented control's rounded ends and the toolbar's trailing control all move to the correct side.

Two things cannot be said logically: translate, which moves the switch knob, and the fallback <select> chevron's background position. Both carry a :dir(rtl) rule. The layout utilities follow the same discipline — .ms-auto and .me-auto exist, .ml-auto deliberately does not.

Verified by rendering a Persian page in both directions and measuring, not by reading the source.

Accessibility

Every foreground and background pair clears WCAG 2.1 AA in both themes, calculated from the computed token values rather than assumed from the palette.

PairLightDark
Body text on content surface17.1615.96
Muted text on content surface7.067.45
Links on content surface6.859.91
Muted on controls6.076.63
Muted on page ground6.538.07
Text on accent6.1611.22
Tones (ok / warn / err)6.03–7.319.51–11.22
  • Motion sits behind prefers-reduced-motion
  • Focus is visible via :focus-visible, never removed
  • Components read the same ARIA attributes screen readers do
  • Errors use explicit aria-invalid, or :user-invalid after native validation runs

Browser support

The policy is a two-year window on browsers, not on CSS: fertig runs in anything released since August 2024. The CSS itself is older and duller than that — @layer has been cross-engine since 2022, oklch() and color-mix() since 2023, light-dark() since May 2024. The newest thing the sheet requires is @starting-style, cross-engine since August 2024 — nothing it needs shipped inside the last two years. Floor: Chrome 123+, Safari 17.5+, Firefox 129+. There is no polyfill, no fallback build and no vendor prefix, with three exceptions that no engine has replaced yet: -webkit-text-size-adjust, the -webkit-text-fill-color autofill repaint, and the progress pseudo-elements.

One thing is carried for browsers below the floor. Every colour token is a light-dark(), and in an engine without it those tokens are invalid at computed-value time — backgrounds go transparent and colour is inherited, so the page lands unreadable rather than plain. A @supports not (color: light-dark(…)) block at the end of the layer restates the palette in flat sRGB, in both schemes. Below the floor you get plain, perfectly readable HTML.

Where the floor comes from

EngineMinimumSet by
Chrome / Edge123light-dark() (123)
Safari17.5light-dark(), @starting-style, text-wrap: balance (17.5)
Firefox129@starting-style, transition-behavior (129)

Two cosmetic properties sit above that floor on purpose and are not gated: scrollbar-color and accent-color only reached Safari in 26.2. An engine without them paints its own scrollbar and its own checkbox tick, so there is nothing to fall back to and nothing to break.

Which component needs which version

No component asks for more than the floor. Anything a component would like to have, it asks for through @supports, so this table is short on purpose:

ComponentChromeSafariFirefoxBecause
Type, tables, cards, badges, avatars, skeletons, tones, layout utilities 12317.5129floor only
Forms, buttons, switch, segmented control, tabs 12317.5129floor only — :dir() is 120 / 16.4 / 49
.card as a query container 12317.5129container queries: 105 / 16 / 110
<dialog>, drawer, sheet 12317.5129dialog: 37 / 15.4 / 98
[popover] menu, tooltip, toast 12317.5129popover: 114 / 17 / 125
Carousel 12317.5129scroll snapping: 69 / 11 / 99; overscroll-behavior-x (144 / 16 / 150) is a nicety
Sticky-sidebar app layout 12317.5129media range syntax: 104 / 16.4 / 102

Tested, not assumed

Every release is loaded in Chromium, WebKit and Firefox, every button on every page is clicked, and each page is checked for overflow, broken anchors, duplicate ids, unlabelled inputs and console errors. This is what the current three engines — Chrome 152, Safari 26.6, Firefox 154 — report today:

FeatureChromiumWebKitFirefox
light-dark(), color-mix(), :has(), oklch()
@layer, @starting-style, container queries
Anchor positioning, position-area
contrast-color(), field-sizing, ::details-content
command/commandfor, closedby, popover
text-box: trim-both
appearance: base-select
Scroll-driven animations
interpolate-size

A dash means that engine misses a flourish, not that anything breaks: every one of those sits behind @supports.

Cascade layers

The sheet declares two layers, @layer fertig, fertig-a11y. Anything you write outside a layer beats both regardless of specificity, so overriding a rule never turns into a specificity fight and never needs !important:

/* this wins over everything in the sheet */
button { background: hotpink }

Progressive enhancement

Everything newer than that floor is strictly additive — if the browser has it the page is better, and if not nothing breaks. Most of it sits behind @supports; the last two rows do not, because an engine that lacks them simply ignores the declaration. The versions are the first release of each engine to ship the feature; a dash means it has not shipped there yet.

FeatureChSaFfBuysWithout it
Anchor positioning
(anchor-name, position-area)
12926147 [popover] menus sit under their button Menu centres on screen
Relative colour
(oklch(from …))
12218128 The filled-button hover is lightened in OKLCH, holding hue and chroma exactly color-mix with white — same lightness, a shade flatter
anchor-size() +
position-visibility
12526.2147 Menus are never narrower than the button that opened them, and hide when it scrolls away Fixed minimum width
field-sizing: content 12326.2152 Textareas grow with their content Fixed height, drag to resize
::details-content 13118.4143 Disclosures animate open and closed They snap, as they always have
interpolate-size 129 Lets that animation run to auto height As above
contrast-color() 14726146 Text on the accent is chosen automatically, so overriding --fertig-ac cannot silently fail contrast Falls back to the hand-picked --fertig-on-ac
text-box: trim-both 13318.2154 Headings sit on their cap height, optically not metrically The font’s built-in half-leading stays
appearance: base-select 13527 The <select> dropdown is styled like the rest of the sheet instead of OS chrome Native dropdown, CSS-drawn chevron
::spelling-error 12117.4 Spellcheck squiggles follow the palette Platform squiggles
text-wrap: pretty 11726 No orphans or bad rags in body copy Ordinary line breaking
<input switch> 17.4 Safari’s native switch is styled identically to role="switch" Renders as a checkbox

Two things people expect to find in that table are not in it, because they are part of the floor rather than enhancements on top of it: @property types the shadow alphas so elevation can transition, and container-type on .card makes every card a query container. Both of the latter are cross-engine and used unguarded; relative colour is in the table above, because the sheet takes its shadow alphas with color-mix instead — same pixels, and three years older.

It answers to user and device preferences

  • prefers-reduced-motion — all transitions and the disclosure animation stop
  • prefers-contrast: more — hairlines and secondary text darken
  • forced-colors — shadows drop and borders switch to system colours for Windows High Contrast
  • pointer: coarse — controls grow to a 44px minimum target, per WCAG 2.5.8
  • env(safe-area-inset-*) — the sticky toolbar clears the notch
  • update: fast — e-ink and other slow displays never enter a transition in the first place
  • inverted-colors — images are re-inverted so the OS does not invert them twice
  • color-gamut: p3 — a cleaner accent on wide-gamut displays

Content it cannot predict

A classless sheet styles markup it has never seen, so it has to survive the awkward cases:

  • A pasted URL wraps instead of widening the page (overflow-wrap)
  • Tables scroll themselves below 40rem rather than pushing the layout sideways
  • Autofilled fields are repainted, so the browser’s yellow never appears — least of all in dark mode
  • Required fields are marked, read-only fields look inert (:required, :read-only)
  • Numerals are slashed and tabular, so 0 and O never trade places
  • Print gets real page margins, no stranded lines, and URLs only for external links

The demo pages additionally use command / commandfor and closedby to drive dialogs with no JavaScript at all, plus cross-document @view-transition.