Skip to content
fm design system
Theme
Scheme
Density

fm design system

Colour, typography, layout and UI components for small web tools. One stylesheet, no build step.

This page is the reference and the demo at once: every example below is live markup using the real components, not a screenshot. It documents the full vocabulary of design-system.css, plus the two ES modules for charts and search.

<link rel="stylesheet" href="https://design-system.flomotlik.me/design-system.css">

Colour

Colours are named by role, never by hue. There is no --fm-color-blue. That is what lets a consuming tool re-theme the system by overriding a dozen values and nothing else — and it is why the names below say what a colour is for rather than what it looks like.

Every ratio shown is measured, not estimated. If you change a value, re-measure it and update the number in the source.

Brand roles

The five brand roles. --fm-color-primary and --fm-color-accent carry white text, so they need at least 4.5:1 against white. --fm-color-secondary is a surface that carries dark text and must never be used as a text colour itself.

--fm-color-primary #1f4a6d Primary surface and action. Carries white text.9.30:1 on white
--fm-color-primary-strong #14324a Deepened variant for opacity stacking — stays AA at 80%.13.25:1 on white
--fm-color-secondary #8fb4cc Secondary surface. Carries dark text, never used as text itself.7.20:1 with ink
--fm-color-accent #9a4a1c Accent surface (badges). Carries white text.6.23:1 on white
--fm-color-highlight #ffd84d NOT decorative — the text colour of high-contrast mode.11.42:1 on ink

Neutrals

--fm-color-ink #1f2328 Body text on light ground.15.80:1 on white
--fm-color-white #ffffff Base surface.

Semantic roles

Components reference these, not the brand tokens directly, so the brand stays changeable in one place.

TokenResolves toUse
--fm-color-text--fm-color-inkBody text
--fm-color-surface--fm-color-whiteBase surface
--fm-color-on-primary--fm-color-whiteText on the primary surface
--fm-color-on-secondary--fm-color-inkText on the secondary surface

Status colours — not themeable

These three are fixed on purpose and are the one part of the palette that carries meaning rather than identity. Green means ok and red means error in every theme and every organisation. Re-tinting them to match a brand destroys the signal.

--fm-web-status-success #3f7d55 Success. Green in every theme.
--fm-web-status-warn #b78a1c Warning. Yellow in every theme.
--fm-web-status-error #b3401e Error. Red in every theme.

The one deliberate exception is the info variant, which draws its fill and border from the primary tint and therefore does follow your brand.

Web layer

A desaturated reading palette that sits alongside the brand layer, for data tools with long reading stretches. Components use this layer. It measures against --fm-web-text, not against ink — a ratio without the text tone it was measured against is worthless.

--fm-web-bg #f4f6f8 Page ground for data tools.
--fm-web-surface #ffffff Card and panel surface.
--fm-web-surface-sunk #f8fafc Recessed surface — tag backgrounds, filter bars.
--fm-web-hairline #dfe4ea Borders and dividers. No text sits on it.
--fm-web-text #22262b Primary reading text.14.05:1 on web-bg
--fm-web-text-soft #5a616b Secondary text, sublines.5.77:1 on web-bg
--fm-web-text-mute #69707a Quietest text layer. Still AA.4.62:1 on web-bg
--fm-web-clay-text #9c5a38 Warm accent text.5.34:1 on white
--fm-web-primary-deep #245a80 Triple duty: surface with white text, text on light, text on a 14% mix.7.37:1 on white
--fm-web-primary #5b8cad Borders, accent rules, hover fills. No text on it.
--fm-web-primary-tint #e4edf4 Soft fill — callouts, hero, hover, zebra.12.84:1 with web-text
--fm-web-highlight #e8c44a Desaturated highlight accent for the screen layer.

Chart palette

Eight categorical tones, low saturation, in the same order as PALETTE in fm-charts.js.

