Skip to content
Light / dark
Skin

A tiny (~5.6kb) CSS library

Write plain HTML and it's already styled, using only modern CSS: nesting, cascade layers, light-dark(), squircles and glass.

Get started Docs

Features

Getting started

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>

Grid

A bare .row splits its children evenly.

1
2
3

Sized columns

Add any .col-1….col-12 and the row switches to twelve tracks. In Chromium, data-span="N" works too.

.col-8
.col-4
.col-3
.col-9
data-span="5"
data-span="7"

Buttons

Any <button> is styled. Use .button on links. Variants just repoint variables, so they combine.

Link

Forms

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.

Options

Card

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.

Card title

Plain article, no classes.

.card.glass: translucent, blurred, with a lit edge.

.card.liquid: refraction, tint and a specular rim.

Dialog, menu & tooltip

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.

Delete project?

This can't be undone.

I have a real tail

Tag

A pill everywhere; a luggage-label shape where border-shape is supported.

new stable deprecated

Details

What's in the box?

Opens with a height animation, where supported.

Liquid glass playground

.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.

Liquid glass

Utilities

ClassDoes
.text-centerCentre text
.text-muted / .text-primaryRecolour text (combine with .error / .success)
.flex / .stackWrapping row / vertical stack, gap via --gap
.full-widthStretch to 100%
.glassTranslucent blurred material
.liquidLiquid glass: refraction, tint and specular rim
.alertCallout; combine with .error / .success
.groupJoin buttons into one segmented bar
.carouselScroll-snap list with ::scroll-marker dots
.row.dividedRules drawn in the grid gutters
[data-flip]Mirror a directional icon in RTL
.hide-sm / .hide-lgHide below / above 40rem
.sr-onlyVisible to screen readers only
.containerCentred, max-width, query container

An application, still without classes

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.

App shell

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>

Data table

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.

InvoiceCustomerStatusAmount
INV-2417Acme Constructions GmbHPaid$12,400.00
INV-2416Blaulicht Medientechnik AGOpen$3,980.50
INV-2410Kranzler & Söhne KGOverdue$1,120.00
248 invoices · updated 2 minutes ago

Tabs

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.

Loading states

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…

Input adornments

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.

$ USD

Key and value

A <dl> is a two-column grid: totals, metadata, spec sheets.

Subtotal
$12,400.00
VAT (0% — reverse charge)
$0.00
Total
$12,400.00

Toasts and pagination

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>.

Avatars, dots and sizes

.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.

MB AC Paid Draft

Density

--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 */

Alerts, switches & meters

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.

Joined controls

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.

RTL

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:

تذكرة ٢٠٤

نفس البنية، اتجاه معاكس. مفتوح

Skins

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.

Ticket #204

Every skin restyles the same markup. open

Tokens do the work; the markup never changes.

Theming

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.

One brand colour

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.

One hue

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 */

A whole look

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 */
}

Contrast, and which knob is safe

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-primaryon --l-bg
#11182717.3:1fine
#0a66c25.5:1fine
#22c55e2.2:1fails as text
#ffd4001.4:1fails 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().

Dark mode

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.