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.
| Build | Raw | Gzip |
|---|---|---|
fertig.min.css | 41.1 KB | 9.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.
| Area | What is covered |
|---|---|
| Content | Typography, lists, code and quotations |
| Controls | Forms, compound fields, input groups, one-time codes, switches and tabs |
| Display | Surfaces, cards, items, empty states, tables, badges and loading states |
| Navigation | Breadcrumbs, menus, listboxes and segmented controls |
| Feedback | Tones, 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.
| Class | Does |
|---|---|
.row | Flex row, wrapping, gapped |
.grid | Auto-fit columns, min 11rem |
.card | Raised panel with optional header/footer |
.badge | Small pill label |
.muted | Secondary text colour |
.center | Centre text |
.wide | Widen 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.
| Token | Default | Controls |
|---|---|---|
--fertig-ac | blue-700 / blue-300 | Accent: links, focus, filled buttons |
--fertig-on-ac | #f7f8fb / #07071c | Off-white or ink text sitting on the accent |
--fertig-w | 72rem | Toolbar content width |
--fertig-g | clamp(1.15rem, 4vw, 1.9rem) | Responsive page gutter |
--fertig-r | 10px | Control 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-rp | calc(var(--fertig-r) * 1000) | Pill radius: badges, switches, meters and joined ends |
--fertig-f | system-ui… | Text font |
--fertig-fm | ui-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
| Token | Controls |
|---|---|
--fertig-bg | Page ground |
--fertig-el | Content and field surfaces |
--fertig-face | Controls, panels and code |
--fertig-fg / --fertig-mut | Graphite/off-white text and secondary text; never absolute black/white on screen |
--fertig-bd | Hairline colour |
--fertig-tb | Opaque toolbar fill |
--fertig-up / --fertig-up2 / --fertig-dn | Elevation scale |
--fertig-a1 / --fertig-a2 | Shadow alphas, per theme (@property registrations, so they carry the prefix — a registration is global) |
--fertig-rw | Large-surface radius: cards, dialogs, menus — calc(var(--fertig-r) * 1.6) |
--fertig-rs | Small radius: inline code and chips — calc(var(--fertig-r) * .6) |
--fertig-nw | The 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.
| Pair | Light | Dark |
|---|---|---|
| Body text on content surface | 17.16 | 15.96 |
| Muted text on content surface | 7.06 | 7.45 |
| Links on content surface | 6.85 | 9.91 |
| Muted on controls | 6.07 | 6.63 |
| Muted on page ground | 6.53 | 8.07 |
| Text on accent | 6.16 | 11.22 |
| Tones (ok / warn / err) | 6.03–7.31 | 9.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-invalidafter 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
| Engine | Minimum | Set by |
|---|---|---|
| Chrome / Edge | 123 | light-dark() (123) |
| Safari | 17.5 | light-dark(), @starting-style, text-wrap: balance (17.5) |
| Firefox | 129 | @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:
| Component | Chrome | Safari | Firefox | Because |
|---|---|---|---|---|
| Type, tables, cards, badges, avatars, skeletons, tones, layout utilities | 123 | 17.5 | 129 | floor only |
| Forms, buttons, switch, segmented control, tabs | 123 | 17.5 | 129 | floor only — :dir() is 120 / 16.4 / 49 |
.card as a query container | 123 | 17.5 | 129 | container queries: 105 / 16 / 110 |
<dialog>, drawer, sheet | 123 | 17.5 | 129 | dialog: 37 / 15.4 / 98 |
[popover] menu, tooltip, toast | 123 | 17.5 | 129 | popover: 114 / 17 / 125 |
| Carousel | 123 | 17.5 | 129 | scroll snapping: 69 / 11 / 99; overscroll-behavior-x (144 / 16 / 150) is a nicety |
| Sticky-sidebar app layout | 123 | 17.5 | 129 | media 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:
| Feature | Chromium | WebKit | Firefox |
|---|---|---|---|
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.
| Feature | Ch | Sa | Ff | Buys | Without it |
|---|---|---|---|---|---|
| Anchor positioning ( anchor-name, position-area) | 129 | 26 | 147 | [popover] menus sit under their button | Menu centres on screen |
| Relative colour ( oklch(from …)) | 122 | 18 | 128 | 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 | 125 | 26.2 | 147 | Menus are never narrower than the button that opened them, and hide when it scrolls away | Fixed minimum width |
field-sizing: content | 123 | 26.2 | 152 | Textareas grow with their content | Fixed height, drag to resize |
::details-content | 131 | 18.4 | 143 | 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() | 147 | 26 | 146 | 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 | 133 | 18.2 | 154 | Headings sit on their cap height, optically not metrically | The font’s built-in half-leading stays |
appearance: base-select | 135 | 27 | — | The <select> dropdown is styled like the
rest of the sheet instead of OS chrome | Native dropdown, CSS-drawn chevron |
::spelling-error | 121 | 17.4 | — | Spellcheck squiggles follow the palette | Platform squiggles |
text-wrap: pretty | 117 | 26 | — | 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 stopprefers-contrast: more— hairlines and secondary text darkenforced-colors— shadows drop and borders switch to system colours for Windows High Contrastpointer: coarse— controls grow to a 44px minimum target, per WCAG 2.5.8env(safe-area-inset-*)— the sticky toolbar clears the notchupdate: fast— e-ink and other slow displays never enter a transition in the first placeinverted-colors— images are re-inverted so the OS does not invert them twicecolor-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
0andOnever 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.