The constraint, and the reason the values look arbitrary: no two entries share both a close hue and a close luminance. That keeps them apart for a red-green colour-blind reader and in greyscale print. Two shades of the same brand colour side by side in a stacked bar is a problem, not brand consistency.

--fm-web-chart-1 #2f6f96 Blue — primary series
--fm-web-chart-2 #c08a2e Ochre
--fm-web-chart-3 #4e8f6d Green
--fm-web-chart-4 #a8503c Rust
--fm-web-chart-5 #6d5b9e Violet
--fm-web-chart-6 #4fb0ad Light teal
--fm-web-chart-7 #6b4a2e Brown
--fm-web-chart-8 #7d838c Slate grey

ECharts itself is loaded by you — the design system bundles no third-party library. The static exports work without a DOM; the functions read the tokens at call time and follow a theme override.

import { palette, ink, font, tip, legend, grid } from 'https://design-system.flomotlik.me/fm-charts.js'; chart.setOption({ color: palette(), // follows --fm-web-chart-1..8 textStyle: { color: ink().text, fontFamily: font() }, tooltip: tip(), legend: legend(), grid: grid(), });

Typography

Five type tokens, a modular size scale (base 1rem, ratio 1.25) and two line-height multipliers. Inter carries body and UI, Spectral the editorial emphasis, JetBrains Mono code and figures. Caveat exists for marginalia only and must never be used for body text.

Families

TokenFamilyUse
--fm-font-headlineInterHeadlines and UI labels
--fm-font-copyInterBody text and UI
--fm-font-emphasisSpectralEditorial emphasis (.fm-emphasis)
--fm-font-monoJetBrains MonoCode, figures, captions
--fm-font-handCaveatMarginalia only — never body text

Type classes

Each class sets family, size, line height and text colour — never a background.

A headline carries the page

A subline sits under it and adds one sentence of context.

Body text carries the detail. It uses the wider copy line height of 1.3 so longer paragraphs stay readable across several lines.

Emphasis sets a line in the serif face, for a pulled-out statement.

Inline atoms: highlight, underline and a search hit.

<h2 class="fm-headline">A headline carries the page</h2> <p class="fm-subline">A subline sits under it …</p> <p class="fm-prose">Body text carries the detail …</p> <p class="fm-emphasis">Emphasis sets a line in the serif face …</p>

Size scale

--fm-text-h1 · 2.441remHeadline 1
--fm-text-h2 · 1.953remHeadline 2
--fm-text-h3 · 1.563remHeadline 3
--fm-text-subline · 1.25remSubline
--fm-text-copy · 1rem · 16pxBody text
--fm-text-small · 0.8remSmall text
--fm-text-micro · 0.6875rem · 11pxMicro — dense caption context only

The micro tier is for dense caption context — sub-labels, table footers, meta tags. Not for reading text.

Line heights

Two multipliers, applied unchanged as line-height.

Tight headline leading
across several lines
for compact titles

Wide copy leading
across several lines
for readable paragraphs

Layout

Layout classes, six spacing tokens, the component metrics and two documentation-only breakpoints.

Layout classes

ClassDoes
.fm-containerCentred content column, capped at --fm-container-max
.fm-sectionVertical section rhythm
.fm-grid, --2, --3Two- and three-column grids that collapse on narrow viewports
.fm-section-headSection heading block with a rule
Column 1
Column 2
Column 3
<div class="fm-grid fm-grid--3"> … </div>

Spacing

Six tokens, base 0.25rem. Use them rather than hard-coded values — this is the layout rhythm, and it is not part of the brand.

--fm-space-1 · 0.25rem
--fm-space-2 · 0.5rem
--fm-space-3 · 1rem
--fm-space-4 · 1.5rem
--fm-space-5 · 2rem
--fm-space-6 · 3rem

Component metrics

TokenValueUse
--fm-radius-sm0.25remSmall brand radius
--fm-radius-md0.5remBrand radius
--fm-web-radius-control6pxInputs, buttons, controls
--fm-web-radius-card10pxCards, panels, modal
--fm-web-radius-mini4pxLozenges, inline tags, chips
--fm-border-width2pxStandard stroke, e.g. the secondary button outline
--fm-web-input-min-h44pxWCAG touch-target floor on inputs and controls
--fm-container-max72remContent column cap

Breakpoints

Two tokens, documentation only: --fm-breakpoint-sm (36rem) and --fm-breakpoint-md (48rem). A @media condition cannot read var(), so the values are repeated literally in the source.

Header & navigation

The page header above IS the live example. A white brand bar with a primary accent rule at its lower edge; the navigation wraps on narrow viewports instead of collapsing into a hamburger.

No logo file ships with the design system. The wordmark alone is the honest default — a stretched or recoloured logo never looks deliberate. Put your own file in the fm-header__logo slot when you have one.

<header class="fm-header"> <div class="fm-header__inner"> <a class="fm-header__brand" href="/"> <img class="fm-header__logo" src="logo.svg" alt="Organisation"> <span class="fm-header__wordmark">Tool name</span> </a> <nav class="fm-header__nav" aria-label="Main navigation"> <ul class="fm-header__nav-list"> <li><a class="fm-header__nav-current" href="#">Start</a></li> </ul> </nav> </div> </header>

Modifiers

ClassDoes
.fm-header--darkSolid primary bar with white text instead of the white bar
.fm-header--compactKeeps everything on one line for search-first layouts — no wrap, tighter padding, truncation allowed
.fm-header--fixedFixed to the top. Add a matching scroll-padding-top on <html>
.fm-header__subbrandA second line under the wordmark, e.g. "A project by …"
.fm-header__supportContact link in the nav zone; on narrow viewports only the icon remains

Tool navigation

Horizontal section navigation, meant to sit directly under the page headline — the quiet alternative to a vertical side menu. Tabs sit on a hairline baseline; the active tab carries a deep primary marker. Optional group labels structure longer navigations, and planned steps stay visible as disabled.

<nav class="fm-toolnav" aria-label="Tool navigation"> <div class="fm-toolnav__group"> <span class="fm-toolnav__label">Prepare</span> <a class="fm-toolnav__item fm-toolnav__item--active" href="#" aria-current="page">Overview</a> </div> </nav>

Buttons, cards and badges

Buttons

A button always carries a variant modifier — --primary or --secondary. Never the bare class.

:hover darkens the primary button and fills the secondary one; :focus-visible shows the focus ring.

<a class="fm-btn fm-btn--primary" href="#">Primary action</a> <a class="fm-btn fm-btn--secondary" href="#">Secondary action</a>

Cards

A card holds one statement. Both variants set their own text colour so the contrast is right on their surface. A variant modifier is mandatory.

Primary card

On the primary surface with white text.

Secondary card

On the secondary surface with dark text.

<article class="fm-card fm-card--primary"> <h3 class="fm-card__title">Primary card</h3> <p class="fm-card__body">…</p> </article>

Badges

A small angled flash for one short phrase. Two variants: accent and highlight.

New Beta
<span class="fm-badge fm-badge--accent">New</span>

Content components

Callout

Five semantic variants. The optional __lead slot replaces a hand-written <strong> prefix.

<aside class="fm-callout fm-callout--warn"> <p class="fm-callout__lead">Note</p> <p class="fm-prose">…</p> </aside>

Tags

Two families in one: structural (neutral, info, required, risk) and status (ok/success, warn, error/danger).

Neutral Info Required Risk Ok Warn Error
<span class="fm-tag fm-tag--ok">Ok</span>

Hero and section head

A hero strip

One sentence of framing, on the tinted surface.

A section head

Skip link

The first interactive element on this page. Tab from the very top to reveal it — it is visually hidden until focused.

<a class="fm-skiplink" href="#main">Skip to content</a>

Data components

Panels frame a block of content; metric cards show a single figure with a semantic accent rule.

69 700 Revenue €
54 200 Cost €
15 500 Net €

A panel

3 items

Panels frame content blocks and pair with the table body variant.

A quiet note at the foot of the panel.

<div class="fm-metric-card fm-metric-card--net"> <span class="fm-metric-card__num">15 500</span> <span class="fm-metric-card__label">Net €</span> </div>

Controls

A tab bar with folder-tab optics and a segment switcher for toggle groups. Attach your own listeners; they toggle .is-active between the buttons.

Tab bar

The panel body sits under the bar and shares its surface.

<div class="fm-tabbar" role="tablist"> <button class="fm-tab is-active" role="tab" aria-selected="true">Overview</button> <button class="fm-tab" role="tab" aria-selected="false">Detail</button> </div> <div class="fm-tab-panel" role="tabpanel">…</div>

Switcher

View
<div class="fm-switcher"> <span class="fm-switcher__label">View</span> <button type="button" class="fm-switch-btn is-active">Table</button> <button type="button" class="fm-switch-btn">Chart</button> </div>

Search trigger

The button that opens the modal search, with its keyboard shortcut shown inline.

Standalone nav

Forms

Input building blocks for data tools. Behaviour — validation, submit — is yours; this supplies optics plus states. Native elements take the class directly; the only wrapper is the optional .fm-field.

Every control carries a 44px touch-target floor (WCAG 2.5.5 / 2.5.8). A placeholder is not a label — it disappears the moment someone types.

A hint sits under the field, never inside it.
Enter a valid email address.
<div class="fm-field"> <label class="fm-field__label" for="email">Email address</label> <input class="fm-input" id="email" type="email"> <span class="fm-field__hint">A hint sits under the field.</span> </div>

Table

Sticky head, optional zebra, compact and dense variants, a numeric column helper and a sticky-first-column variant for wide comparisons. Optics only: sorting and selection are yours, toggling .is-active and .is-selected.

Half your audience opens the tool on a phone. A twelve-column table is unusable there however well it is styled — wrap it in .fm-table-scroll, or show fewer columns.

Item Owner Amount €
Cargo-bike sharingTransport42 000
School gardenEducation18 500
Energy adviceFacilities9 200
Total69 700
<div class="fm-table-scroll"> <table class="fm-table fm-table--zebra"> <thead><tr><th>Item</th><th class="fm-table__num">Amount €</th></tr></thead> <tbody>…</tbody> </table> </div>
ModifierDoes
--zebraAlternating row backgrounds
--compactReduced cell padding for dense lists
--denseMicro type for data-heavy tables
--sticky-colSticky first column; needs .fm-table-scroll

Dropzone

File-drop zone with idle, hover, dragover and error states. Optics only — wire up the listeners and toggle .is-dragover and .is-error.

Drop a file here or

Toast

The toaster is the fixed container, the toast a single message. Four severity variants. The slide-in respects prefers-reduced-motion. Auto-dismiss is yours.

Toolbar

A sticky action bar for bulk actions. Sticks to the bottom of the viewport by default; --top flips it.

3 selected

Themes

A theme is a flat set of token values and nothing else. It never touches a component — which is why switching one at the top of this page re-renders everything below without a single component rule being involved.

<html data-fm-theme="report" data-fm-density="dense">
ThemeForCharacter
tool (default)interactive tools, dashboards, viewersscreen-first, neutral ground, ink-blue primary
reportreports and auditsnear-monochrome, tighter by default, made to be printed
notebookbriefs and noteswarm paper, editorial ink

Three independent axes

AttributeValuesDoes
data-fm-themetool · report · notebookThe palette and its dark counterpart
data-fm-schemelight · dark · systemLight unless you say otherwise. system opts in to prefers-color-scheme
data-fm-densitynormal · compact · denseTightens the spacing scale, so it propagates to every component at once

They compose: a theme carries a preferred density, and an explicit data-fm-density still wins over it.

Dark is never automatic

The default is light, including when the viewer's system is set to dark. That is deliberate, and it is the one place this system overrides a stated system preference.

These are tools that exist to show numbers — tables, figures, charts. On a dark ground thin numeric glyphs and chart fills lose contrast, and light-on-dark text blooms for astigmatic readers. That is exactly the dense material people most need to read accurately, so defaulting a data tool into dark trades legibility for a preference nobody expressed in the tool itself.

Dark is still there, measured and complete, and every theme carries its own dark palette rather than a filter applied on top. It is opted into: data-fm-scheme="dark" for always dark, or data-fm-scheme="system" to follow prefers-color-scheme after all. If your tool is a reading surface rather than a data surface, that is a reasonable thing to switch on.

What a theme may not do

  • Status colours are not part of a theme. --fm-web-status-* and the warn/error/success variants are declared once and keep their meaning everywhere. Switch through all three themes above and watch the callouts: only info moves, because it follows the brand on purpose.
  • High contrast is not a theme. .fm-mode-hc resolves to a fixed near-black and amber pair through --fm-hc-surface / --fm-hc-text, deliberately outside the theme system. It is an accessibility mode, so it has to land in the same place under every theme and scheme.
  • Print is always light. Printing resets the tokens to the report palette whatever is on screen, and sets an A4 page box. A dark theme printing dark wastes toner and usually comes out unreadable.

Scoping a theme to part of a page

The selectors are plain attribute selectors, not :root-only, so a theme can be applied to a subtree — useful for a preview pane or a side-by-side comparison.

This box is data-fm-theme="notebook" while the page around it is not.

Primary Ok

Your own theme

If none of the three fits, do not add a fourth here — override the tokens in your own stylesheet, as described in Theming. A consuming tool's unlayered :root block beats every theme above, because these are declared inside @layer base.

High contrast

Put .fm-mode-hc on <body> or any ancestor. Every component has a hand-written override; the palette is ink plus highlight, with accent for errors. The toggle button is yours to build.

High-contrast mode, scoped to this box.

Primary Secondary Ok
<body class="fm-mode-hc"> … </body>

Theming

Link your own stylesheet after the design system and override tokens in a plain :root block. That is enough because the defaults live in @layer rules, and unlayered declarations beat layered ones regardless of document order.

<link rel="stylesheet" href="https://design-system.flomotlik.me/design-system.css"> <link rel="stylesheet" href="local.css">
/* local.css — colours for <organisation>. Source of the values: <style guide, URL, or who supplied them>, <date>. */ :root { --fm-color-primary: #1f4a6d; --fm-color-primary-strong: #14324a; --fm-color-secondary: #8fb4cc; --fm-color-accent: #9a4a1c; --fm-web-primary-deep: #245a80; --fm-web-primary: #5b8cad; --fm-web-primary-tint: #e4edf4; --fm-web-chart-1: #2f6f96; --fm-web-chart-2: #c08a2e; /* … through -8 */ }

Two rules

  • Override tokens, never components. The moment you redefine .fm-btn, the next release breaks your tool silently.
  • Status colours are not themeable. Leave --fm-web-status-* and the warn/error/success variants alone. Green means ok everywhere.

The brand colour is usually too light

This is the common failure, not an edge case. --fm-color-primary and --fm-web-primary-deep carry white text, so they need at least 4.5:1 against white. Many brand colours were chosen for print and logos and fail badly.

Do not change the brand colour — split the roles. Use it as a surface (--fm-color-secondary, --fm-web-primary-tint) and a darkened variant wherever white text sits on it. Write down in the file that the darker value is derived for legibility, not invented, or someone will "correct" it back later.

Check it afterwards

  • Measure contrast, do not eyeball it. White text on the primary button, body text, and anything on a tinted surface. 4.5:1 for normal text, 3:1 for large.
  • Check the chart palette. Eight colours must stay distinguishable from each other, including for the common colour-vision deficiencies.
  • Load the page and look at it. Token overrides are cheap to get almost right and easy to get subtly wrong.