Card title
Plain article, no classes.
Write plain HTML and it's already styled, using only modern CSS: nesting, cascade layers, light-dark(), squircles and glass.
!important (cascade layers)One line from a CDN. Nothing to install, nothing to build:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leicht@0.2/dist/leicht.min.css">
The six skins are a second, optional file (1.6 kb) — add it only if you want them:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leicht@0.2/dist/leicht-themes.min.css">
Or install from npm and let your bundler take it:
npm i leicht
@import "leicht/min";
@import "leicht/themes/min"; /* optional skins */
With no bundler, link node_modules/leicht/dist/leicht.min.css directly — or download that file and self-host it. There is no build step and no JavaScript either way.
Then write ordinary HTML:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="leicht.min.css">
</head>
<body>
<header><nav>…</nav></header>
<main>…</main>
</body>
</html>
A bare .row splits its children evenly.
Add any .col-1….col-12 and the row switches to twelve tracks. In Chromium, data-span="N" works too.
Wrap fields in <label> — except <select>, which needs a separate label with for, because WebKit won't open a select that its label wraps. Errors show only after the user touches a field (:user-invalid). The textarea grows with its content, and the select opens a glass dropdown where supported.
An <article> is a card; .card does the same on anything. A direct <header> or <footer> bleeds to the edges. Add .glass for the toolbar material, or .liquid for the refracting one — both need something behind them to show, so this row sits on a photo.
Plain article, no classes.
.card.glass: translucent, blurred, with a lit edge.
.card.liquid: refraction, tint and a specular rim.
Standard HTML popovers and invoker commands, with no JavaScript. Menus anchor to their button. Tooltips (popover="hint") align to their button's edge, and their tail follows when they flip sides.
A pill everywhere; a luggage-label shape where border-shape is supported.
new stable deprecated
Opens with a height animation, where supported.
.liquid stacks three layers the way the real material works: a refraction layer that distorts the backdrop, a tint, and a specular rim — all on pseudo-elements, so your markup stays plain. The distortion uses filter on that layer rather than inside backdrop-filter, which may also reach Safari. The dock and pill below are plain elements with .liquid. The controls rewrite the filter live.
| Class | Does |
|---|---|
.text-center | Centre text |
.text-muted / .text-primary | Recolour text (combine with .error / .success) |
.flex / .stack | Wrapping row / vertical stack, gap via --gap |
.full-width | Stretch to 100% |
.glass | Translucent blurred material |
.liquid | Liquid glass: refraction, tint and specular rim |
.alert | Callout; combine with .error / .success |
.group | Join buttons into one segmented bar |
.carousel | Scroll-snap list with ::scroll-marker dots |
.row.divided | Rules drawn in the grid gutters |
[data-flip] | Mirror a directional icon in RTL |
.hide-sm / .hide-lg | Hide below / above 40rem |
.sr-only | Visible to screen readers only |
.container | Centred, max-width, query container |
Each of these keys off the markup that already makes it accessible, so you write the correct HTML and the styling follows. The acceptance test is a full billing console written this way, with no custom CSS at all.
An <aside> as a direct child of <body> turns the page
into a shell: sidebar column plus content, with the glass toolbar and page gutters
unchanged. Give the <aside> a popover attribute and point a
button at it, and below 60rem the same element becomes an off-canvas drawer that the
browser opens, light-dismisses and animates — no JavaScript. --l-aside sets
the column width.
<body>
<aside id="nav" popover>
<nav><ul><li><a href="#" aria-current="page">Overview</a></li></ul></nav>
</aside>
<header>
<nav><button popovertarget="nav" class="ghost hide-lg">☰</button></nav>
</header>
<main>…</main>
</body>
Wrap a <table> in a <figure> — semantic HTML's own
table wrapper. The figure becomes the scrollport, so a wide table scrolls inside its own
frame instead of pushing the page sideways, and <figcaption> becomes the
caption row. Set --l-table-max to cap the height and the
<thead> sticks. A cell with .num or a
<data value> child aligns to the end.
| Invoice | Customer | Status | Amount |
|---|---|---|---|
INV-2417 | Acme Constructions GmbH | Paid | $12,400.00 |
INV-2416 | Blaulicht Medientechnik AG | Open | $3,980.50 |
INV-2410 | Kranzler & Söhne KG | Overdue | $1,120.00 |
A [role=tablist] of buttons, with aria-selected marking the
current one. The ARIA that makes them announce correctly is the hook that styles them.
aria-busy="true" draws a shimmering skeleton — or, on a
<button>, a spinner and a disabled state. Both stop under
prefers-reduced-motion.
Loading the next page of invoices…
An <input> fills its line, so a currency prefix or a unit suffix has
nowhere to sit. .field is the row they share: it carries the border and the
focus ring, and the input inside carries neither.
A <dl> is a two-column grid: totals, metadata, spec sheets.
An <output> as a direct child of <body> becomes a
toast stack pinned to the corner — it is already a live region, so screen readers announce
it. Pagination is a labelled <nav> around an <ol>.
.avatar takes --size; .dot inherits the variant
colour; .sm and .lg set only font-size, because
control padding is em — one declaration resizes the whole control.
--l-space is one knob for the whole UI. Layout spacing derives from it, so
changing it compacts or loosens cards, dialogs, alerts, the sidebar and the toast stack
together — while component interiors stay in em and scale with their own font
size instead.
:root { --l-space: .75rem } /* denser */
:root { --l-space: 1.25rem } /* roomier */
A callout takes .alert and the same variants as buttons. <progress>, <meter> and a checkbox with role="switch" need no classes at all.
Cascade layers mean your CSS always wins. No !important anywhere.
Deployed to production.
Three files could not be read.
Wrap buttons in .group and they become one segmented bar — the outer corners round, the inner ones don't, and it flips correctly in RTL.
A scroll-snap list that gets its own dots — ::scroll-marker, no JavaScript. Swipe or scroll it sideways. Where the pseudo-element isn't supported it stays an ordinary scroller.
Each item snaps to centre.
The dots come from ::scroll-marker.
The current one is :target-current.
No script, no classes on the items.
Add .divided to a .row and the gaps get rules — gap decorations, so nothing is drawn on the outer edges.
Everything is written with logical properties, so <html dir="rtl"> mirrors the whole library — padding, borders, card headers, menu anchoring, the tooltip tail, even the offset shadows in the brutal skin. Set it on <html> and the whole page flips.
Two things logical properties can't decide for you, so the library handles them:
pre stays LTR and inline code is isolated, so a snippet never reorders around Arabic or Hebrew text.<svg data-flip>. An arrow flips; a clock or a logo shouldn't.نفس البنية، اتجاه معاكس. مفتوح
A skin is a block of token overrides in an optional second file (leicht-themes.css, 1.6 kb gzipped for all six). Set <html data-skin="cyber"> — light and dark still work on top.
Every skin restyles the same markup. open
Tokens do the work; the markup never changes.
Three ways in, depending on how much you want to change. Put your overrides in plain CSS — it sits outside leicht's layers, so it always wins and you never need !important.
The most common case: you have a hex value and need the library to match it. Everything else — surfaces, borders, muted text, focus rings, tags — is mixed from it.
:root { --l-primary: #0a66c2 }
Text drawn on that colour picks itself: where contrast-color() is supported, --l-on-primary resolves to black or white automatically. Elsewhere it falls back to the page background.
Re-tints the neutrals along with the accent, which keeps greys in the same family as the brand instead of leaving them cold.
:root { --l-hue: 150 } /* 0 red · 150 green · 257 blue · 300 violet */
Shape, type and depth are tokens too, so a skin is a block of them. This is the entire terminal skin, minus its two typographic flourishes:
[data-skin="terminal"] {
--l-font: "Geist Mono", ui-monospace, monospace;
--l-bg: light-dark(oklch(93.5% 0 0), oklch(21% 0 0));
--l-primary: light-dark(oklch(45% .14 145), oklch(82% .18 145));
--l-radius: 0;
--l-shadow: 3px 3px 0 color-mix(in oklab, var(--l-fg) 20%, #0000);
--l-blur: none; /* no glass */
--l-corner: round; /* opt out of squircles */
}
Changing the hue can't break contrast. OKLCH lightness is perceptual, and the tokens hold it fixed, so the accent measures the same against the page at every hue — 4.86:1 in light mode whether you pick red, yellow, green or blue. That is the whole reason the palette is written in OKLCH rather than hex.
A raw brand colour can. There, lightness is whatever the brand is, and some brands are simply not text colours. Measured against the light background:
--l-primary | on --l-bg | |
|---|---|---|
#111827 | 17.3:1 | fine |
#0a66c2 | 5.5:1 | fine |
#22c55e | 2.2:1 | fails as text |
#ffd400 | 1.4:1 | fails as text |
If your brand colour is light, keep it for fills and give links and small text a darker variant — --l-primary: oklch(from #22c55e 45% c h) keeps the hue and drops the lightness. Text drawn on the accent looks after itself through contrast-color().
Follows the OS by default. Force it with <html data-theme="dark">, and note that a skin rides on top: data-skin and data-theme compose.