Skip to content

Start

Getting started

Link two files and you are done.

Install and link #

Two files and a fonts folder, no build step needed to use it

Build once with npm run build (or take the files from dist/), then link the stylesheet and the script. Keep the fonts/ folder next to the CSS: JetBrains Mono is self-hosted, so ldcss never calls Google Fonts.

ldcss needs a current evergreen browser: it uses @layer, :has() and color-mix().

<link rel="stylesheet" href="dist/ldcss.css">
<script src="dist/ldcss-theme.js"></script>   <!-- optional, tiny: no theme flash -->
<script src="dist/ldcss.js"></script>         <!-- end of <body> -->
Class / attribute What it does
dist/ldcss-theme.js Optional, 0.3 KB, goes in <head> (blocking). Applies the theme a visitor chose earlier before the first paint, so there is no flash of the wrong theme. Without it everything still works; people who chose the opposite of their OS setting just see a brief flash.
dist/ldcss.css, dist/ldcss.min.css The stylesheet. The .min file is the same rules with comments and whitespace removed.
dist/ldcss.js, dist/ldcss.min.js Behaviour for every data-ld-* component. Safe to load at the end of <body>; it also starts a mutation observer so markup added later is initialised.
dist/fonts/ JetBrains Mono (Latin subset, regular and bold, WOFF) with its OFL license. Must sit beside the CSS file.

Naming conventions #

Classes for structure, attributes for state and variants

A class says what something is (ld-btn, ld-card). A data-ld-* attribute says which variant or state it is in (data-ld-variant="primary", data-ld-active="true"). Utilities are single-purpose classes, and any of them can be prefixed with a breakpoint, a theme or a state.

Class / attribute What it does
ld-* Component and utility classes: ld-btn, ld-pad-a-3, ld-grid.
data-ld-* Variants, sizes, state and behaviour: data-ld-variant, data-ld-size, data-ld-active, data-ld-toggle.
ld-{sm|md|lg|xl}:* Responsive utilities, from min-width 640 / 960 / 1200 / 1440px. Example: ld-md:d-flex.
ld-dark:*, ld-light:* A small set of theme-specific utilities for one-off flips.
ld-hover:*, ld-focus:*, ld-focus-visible:* State variants of common utilities. Example: ld-hover:bg-raised.
--ld-* Design tokens (custom properties). Override them on :root or on any container.
ld:{component}:{event} Bubbling CustomEvents. Listen with ldcss.on(); see the JavaScript page.

Cascade layers #

Why your own CSS always wins

ldcss is split into six cascade layers, lowest priority first: ld.tokens, ld.base, ld.components, ld.utilities, ld.escape, ld.variants. A later layer beats an earlier one no matter how specific the selectors are, so a utility class always beats a component rule. CSS you write outside any layer beats all of ldcss, without !important.

One consequence: a shadow utility such as ld-shadow-soft also wins over a button's :active shadow.

/* your own stylesheet, unlayered: wins over every ldcss layer */
.ld-btn { border-radius: 0; }

/* or put your code in a layer of your own, declared after ld */
@layer ld, app;
@layer app { .ld-card { padding: 2rem; } }
Class / attribute What it does
@layer ld.tokens, ld.base, ld.components, ld.utilities, ld.escape, ld.variants Declared at the top of ldcss.css. Component rules live in ld.components, single-class utilities in ld.utilities, the per-element escape hatches in ld.escape, hover/focus variants in ld.variants.
@layer ld, app; Put your layered CSS after ld to give it priority.

Foundations

Theming & tokens

Light, dark, and every value you can override.

Light, dark and unthemed #

Set the theme with one attribute

data-ld-theme on <html> (or any container) switches the palette. A toggle button remembers the choice in localStorage and falls back to the OS setting on a first visit. A light subtree inside a dark page works too.

<html>                                  <!-- no attribute: follows the OS, no JavaScript needed -->
<html data-ld-theme="dark">             <!-- or choose one -->
<button data-ld-toggle="theme">Toggle theme</button>

ldcss.theme.get();          // 'light' | 'dark': what is showing now
ldcss.theme.set('dark');    // choose and remember
ldcss.theme.set('auto');    // forget the choice, follow the OS again
Class / attribute What it does
data-ld-theme Values: light · dark. On <html> or any container
data-ld-unstyled Resets radius/shadow/border-weight/accent-fill to neutral on a container, so your own stylesheet (loaded after ldcss.css) can restyle cleanly without fighting specificity — layout, spacing, and behavior are unaffected
data-ld-notheme Freezes subtree at light appearance
data-ld-toggle="theme" Click to flip theme, persists via localStorage; on a first visit with nothing saved yet, falls back to the OS/browser's prefers-color-scheme
data-ld-theme="light" Now also works on a subtree inside a dark page, using the same shared light tokens as :root and data-ld-notheme
No data-ld-theme (automatic) With no attribute the page follows the operating system through prefers-color-scheme, from the very first paint and with JavaScript off. The build copies every [data-ld-theme="dark"] rule into a prefers-color-scheme: dark block, so the dark-only utilities and component tweaks follow too.
dist/ldcss-theme.js Put it in <head> to apply a saved choice before the first paint.
ldcss.theme.get(), ldcss.theme.set("light" | "dark" | "auto") get returns what is showing; set chooses and remembers (in localStorage), "auto" forgets and follows the OS again. Fires ld:theme:change with { theme, setting }.
color-scheme Each theme also sets color-scheme, so scrollbars and native controls match.

Design tokens #

Every colour, size and speed is a custom property

Override a token on :root for the whole page, or on one element for just that subtree. Colour tokens change with the theme; scale tokens do not.

/* a rounder, bluer page */
:root { --ld-radius: 16px; --ld-accent: #2563eb; --ld-accent-text: #fff; }
Token Meaning
--ld-accent, --ld-accent-text, --ld-accent-fill Brand colour, text on top of it, and the fill used by primary buttons (can be a gradient).
--ld-bg, --ld-surface, --ld-surface-raised Page background, card/input surface, and a raised or hovered surface.
--ld-text, --ld-text-muted Body text and secondary text.
--ld-border, --ld-border-soft, --ld-border-width Decorative borders, hairline dividers, and the border thickness. They are intentionally faint; do not use them to mark the edge of a control.
--ld-border-control The edge of a form control (input, select, switch, range track, tag input, dropzone). 3:1 or better against the page, surface and raised surface in both themes, which is what WCAG 1.4.11 asks for.
--ld-focus-ring, --ld-focus-border, --ld-code-text Colour of the focus outline; the border colour of a focused field (the accent darkened toward the focus colour so it reaches 3:1 on every surface); code text.
--ld-danger, --ld-success, --ld-warning, --ld-info (+ -text) Status colours and the readable text colour for each.
--ld-shadow, --ld-shadow-lg, --ld-shadow-soft, --ld-shadow-soft-lg, --ld-shadow-color, --ld-shadow-offset Elevation. The soft pair is what data-ld-shadow="soft" points at; colour and offset belong to data-ld-shadow="hard".
--ld-radius, --ld-border-radius-sm, --ld-border-radius-lg Corner radius for components (10px), and a smaller (half) and larger (double) step derived from it. Set all three on an element to change the radius for just that part of the page.
--ld-space-{1-6,8} Spacing scale: 4, 8, 12, 16, 24, 32, 48px. --ld-gutter is one step of it used for layout gaps.
--ld-text-{xs…4xl}, --ld-weight-{normal,medium,bold}, --ld-leading-{tight,normal,relaxed} Type sizes, weights and line heights.
--ld-font-sans, --ld-font-mono, --ld-font-family Font stacks; --ld-font-family is what text actually uses.
--ld-z-{dropdown,popover,tooltip,modal,offcanvas,toast,command} Stacking order of the overlay components.
--ld-transition-fast, --ld-transition, --ld-transition-slow Speed and easing for hover and focus changes: 80ms, 120ms and 240ms, all ease. Use as transition: color var(--ld-transition-fast). --ld-duration-fast, --ld-duration and --ld-duration-slow are the numbers alone.
--ld-content-width Widest the content column gets (1320px). ld-container stops growing here, and so do other layouts that use it.
--ld-line-height-{none,tight,normal,relaxed,loose} Line heights: 1, 1.25, 1.6, 1.75, 2. Headings use tight, body text normal. The older --ld-leading-{tight,normal,relaxed} names still work and follow these.
--ld-opacity-{0,5,10,20,25,30,40,50,60,70,75,80,90,95,100} Opacity scale from 0 to 1. ld-opacity-N reads the matching token, so changing a token changes every use of it.
--ld-opacity-disabled, --ld-opacity-overlay Opacity of a control that cannot be used right now (0.5), and how dark the scrim behind text on an image is (0.6).
--ld-sidebar-width Width of the ld-shell sidebar column (15rem).

Accent colours, scales and surfaces #

Re-skin a single subtree

These classes re-skin everything below them without touching the rest of the page.

Class / attribute What it does
ld-accent-{color} mint·blue·purple·pink·orange·yellow·red — Swap brand color for a subtree; for a color outside this list, set --ld-accent/--ld-accent-text inline instead — same mechanism, no preset needed
ld-scale-{compact,cozy,spacious} Override spacing tokens for a subtree
ld-font-sans, ld-font-serif, ld-font-mono Switch subtree type family (sans is default)
ld-glass, ld-flat Alternative surface registers
data-ld-unstyled Resets radius, shadow, border weight and accent fill to neutral on a container, so your own stylesheet can restyle it cleanly.

Foundations

Layout & page structure

Containers, grids, page blocks, the sidebar shell and resizable panels.

Containers, grids and stacks #

Centre it, grid it, stack it

ld-container centres content and steps its width per breakpoint. ld-grid is an even, responsive grid; ld-grid-row with ld-col-* is a 12-column grid for uneven layouts; ld-stack spaces children vertically.

<div class="ld-container">
  <div class="ld-grid" data-ld-cols="3"> … </div>
  <div class="ld-grid-row">
    <div class="ld-col-8">main</div><div class="ld-col-4">aside</div>
  </div>
</div>
Class / attribute What it does
ld-container Centered, max-width 1100px
ld-grid, data-ld-cols: 2·3·4 Responsive grid
ld-stack Vertical spacing between children
ld-row New in v3.0.0 — inline cluster: flex, wraps, gapped, vertically centered
ld-grid-row, ld-col-{1-12}, ld-col-auto, ld-offset-{0-11} Flexbox 12-column grid for asymmetric layouts — distinct from the even-split ld-grid. Responsive with -sm/-md/-lg/-xl infixes, e.g. ld-col-md-6, ld-offset-lg-2. Gutter via --ld-gutter (defaults to --ld-space-4)
.ld-container Now steps its max-width per breakpoint (100% fluid → 600 → 900 → 1100 → 1320px) instead of one fixed cap — see v2.0.0 changelog for the breaking-change note

Hero, section and footer #

The blocks most pages are built from

A page header (ld-hero), titled sections separated by a hairline (ld-section), and a footer. Both hero and section take data-ld-size="sm" for a compact version.

<header class="ld-hero">
  <h1>Title</h1><p class="ld-text-muted">Intro</p>
</header>
<section class="ld-section" id="intro">
  <h2 class="ld-section-title">Intro</h2>
  <p class="ld-section-sub">One line about this section</p>
</section>
Class / attribute What it does
ld-hero, data-ld-size: sm New in v3.0.0 — page header block; sm is the compact version
ld-section, ld-section-title, ld-section-sub, data-ld-size: sm New in v3.0.0 — a page section with a top hairline, title and optional one-line subtitle
ld-footer New in v2.4.0 — bottom counterpart to ld-nav: same padding/surface, border-top instead of border-bottom. Pair with ld-shadow-up* for a lift instead of (or alongside) the border

Layout primitives #

Boxes that arrange themselves without media queries

These four react to the space they are given, not to the window, so the same markup works in a wide page, a narrow column or inside a card. Set the gap with data-ld-gap="0-6|8" (a spacing step) or --ld-layout-gap (any length).

ld-cluster is inline things that wrap with one gap (tags, buttons, links). ld-sidebar-layout puts content next to a sidebar that is only as wide as it needs to be, and stacks them when the content would get squeezed. ld-switcher lays children side by side while they fit and stacks them below a width. ld-cover centres one child in an area of a chosen height.

<div class="ld-cluster" data-ld-gap="2" data-ld-justify="between"> …tags or buttons… </div>

<div class="ld-sidebar-layout" style="--ld-side-width: 14rem">
  <aside>Sidebar, first child</aside>
  <main>Content</main>
</div>

<div class="ld-switcher" data-ld-threshold="md" data-ld-limit="3"> …cards… </div>

<section class="ld-cover" style="--ld-cover-height: 60vh">
  <header>Top</header>
  <div data-ld-centered>In the middle</div>
  <footer>Bottom</footer>
</section>

Live example

ld-cluster

designtokensaccessibilityno build stepdark modetablessearchmedia

ld-sidebar-layout (resize the window: the sidebar stacks on top when the content would be squeezed)

Content takes the rest of the row. Wrap it in whatever you like.

ld-switcher (three across, one column below 30rem of width)

One
Two
Three

ld-cover (--ld-cover-height: 9rem)

headerCentred in the areafooter
Class / attribute What it does
ld-cluster Flex row that wraps, with one gap, vertically centred.
data-ld-justify, data-ld-align On ld-cluster: start · center · end · between; and center · start · end · baseline · stretch.
ld-sidebar-layout The first child is the sidebar, the last is the content. The sidebar is as wide as its content (or --ld-side-width); the content takes the rest and never gets narrower than --ld-content-min (50%), below which they stack.
data-ld-side="end" The sidebar is the last child instead, so the markup can stay content first.
--ld-side-width, --ld-content-min Ideal sidebar width, and the narrowest the content may get before wrapping.
ld-switcher Children share the row equally. Row while the box is wider than --ld-threshold (30rem), column otherwise.
data-ld-threshold xs · sm · md · lg · xl = 20 · 30 · 40 · 50 · 60rem. Or set --ld-threshold to any length.
data-ld-limit="2-6" On ld-switcher: with more children than this, always stack.
data-ld-align="start|center|end" On ld-switcher: by default children are as tall as the tallest; use this when they have shapes of their own, such as images with a ratio.
ld-cover A column at least --ld-cover-height tall (the viewport by default). The child with data-ld-centered (or ld-cover-centered) sits in the middle; earlier children stay at the top, later ones at the bottom.
data-ld-gap, --ld-layout-gap Gap for all four: spacing step 0-6 or 8, or any length through the custom property.

Sidebar and app shell #

A sidebar that becomes a menu on small screens

ld-shell wraps an ld-sidebar and the content (ld-shell-main). From a breakpoint up the sidebar is a sticky column; below it, it becomes a sticky top bar with a dropdown menu. Pick the breakpoint with data-ld-collapse-at (default sm, 640px). See demo-sidebar.html for a full page, and this page for a real one.

<div class="ld-shell" data-ld-collapse-at="md">
  <aside class="ld-sidebar">
    <div class="ld-sidebar-header">
      <a class="ld-nav-brand" href="/">Brand</a>
      <button class="ld-btn ld-sidebar-toggle" data-ld-toggle="navbar-collapse"
              data-ld-target="#side-menu" aria-expanded="false">Menu</button>
    </div>
    <nav class="ld-sidebar-body" id="side-menu" aria-label="Main">
      <ul class="ld-sidebar-links">
        <li><a class="ld-sidebar-link" href="#a" data-ld-active="true">Home</a></li>
      </ul>
    </nav>
  </aside>
  <main class="ld-shell-main">…</main>
</div>
Class / attribute What it does
ld-shell, ld-shell-main The grid that holds sidebar and content; ld-shell-main is the content column.
data-ld-collapse-at On ld-shell: sm (default) · md · lg · xl. The width at which the sidebar appears.
ld-sidebar, ld-sidebar-header, ld-sidebar-toggle, ld-sidebar-body Sidebar frame. The toggle only shows below the breakpoint; the body becomes the dropdown there.
ld-sidebar-links, ld-sidebar-link, ld-sidebar-heading, ld-sidebar-footer The link list, one link (set data-ld-active="true" for the current one), a group heading, and a footer pinned to the bottom.
--ld-sidebar-width Column width; override on ld-shell.
Behaviour The menu closes when a link is chosen and on Escape, which returns focus to the toggle. Works with data-ld-scrollspy.

Resizable panels #

Drag, or use the keyboard

Mark a container data-ld-resizable; handles appear between its ld-panel children. Sizes are percentages, clamped by each panel's min and max, and can be remembered. Handles are keyboard operable: arrows move (Shift for bigger steps), Home and End jump to the limits, Enter collapses or restores, double-click resets.

<div data-ld-resizable data-ld-persist="layout" style="height: 280px">
  <div class="ld-panel" data-ld-size="30" data-ld-min="15%">Files</div>
  <div class="ld-panel">Editor</div>
</div>

Live example

Drag the line, or focus it and press the arrow keys.
Middle
Min 100px
Class / attribute What it does
data-ld-resizable On the container. ld-resizable styling is added for you.
data-ld-direction horizontal (default) or vertical.
ld-panel, data-ld-size, data-ld-min, data-ld-max A panel; starting size in percent; limits as "20%", "160px" or a number of pixels.
ld-resize-handle Inserted between panels automatically, or write your own. It is a focusable role="separator" with aria-valuenow/min/max.
data-ld-persist A key; sizes are saved in localStorage under it.
ld:resize:start, :move, :end Events on the container with detail.sizes (percentages) and detail.handle.
ldcss.resizable.get(el), .set(el, [30, 70]), .reset(el) Read, set or reset sizes from script.

Utilities

Spacing, type & colour

Padding, margin, text and colour classes.

Spacing #

Padding, margin and gap on the spacing scale

Sizes map to --ld-space-{size}. Sides are a (all), t, r, b, l, v (vertical) and h (horizontal).

<div class="ld-pad-a-4 ld-mar-b-5 ld-d-flex ld-gap-3"> … </div>
Pattern What it does
ld-pad-{side}-{size} side: a·t·r·b·l·v·h — Padding, e.g. ld-pad-a-3
ld-mar-{side}-{size} side: a·t·r·b·l·v·h — Margin, e.g. ld-mar-v-6
size 0·1·2·3·4·5·6·8 — Maps to --ld-space-{size}
ld-mar-{side}-n{size} side: a·t·r·b·l·v·h — Negative margin, e.g. ld-mar-t-n3
ld-gap-{size} + -x-, -y- variants — Flex/grid gap

Typography #

Size, weight, case, truncation

Class / attribute What it does
ld-text-* xs·sm·base·lg·xl·2xl — Font size (maps to --ld-text-*)
ld-font-* normal·medium·bold — Font weight
ld-uppercase / ld-lowercase / ld-capitalize Text transform
ld-tracking-wide letter-spacing: 0.05em
ld-truncate Single-line ellipsis
ld-line-clamp-* 2·3 — Multi-line ellipsis
ld-leading-{none,tight,normal,relaxed,loose} Line height from the --ld-line-height-* tokens: 1, 1.25, 1.6, 1.75, 2.
ld-underline, ld-no-underline Text decoration. Pair ld-no-underline with ld-hover:underline for links that underline on hover.

Colour and opacity #

Text and background colours

Text and background colours read the theme tokens, so they follow light and dark automatically.

Class / attribute What it does
ld-text-* muted·accent·danger — Text color
ld-bg-* surface·raised·transparent·accent·danger·success·warning·info — Background color. The last five (new in v2.4.0) read the same semantic tokens as their ld-text-*/border counterparts
ld-opacity-{0,5,10,20,25,30,40,50,60,70,75,80,90,95,100}, ld-opacity-disabled Opacity from the --ld-opacity-* tokens. Pair with ld-hover:opacity-* for fade effects.

Utilities

Display, flex, grid & sizing

Everything that arranges and sizes boxes.

Display, flex and alignment #

The everyday flexbox set

<div class="ld-d-flex ld-items-center ld-justify-between ld-gap-3"> … </div>
Class / attribute What it does
ld-d-* block·inline·inline-block·flex·inline-flex·grid·none — Display
ld-flex-* row·row-rev·col·col-rev·wrap·nowrap·1·auto·none — Flex direction / wrap / grow
ld-items-* start·center·end·stretch·baseline — align-items
ld-justify-* start·center·end·between·around·evenly — justify-content
ld-text-* left·center·right — Text align

Flex items and alignment extras #

Grow, shrink, basis, order and per-item alignment

New in v3.1.0: grow/shrink, basis, content-*, justify-items/self, place-*. Basis, grow and shrink also come in responsive versions.

Class / attribute What it does
ld-self-* start·center·end·stretch — align-self, per-item override
ld-order-* 1..5, first, last — order
ld-grow, ld-grow-0, ld-shrink, ld-shrink-0, ld-flex-initial Flex grow and shrink factors.
ld-basis-{0,auto,full,25,33,50,66,75} flex-basis.
ld-flex-wrap-reverse, ld-order-{0,none} Reverse wrapping; reset order.
ld-content-{start,center,end,between,around,evenly,stretch} align-content.
ld-justify-items-*, ld-justify-self-*, ld-self-baseline, ld-self-auto Item alignment in grid and flex.
ld-place-items-*, ld-place-content-*, ld-place-center place-items / place-content shorthands; ld-place-center centres both ways.

Grid #

Columns, spans, rows and auto-fit

ld-grid-cols-{1-12} sets a fixed number of columns. ld-grid-fit-{xs,sm,md,lg,xl} is the one-liner for card grids: as many columns as fit, each at least that wide (9, 12, 16, 20 or 24rem), and ld-grid-fill-* keeps empty tracks. Place items with ld-span-*, ld-col-start-*, ld-row-span-*.

<div class="ld-d-grid ld-grid-fit-sm ld-gap-4">
  <div class="ld-card">…</div><div class="ld-card">…</div>
</div>

Live example

1
2
3
4
span 2
Class / attribute What it does
ld-grid-cols-{1-12}, ld-grid-cols-none Fixed column count (minmax(0, 1fr) tracks). Responsive: ld-md:grid-cols-4. Pair with ld-d-grid.
ld-grid-rows-{1-6}, ld-grid-rows-none Fixed row count.
ld-grid-fit-{xs,sm,md,lg,xl} Auto-fit columns, each at least 9 / 12 / 16 / 20 / 24rem. Collapses empty tracks.
ld-grid-fill-{xs,sm,md,lg,xl} Same, but keeps empty tracks so items do not stretch.
ld-span-{1-12}, ld-span-full Item spans that many columns (ld-span-full = all). Responsive: ld-md:span-6.
ld-col-start-{1-13}, ld-col-end-{1-13} Explicit column lines.
ld-row-span-{1-6}, ld-row-span-full Item spans rows.
ld-grid-flow-{row,col,dense,row-dense,col-dense} grid-auto-flow.
ld-auto-rows-{auto,min,max,fr}, ld-auto-cols-{auto,min,max,fr} Implicit track sizes.

Sizing #

Width, height, min, max and squares

Percent widths now run 10-100 in steps (ld-w-10, -20, -25, -30, -33, -40, -50, -60, -66, -70, -75, -80, -90, -100). ld-size-* sets width and height together, on the spacing scale.

<img class="ld-size-6" src="avatar.png" alt=""> <div class="ld-w-full ld-md:w-50 ld-max-w-prose"> … </div>
Class / attribute What it does
ld-w-{auto,full,screen,min,max,fit}, ld-w-{10…100} Width: keywords or percent. Responsive: ld-sm:w-50, ld-md:w-33 and so on for auto, full, 25, 33, 50, 66, 75, 100.
ld-h-{auto,full,screen,min,max,fit}, ld-h-{10…100} Height. ld-h-screen uses 100dvh where available.
ld-min-w-*, ld-max-w-* min: 0 full min max fit screen. max: none xs sm md lg xl 2xl 3xl prose full min max fit screen (prose = 65ch).
ld-min-h-*, ld-max-h-* min: 0 full screen min max fit. max: none full screen min max fit.
ld-size-{0-6,8}, ld-size-full, ld-size-auto Width and height at once, on the spacing scale (0 to 48px).

Position and stacking #

Offsets and z-index

Class / attribute What it does
ld-{static,relative,absolute,fixed,sticky} Position
ld-inset-0, ld-{top,right,bottom,left}-0 Edges (pair with a position class)
ld-z-* 0·10·20·30·40·50 — Numeric z-index scale
ld-z-* dropdown·popover·tooltip·modal·offcanvas·toast·command — Named — matches ldcss's own component stacking (20/25/30/50/55/60/70)

Visibility, overflow and misc #

Screen-reader text, scrolling, media boxes

Class / attribute What it does
ld-sr-only Visually hidden, screen-reader accessible
ld-cursor-* pointer·not-allowed·default — Cursor
ld-select-none Disables text selection
ld-overflow-* hidden·auto·scroll·visible — Overflow
ld-sr-only-focusable Like ld-sr-only, but reveals on focus — the primitive ld-skip-link is built on, usable directly for a plainer reveal-on-focus element
ld-scrollbar-thin Cross-browser thin scrollbar (Firefox scrollbar-* + WebKit ::-webkit-scrollbar) for any scrolling container
ld-safe-{top,bottom,left,right} Padding via env(safe-area-inset-*) for content pinned to a viewport edge on notched devices — no-ops safely where unsupported
ld-aspect-{square,video,portrait,wide}, ld-object-{cover,contain,fill,center,top,bottom} Aspect-ratio boxes and object-fit/position, e.g. for media inside a card

Utilities

Motion, states & escape hatches

Transitions, transforms, hover/focus variants and per-element overrides.

Transitions #

Say what animates, how long, and how

Add a transition-* class to choose which properties animate, then duration-*, delay-* and ease-*. Under prefers-reduced-motion ldcss turns transitions off.

<button class="ld-btn ld-transition-colors ld-duration-200 ld-ease-out ld-hover:bg-raised">Save</button>
Class / attribute What it does
ld-transition, ld-transition-{colors,opacity,shadow,transform,all}, ld-transition-none Which properties transition (150ms ease unless you set a duration).
ld-duration-{0,75,100,150,200,300,500,700,1000} Duration in milliseconds.
ld-delay-{0,75,100,150,200,300,500,700,1000} Delay in milliseconds.
ld-ease-{linear,in,out,in-out} Timing function.

Transforms #

Scale, rotate and translate that combine

Each class sets one transform property, so they combine on one element. Negative values use an n: ld-translate-y-n2 moves up 8px.

Live example

hover:scale-105
hover:translate-y-n2
rotate-3
Class / attribute What it does
ld-scale-{0,50,75,90,95,100,105,110,125,150} Scale in percent.
ld-rotate-{0,1,2,3,6,12,45,90,180}, ld-rotate-n{1,2,3,6,12,45,90,180} Rotation in degrees; n is negative.
ld-translate-x-{0-6,8,full,nfull}, ld-translate-y-* Move on the spacing scale; n prefix for negative (ld-translate-y-n2); full is 100% of the element.
ld-origin-{center,top,bottom,left,right}, ld-will-change-{transform,opacity} Transform origin and a will-change hint.

Hover and focus variants #

The same utility, only on a state

Prefix a supported utility with ld-hover:, ld-focus: or ld-focus-visible:. They are generated from the base utilities, so they always match. hover: applies only on devices that can hover, so touch screens never get a stuck hover.

Live example

Hover: accent + underline
Class / attribute What it does
ld-hover:*, ld-focus:*, ld-focus-visible:* State variants of text-{muted,accent,danger}, bg-*, opacity-*, shadow-*, underline, no-underline, ring (e.g. ld-hover:bg-raised). hover: only applies on devices that can hover. Generated from the base utilities by the build — edit the lists in src/css/variants/s40-state-variants.gen.mjs
ld-ring The dashed focus outline as a utility; combine with ld-focus-visible:ring.
Supported text-{muted,accent,danger}, bg-*, opacity-*, shadow-*, underline, no-underline, ring, and scale-* / translate-* motion. Add more in src/css/variants/s40-state-variants.gen.mjs.

Responsive and theme variants #

Change a utility at a breakpoint or per theme

Class / attribute What it does
ld-sm:* min-width: 640px — Same sm breakpoint .ld-grid already uses
ld-md:* min-width: 960px — Same md breakpoint .ld-grid already uses
ld-lg:* min-width: 1200px — New in v1.1.0
ld-xl:* min-width: 1440px — New in v1.1.0
Each of the four prefixes above covers: d-* (display), grid-cols-* (1–6), flex-row/flex-col, order-* (1..5, first, last — new in v2.2.0), text-left/center/right, gap-0/2/4/6, pad-a-*/mar-a-* (all-sides, sizes 0/2/3/4/6/8), text-xs…text-3xl, and w-25/50/75/100 — e.g. ld-md:flex-row, ld-md:order-2, ld-lg:pad-a-6. Not covered: per-side padding/margin, the full 8-step spacing scale, and border-radius — same pattern, add if needed.
ld-dark:hidden, ld-dark:block, ld-dark:opacity-{50,75}, ld-light:hidden Small, deliberately non-exhaustive set for a one-off property flip between themes. For anything bigger, retheme the subtree with tokens (§1) instead of growing this list

Utility attributes #

Shadow and border by attribute

Class / attribute What it does
data-ld-shadow Values: none · sm · md · lg · hard. Soft blur depth by default; hard switches to the original offset shadow via --ld-shadow — set on <html> for the whole page or on one element
data-ld-border Values: none · thin · thick · accent. Border weight/color

Escape hatches #

Per-element overrides

For when a component's own look is not what you want on one element. They live in the ld.escape layer, so they win over component rules.

Class / attribute What it does
ld-round* -none·-sm·(default)·-lg·-xl·-full — Border radius
ld-border-{1,2,3,4}px-{solid,dashed,dotted,double} + ld-border-0 — Border width × style grid
ld-shadow-soft* -sm·(default)·-lg·-none — Per-element blurred shadow depth tier, independent of the --ld-shadow token
ld-shadow-up* -sm·(default)·-lg — New in v2.4.0 — same sm/base/lg scale as ld-shadow-soft*, cast upward instead of down. Pairs with ld-footer, or anything else anchored to the bottom of the viewport; use ld-shadow-none for "no shadow" in either direction

Animation #

Generic motion

Class / attribute What it does
ld-animate-{spin,pulse,bounce} Generic, reusable motion — separate from the one-off keyframes already built into toast/skeleton/spinner. Respects prefers-reduced-motion via the existing global rule

Components

Buttons, badges & cards

The small parts most interfaces are made of.

Buttons #

Variants, sizes and groups

Variants and sizes are attributes on ld-btn. Group related buttons in ld-btn-group so they share one outline.

A pressed button moves down 1px using the translate property, not transform, so it adds to any transform you put on a button (a centring transform, a hover lift) instead of replacing it.

<button class="ld-btn" data-ld-variant="primary" data-ld-size="sm">Save</button>
Class / attribute What it does
ld-btn, data-ld-variant Values: primary · outline · ghost · danger
data-ld-size Values: sm · md · lg
data-ld-disabled Values: true
data-ld-prompt Values: true — prefixes label with >
ld-btn-group, data-ld-split Adjacent ld-btns sharing one outline instead of each drawing its own border/radius/shadow. data-ld-split narrows the last button for a split-button trigger — wire an actual dropdown onto it separately if you need one
data-ld-toggle="copy", data-ld-copy / data-ld-target Copies literal text or a target's textContent

Cards #

A surface with parts

ld-card is a bordered surface. Add data-ld-interactive="true" for a hover lift on cards that are clickable.

<article class="ld-card" data-ld-interactive="true">
  <p class="ld-card-eyebrow">Eyebrow</p>
  <h3 class="ld-card-title">Title</h3>
  <div class="ld-card-body">…</div>
</article>
Class / attribute What it does
ld-card, data-ld-interactive Values: true — lift on hover
ld-card-eyebrow / -title / -body / -footer Values: Card subparts

Badges, dots, avatars, keys and icons #

Little things

Class / attribute What it does
ld-badge, data-ld-variant Values: primary · danger
ld-dot Absolutely positioned notification dot/count on a position: relative ancestor (ld-avatar already is one). data-ld-count for a number instead of a bare dot; data-ld-variant: accent · muted. Distinct from the inline-text ld-badge
ld-avatar, data-ld-size, data-ld-variant Values: size: sm·lg; variant: accent
ld-kbd Keyboard key badge
ld-icon, ld-icon-sm, ld-icon-lg Sizing convention for inline SVGs (1em by default, tracks surrounding font-size) — not an icon set; bring your own SVG with stroke="currentColor"

Components

Media & galleries

Images, video, captions, overlays, masonry and galleries. CSS only.

Images and fixed-shape wrappers #

Fluid images, and boxes that keep a shape

ld-img makes an image fluid: never wider than its box, keeping its own proportions. ld-media is a wrapper with a fixed shape (data-ld-ratio) whose image, <picture>, <video> or <iframe> fills it and is cropped rather than stretched. data-ld-fit="contain" shows the whole image instead. Without a ratio the wrapper takes the media's own shape. Always give images an alt (empty when decorative) and loading="lazy" below the fold.

<img class="ld-img" src="photo.jpg" alt="…" loading="lazy">

<figure class="ld-media" data-ld-ratio="16-9">
  <img src="photo.jpg" alt="…" loading="lazy">
</figure>

Live example

A square image cropped to 16:9
The same square image shown whole
A portrait image
Class / attribute What it does
ld-img Fluid image: max-width: 100%; height: auto; display: block. data-ld-rounded (or sm, full) rounds it.
ld-media Wrapper with a rounded, clipped box and a quiet background while loading. Children (img, picture, video, canvas, svg) fill the width.
data-ld-ratio On ld-media or ld-video: 1-1 · 4-3 · 3-2 · 16-9 · 21-9 · 3-4 · 2-3 · 9-16. Media then fills and crops to the box.
data-ld-fit="contain" Show the whole image inside the shape instead of cropping it.
data-ld-rounded="none|sm" On ld-media: square corners or the small radius.

Video and embeds #

Consistent aspect ratios

ld-video is a 16:9 box (a different one with data-ld-ratio) that an <iframe> or <video> fills completely, so an embed keeps its shape at every width instead of collapsing to 150px tall. The frame is black behind it while it loads. Give iframes a title.

<div class="ld-video">
  <iframe src="https://www.youtube-nocookie.com/embed/…" title="Talk recording" allowfullscreen></iframe>
</div>
<div class="ld-video" data-ld-ratio="4-3"><video controls src="clip.mp4"></video></div>
Class / attribute What it does
ld-video 16:9 box; iframe, video, embed and object children fill it with no border.
data-ld-ratio 1-1 · 4-3 · 3-2 · 21-9 · 9-16, or leave it for 16:9.
ld-aspect-{square,video,4-3,3-2,portrait,wide} The plain aspect-ratio utilities, for anything else.

Captions and overlays #

Text with or on top of an image

A caption below the image: wrap the ld-media and a <figcaption> in ld-figure. A caption or overlay on top of the image: put ld-media-caption or ld-media-overlay inside the ld-media. Text on an image needs something behind it to stay readable, so both draw a dark gradient (strength --ld-opacity-overlay) and use white text in every theme. data-ld-show="hover" shows an overlay on hover and keyboard focus; touch screens, which cannot hover, always show it.

<figure class="ld-figure">
  <div class="ld-media" data-ld-ratio="1-1"><img src="a.jpg" alt="…"></div>
  <figcaption>Caption below</figcaption>
</figure>

<figure class="ld-media" data-ld-ratio="4-3">
  <img src="a.jpg" alt="…">
  <div class="ld-media-overlay" data-ld-show="hover">
    <h3>Title</h3><p>Shown on hover or focus</p>
  </div>
</figure>

Live example

Sample
A caption below the image
Sample
A caption on the image
Sample

Overlay

Shown on hover and focus

Class / attribute What it does
ld-figure Figure with the media and a caption stacked; figcaption (or ld-caption) is small and muted.
ld-media-caption Caption laid over the bottom of an ld-media, on a gradient.
ld-media-overlay Fills the media with a gradient and holds any content; links and buttons inside stay clickable.
data-ld-align On ld-media-overlay: bottom (default), center (with an even scrim) or top.
data-ld-show="hover" Overlay is hidden until hover or focus within; always visible on devices that cannot hover.

Masonry-like layout #

Items of different heights packed into columns

ld-masonry uses the browser's multi-column layout, so it works everywhere with no JavaScript: columns are --ld-masonry-min wide (14rem) and their number follows the space. Items run down each column in order, not across, which is how columns work. Where the browser supports real masonry (display: grid-lanes, or the older grid-template-rows: masonry) that is used instead and items run left to right; I could only test the columns version, since the browser used for testing had neither.

<div class="ld-masonry" style="--ld-masonry-min: 12rem">
  <figure class="ld-media"><img src="a.jpg" alt="…"></figure>
  <figure class="ld-media"><img src="b.jpg" alt="…"></figure>
</div>

Live example

Sample 1
Sample 2
Sample 3
Sample 4
Sample 5
Sample 6
Sample 7
Sample 8
Sample 9
Class / attribute What it does
ld-masonry Multi-column layout with --ld-masonry-min (14rem) wide columns; items never split across columns.
data-ld-cols="2-6" Fix the number of columns instead of letting the width decide.
--ld-masonry-min, --ld-layout-gap Column width, and the gap (column and row) in any length.

Components

Forms & inputs

Fields, affixes, validation and choice controls.

Fields and groups #

Labels, inputs and hints

Wrap each label and field in ld-form-group. Mark a field invalid by hand with data-ld-error="true", or let ld-validate do it from the browser's own validation.

<div class="ld-form-group">
  <label class="ld-label" for="email">Email</label>
  <input class="ld-input" id="email" type="email" required>
  <p class="ld-form-hint">We never share it.</p>
</div>
Class / attribute What it does
ld-form-group, ld-label
ld-input, ld-textarea, ld-select, data-ld-error Values: true
ld-form-hint, ld-form-error
ld-checkbox Values: Wraps a native checkbox
ld-switch Values: Wraps a native checkbox, styled as toggle
ld-range Values: Styled <input type="range">
ld-select Now draws its own dropdown arrow (light/dark aware) instead of the bare browser default; add [multiple] and the arrow is automatically suppressed

Affixes, clear and password toggles #

Icons and actions inside an input

Class / attribute What it does
ld-input-group, ld-input-affix Icon/unit alongside an ld-input; add data-ld-affix="right" on the group to move it to the trailing edge
data-ld-input-clear Button inside an ld-input-group; empties and refocuses the input (via data-ld-target, or its previous sibling by default). Auto-shows/hides based on whether the input has a value
data-ld-password-toggle Same affix slot; flips a password input (data-ld-target or previous sibling) between masked and plain text

Floating labels and validation #

Labels that rise, fields that report

ld-validate colours fields only after the person has touched them (it uses :user-invalid), and ldcss mirrors that into aria-invalid so screen readers hear it too.

Class / attribute What it does
ld-form-floating Label rises out of the field on focus/value. Input needs placeholder=" " (a real space) and must come before the <label> in markup
.ld-validate Wrap a form to get automatic red/green on .ld-input/.ld-textarea via native :user-invalid/:user-valid — only after interaction, not on first paint. Independent of the manual data-ld-error/-success attributes

Segmented control, chips, tags, rating and dropzone #

Choosing and collecting

Class / attribute What it does
data-ld-segmented, data-ld-segment Container + option, attribute-only (no class needed). data-ld-active="true" marks the current option; optional data-ld-target="#input" on the container syncs the value into a hidden input
data-ld-chip Interactive filter chip (contrast with the display-only ld-tag). Wrap in a container with data-ld-chips="single" for radio-style selection; omit the wrapper for independent multi-select toggles. The active checkmark is automatic — add a .ld-chip-check span inside only if you want to control its placement yourself
ld-tag / data-ld-tag, data-ld-tags, data-ld-tags-input Enter/comma to add, Backspace to remove last. Each tag's remove button is .ld-tag-remove or data-ld-tag-remove — either works, and tags typed into the input get both
data-ld-rating, data-ld-star, data-ld-value 1–5 click-to-set
data-ld-dropzone, data-ld-dropzone-label Click-to-browse + drag/drop

Components

Combobox & search

Autocomplete from static, local or remote data, and fuzzy search.

Combobox #

Static options, local items or a remote source

One component, three data sources. With none set, it filters the options you wrote. With data-ld-source="#id" it fuzzy-matches a JSON <script>; with a URL it fetches results as you type. Focus stays in the input; the highlighted option is announced through aria-activedescendant, and result counts are read out politely.

Typing again cancels the request still in flight, and an answer that arrives late for an old query is discarded, so the list never shows stale results. Answers are remembered per query (turn off with data-ld-cache="false").

<div class="ld-form-group" data-ld-combobox
     data-ld-source="/api/people?q={q}" data-ld-debounce="250" data-ld-min-chars="2">
  <label class="ld-label" for="who">Person</label>
  <input class="ld-input" id="who" type="text">
  <div data-ld-combobox-list></div>
</div>

Live example

Class / attribute What it does
data-ld-combobox Wraps an input + data-ld-combobox-list of data-ld-combobox-option items. Typing filters, ↑/↓ highlights, Enter/click selects, Escape closes. Add a data-ld-combobox-empty element for the no-matches state
data-ld-source A URL (/api?q={q}, or a base URL that gets ?q=…), or #id of a <script type="application/json"> for local items. Remote responses must be JSON arrays.
data-ld-items-path, data-ld-query-param Where the array sits in a response (data.results), and the query-string name when the URL has no {q} (default q).
data-ld-debounce, data-ld-min-chars, data-ld-limit Wait before sending (default 250ms), shortest query that searches (default 0), most results shown (default 50).
data-ld-label-key, data-ld-value-key, data-ld-description-key Which fields of each item are the label, the value and the second line.
data-ld-fuzzy="true" Fuzzy-match and rank the static options you wrote (local items are always fuzzy).
data-ld-state Set on the box: idle · loading · results · empty · error. Style or script against it.
data-ld-combobox-loading, -empty, -error, -retry Optional elements for each state. They are created for you if absent; add your own to change the wording or markup. The retry button sends the last query again.
data-ld-template="#tpl" A <template> cloned per result; data-ld-field="label" fills text, data-ld-field-src / -href set src / href.
data-ld-select-on-tab, data-ld-escape-clear="false", data-ld-autohighlight="false", data-ld-highlight="false" Tab chooses the highlighted option; do not clear on a second Escape; do not pre-highlight the first result; no <mark> on matched letters in remote results.
ldcss.combobox.source(el, (query, { signal }) => items) Use your own data function (sync, or a Promise that honours signal).
ldcss.combobox.render(el, (item, ctx) => node | html) Custom result row. ctx.highlight(text) returns escaped HTML with the matched letters in <mark>; escape anything else with ldcss.escapeHtml.
ldcss.combobox.setItems(el, items), .search(el, q), .clear(el), .open(el), .close(el) Local items and imperative control.
Keyboard ↓ ↑ move (↓ opens the list), PageUp/PageDown jump 10, Enter chooses, Esc closes (again: clears), Tab closes.
ld:combobox:search, :results, :error, :cancel, :select Events on the box. select carries { item, value, label, option } and is cancelable (cancel it to stop navigation for items with an href).

Fuzzy matching API #

The matcher behind the search bar, usable on its own

A subsequence matcher with scoring (the approach of fzy): every query character must appear in order. A match scores higher when it is consecutive, starts a word, or follows a separator, and gaps cost a little.

ldcss.fuzzy.match('btn', 'Button')        // { score, indices: [0, 3, 5] }  or null
ldcss.fuzzy.search('btn pri', items, { keys: ['name', 'desc'], limit: 10 })
ldcss.fuzzy.highlight('Button', [0, 3, 5])  // '<mark>B</mark>ut<mark>t</mark>o<mark>n</mark>
Class / attribute What it does
ldcss.fuzzy.match(query, text) Returns { score, indices } (matched character positions) or null.
ldcss.fuzzy.search(query, items, options) Ranks items best first. Items are strings or objects; options.keys lists fields (strings, dotted paths, functions, or { name, weight }), limit caps results, threshold drops weak ones. A multi-word query is an AND. An empty query returns everything in order.
ldcss.fuzzy.highlight(text, indices) Escaped HTML with the matched characters wrapped in <mark>.
ldcss.escapeHtml(text) Escape text for safe insertion into HTML.

Components

Overlays & menus

Dropdowns, popovers, dialogs, context menus and tooltips.

Dropdown, popover, modal, offcanvas, command palette #

Triggers and what they open

Every overlay is opened by a trigger carrying data-ld-toggle. While a modal, offcanvas or command palette is open, the rest of the page is made inert (nothing behind it can be tabbed to or read), and the dialog is named from its first heading. Dropdown menus get role="menu" and arrow-key navigation.

Dialogs stack properly: each one remembers what opened it and hands focus back to exactly that when it closes, Escape closes one layer at a time (a dropdown inside a modal first, then the modal; stacked modals top first), and the focus trap only visits elements that are really visible and enabled.

Class / attribute What it does
data-ld-toggle="dropdown" Target: data-ld-dropdown-menu — Wrap both in data-ld-dropdown
data-ld-toggle="popover" Target: data-ld-popover-content — Wrap both in data-ld-popover
data-ld-toggle="modal" Target: .ld-modal-backdrop > .ld-modal — Dismiss: data-ld-dismiss="modal"
data-ld-toggle="offcanvas" Target: .ld-offcanvas-backdrop > .ld-offcanvas — data-ld-pos="right" on backdrop; dismiss: data-ld-dismiss="offcanvas"
data-ld-toggle="command" Target: .ld-command-backdrop — Also opens with ⌘/Ctrl+K
data-ld-command-item Target: data-ld-command-action — Item with no action is a plain row — put a real <a href> inside it for navigation, or use theme / toast:message / modal:#id / offcanvas:#id / goto:#id-or-url (v2.3.0 — #id smooth-scrolls + focuses in-page, anything else navigates there)

Context menu #

Right-click, long-press or the menu key

A region with data-ld-context-menu="#menu" opens that menu where you click, clamped inside the viewport. It also opens from the keyboard (Shift+F10 or the menu key) and on a touch long-press. Hold Shift while right-clicking to get the browser's own menu.

<div data-ld-context-menu="#ctx" tabindex="0">Right-click me</div>
<div class="ld-context-menu" id="ctx">
  <button class="ld-context-item" data-ld-value="copy">Copy <span class="ld-context-shortcut">Ctrl C</span></button>
  <div class="ld-context-separator"></div>
  <button class="ld-context-item" data-ld-variant="danger" data-ld-value="delete">Delete</button>
</div>

Live example

Right-click here, long-press on touch, or focus and press Shift+F10.
Item
Class / attribute What it does
data-ld-context-menu="#id" On the region: the menu to open. Add tabindex="0" to make it reachable from the keyboard. data-ld-longpress="false" turns off touch long-press.
ld-context-menu, ld-context-item, ld-context-label, ld-context-separator, ld-context-shortcut The menu and its parts. Items can be <button> or <a>; aria-disabled="true" skips an item; data-ld-variant="danger" colours it.
data-ld-value On an item: the value reported in the select event.
Keyboard ↑ ↓ Home End move, a letter jumps to the next item starting with it, Enter or Space activates, Esc closes and returns focus, Tab closes.
ld:contextmenu:before-show, :show, :hide, :select before-show is cancelable. select carries { item, value, region, target, menu }.
ldcss.contextMenu.open(menu, x, y, region), .close() Open or close from script.

Tooltip #

A label on hover and focus

data-ld-tooltip becomes the accessible name of an icon-only control, or its description when it already has one. Esc hides a visible tooltip without moving focus, as WCAG 1.4.13 asks.

Class / attribute What it does
data-ld-tooltip, data-ld-tooltip-pos pos: top·bottom·left·right

Components

Feedback & notifications

Alerts, toasts with a queue, a notification centre, progress and loading states.

Alerts #

Inline messages

Class / attribute What it does
ld-alert, data-ld-variant Values: info · success · warning · danger

Toasts and the queue #

Short messages that stack and wait their turn

ldcss.toast() shows a toast. At most data-ld-toast-max (default 5) are visible; the rest wait in a queue and appear as others close. Hovering or focusing a toast pauses its timer, and so does switching to another tab. Showing a toast with an id that is already up updates it instead of stacking a copy. Danger toasts are announced immediately.

When you do not pass a duration, a toast stays long enough to read: 5 seconds, plus 40 ms for every character beyond 60, plus 3 seconds when it has buttons, never more than 15 seconds. Set data-ld-toast-default-duration on <body> to use your own number (0 keeps toasts until dismissed).

ldcss.toast({ message: 'Saved', variant: 'success' });

const t = ldcss.toast({ id: 'upload', title: 'Uploading', message: '0%', duration: 0 });
t.update({ message: '60%' });
t.update({ message: 'Done', variant: 'success', duration: 2000 });

ldcss.toast({ message: 'Item removed', progress: true,
  actions: [{ label: 'Undo', onClick: () => restore() }] });

Live example

Class / attribute What it does
data-ld-toggle="toast", data-ld-toast-message/-variant/-duration Values: variant: success · danger
data-ld-toast-position (on <body>) Values: top-left · top-center · top-right (default) · bottom-left · bottom-center · bottom-right — read once, at container-creation time
ldcss.toast(spec) → { id, el, dismiss(), update(patch) } spec: title, message, variant (success · danger · warning · info), duration (ms, 0 keeps it; default depends on the text), id, actions, progress. A string works too: ldcss.toast("Saved").
actions: [{ label, onClick(event, handle), dismiss }] Buttons inside the toast. Each dismisses the toast unless dismiss: false.
data-ld-toast-max (on <body>) Most toasts visible at once; default 5.
data-ld-toast-default-duration (on <body>) Milliseconds a toast stays when it has no duration of its own; 0 = until dismissed. Without it the reading-time rule above applies.
data-ld-toast-persist, -title, -progress More options for the data-ld-toggle="toast" trigger: no auto-dismiss, a title, a countdown bar.
ldcss.toast.clear() Dismiss everything, queue included.
ld:toast:show, :hide, :action, :update Events. hide carries reason: timeout, dismiss, action or clear.

Notification centre #

A history of what happened, with unread counts

ldcss.notify() shows a toast and also keeps the notification: an unread count, a list you can open later, mark read, dismiss, clear all. Put data-ld-notification-center anywhere (a popover works well) and data-ld-notification-count on a badge. Add data-ld-notify-store="key" to <body> to keep the history in localStorage.

<button class="ld-btn" data-ld-toggle="popover">Bell
  <span class="ld-badge" data-ld-notification-count hidden></span></button>
<div data-ld-popover-content>
  <div data-ld-notification-center data-ld-mark-read="open"></div>
</div>

ldcss.notify({ title: 'Build finished', message: 'main passed', variant: 'success' });

Live example

Class / attribute What it does
ldcss.notify(spec) Same fields as ldcss.toast, plus toast: false to add to the history without a pop-up. Returns { id, markRead(), dismiss() }.
data-ld-notify, data-ld-notify-title, -message, -variant A trigger button that adds a notification when clicked.
data-ld-notification-center Renders the list, with "Mark all read" and "Clear all". data-ld-title, data-ld-empty change the wording; data-ld-mark-read="open" marks everything read shortly after the popover, dropdown, modal or offcanvas holding it opens.
data-ld-notification-count A badge that shows the unread count and hides at zero.
data-ld-notify-store, data-ld-notify-max On <body>: a localStorage key to persist the history, and how many to keep (default 50).
ldcss.notifications.list(), .unread(), .markRead(id), .markAllRead(), .dismiss(id), .clear() Read and manage the history.
ld:notification:add, :read, :dismiss, :clear Events on <body>.

Progress, skeletons, spinners and empty states #

Show that something is happening, or that nothing is there

Class / attribute What it does
ld-progress-bar, data-ld-progress, data-ld-value Values: 0–100
ld-skeleton, data-ld-shape Values: circle · text
ld-spinner, data-ld-variant Values: dots
data-ld-shape="button"/"avatar", ld-skeleton-w-{25,50,75,100}, ld-skeleton-paragraph Extends the existing ld-skeleton/data-ld-shape="circle"/"text"
ld-empty, ld-empty-icon/-title/-body/-actions The "no results" pattern — pure layout/type, no JS

Components

Tables, lists & code

Data tables that sort, search and page themselves.

Tables #

The basics

ld-table styles a plain table. For sorting, searching and paging, add data-ld-table (next section).

Class / attribute What it does
ld-table, data-ld-striped, data-ld-bordered Values: true
data-ld-sticky-head On .ld-table: pins <thead> while the body scrolls. Needs a bounded-height scrolling ancestor — position: sticky does nothing without one
th[data-ld-sort] Click to sort a .ld-table by that column. data-ld-sort-key identifies the column in the fired event; per-cell data-ld-sort-value sorts a formatted cell (dates, currency) by a hidden raw number. Numeric if both sides parse as numbers, else string compare. Fires ld:table:sort with {key, direction}

Easy tables #

One attribute for sorting, search, paging and selection

Add data-ld-table to a table and headers become sort buttons that work from the keyboard and announce the new order. Add data-ld-search for a fuzzy filter, data-ld-page-size for paging, data-ld-select for checkboxes. Numbers and dates are detected and sorted as numbers and dates; blanks sort last. Clicking a header again reverses the order, a third click restores the original order. Rows you add later are picked up automatically.

<table class="ld-table" data-ld-table data-ld-search data-ld-page-size="10"
       data-ld-select data-ld-empty="No people match.">
  <caption>People</caption>
  <thead><tr><th>Name</th><th>Age</th><th data-ld-nosort>Actions</th></tr></thead>
  <tbody>…</tbody>
</table>

Live example

People
Name Role Age Joined
Zoë Martin Designer 31 2021-03-04
Alan Turner Engineer 9 2019-11-30
Bea Okafor Manager 100 2022-01-15
Carl Dijkstra Engineer 2018-07-01
Dana Lee Support 45 2020-05-20
Eli Park Designer 27 2023-02-02
Fay Chen Engineer 52 2017-09-09
Gus Holm Support 19 2024-04-04
Hana Sato Manager 38 2016-12-12
Ivy Novak Designer 29 2025-01-08
Class / attribute What it does
data-ld-table On the <table>: sortable headers, a scroll wrapper (focusable, labelled from the <caption>), an empty state, and a status line for screen readers.
data-ld-search, data-ld-search-columns, data-ld-search-placeholder Adds a search box; fuzzy, every word must match. Restrict to columns with 0,2 (0-based). Escape clears it.
data-ld-page-size, data-ld-page-sizes Rows per page, and a rows-per-page select (10,25,50).
data-ld-select A checkbox column with select-all; selection survives paging and filtering. Selected rows get data-ld-selected and aria-selected.
data-ld-empty Text shown when nothing matches.
data-ld-sortable="false", th[data-ld-nosort] Turn header sorting off for the table, or for one column.
th[data-ld-sort-type], td[data-ld-sort-value] number, date or text (auto-detected otherwise); sort by a hidden value instead of the visible text.
ld:table:sort, :filter, :page, :select, :render Events on the table. select carries { count, rows }.
ldcss.table.refresh(el), .selected(el), .clearSelection(el), .search(el, q), .page(el, n) Control from script.

Lists and timelines #

Stacked rows and vertical history

Class / attribute What it does
ld-list, ld-list-item, data-ld-interactive, data-ld-active Values: true
ld-timeline, ld-timeline-item data-ld-variant: accent · danger. Vertical, open-ended (contrast with the fixed-stage ld-stepper); subparts ld-timeline-time/-title/-body

Code and terminal #

Showing code

Class / attribute What it does
ld-code Block <pre>, uses --ld-code-text for contrast-safe color
ld-code-inline Inline <code>
ld-terminal, ld-terminal-bar, ld-terminal-body Terminal window chrome

JavaScript

JavaScript API & events

One event system, automatic initialisation, and the helpers on window.ldcss.

Events #

One naming scheme for every component

Every event is a bubbling CustomEvent named ld:{component}:{event} whose detail says what changed. Events named before-… are cancelable: call preventDefault() to stop the action. ldcss.on() subscribes with optional delegation, once, and a wildcard that sees every event.

ldcss.on('toast:show', (e, detail) => console.log(detail.id));
ldcss.on('table:select', (e, d) => console.log(d.count), { target: '#people' });
ldcss.once('modal:show', () => track('opened'));
ldcss.on('*', (e) => console.debug(e.type, e.detail));   // everything

const off = ldcss.on('combobox:select', handler);
off();                                                    // unsubscribe
Event Detail
ld:modal|offcanvas|dropdown|popover|command:show, :hide show: { trigger }; hide: {}. Fired on the panel (or backdrop) and bubbles.
ld:tab:show, ld:accordion:show, :hide, ld:carousel:show, ld:navbar:show, :hide Component specific; see the component.
ld:disclosure:before-show, :show, :before-hide, :hide { trigger }; before- is cancelable.
ld:contextmenu:before-show, :show, :hide, :select { region, target, x, y }; select: { item, value, region, target, menu }.
ld:toast:show, :hide, :action, :update { id, variant }, { id, reason }, { id, action }.
ld:notification:add, :read, :dismiss, :clear { notification, updated }, { id } or { all, ids }.
ld:resize:start, :move, :end { sizes, handle } (percentages).
ld:combobox:search, :results, :error, :cancel, :select { query }, { query, count, items }, { query, error }, { item, value, label, option }.
ld:table:sort, :filter, :page, :select, :render { key, column, direction }, { query, count }, { page, pageSize, pageCount }, { count, rows }, { visible, total }.
ld:search:filter { query, count, total }.
ld:init Fired on an element ldcss just initialised after it was added to the page.
ld:{component}:init, :destroy One component was set up or torn down on this element: { component }. See Component lifecycle.
ld:theme:change Fired on <html> by ldcss.theme.set() and the theme toggle: { theme, setting } (theme is what shows, setting is light, dark or auto).
ldcss.on(type, fn, options), ldcss.off(type, fn), ldcss.once(type, fn, options) type with or without the ld: prefix, or "*". Options: target (selector; only events from inside it), root, once. on returns an unsubscribe function.
ldcss.emit(el, name, detail, cancelable) Dispatch your own ld: events through the same system; returns false when cancelled.

Component lifecycle #

Set up, tear down and set up again, one component or a whole subtree

Components that need per-element setup (tables, search bars, comboboxes, resizable panels, carousels, disclosures, tooltips and more) are registered by name. You can initialise just one, destroy it and have everything it attached removed, and initialise it again after changing its content. The page-wide mutation observer uses the same calls: markup that is added gets init, markup that is removed gets destroy, so nothing leaks timers, observers or listeners.

destroy removes what the component created: its listeners, observers and timers, generated markup (a table's toolbar, a search bar's button, a combobox's result rows), and attributes it added. The original markup is put back, in its original order. Open dialogs inside are closed. Delegated behaviour (tabs, toggles, the context menu) stops responding there until init runs again, which is what data-ld-destroyed on the element means. A combobox's source and render functions survive, so reinit picks up where it was.

ldcss.init('#people');                       // every component inside #people
ldcss.init('#people', 'table');              // only the table component
ldcss.destroy('#people');                    // everything inside, and its listeners
ldcss.destroy('#people table', ['table']);   // names can be an array
ldcss.reinit('#people');                     // destroy + init, e.g. after replacing its content

const table = ldcss.component('table');      // handle for one component
table.init(el); table.destroy(el); table.reinit(el);
table.has(el);                               // is this element set up?
table.instances();                           // every live table
const stop = table.on('sort', (e, d) => {}); // ld:table:sort, same options as ldcss.on
ldcss.on('toast:show', fn, { root: panel }); // removed when panel is destroyed

ldcss.destroy();                             // the whole page: components, observer, every listener
ldcss.init();                                // and back

Live example

Lifecycle demo
Name Score
Ada 91
Grace 88
Linus 72
Margaret 95
Dennis 67
(lifecycle events appear here)
Call What it does
ldcss.init(target?, names?) Set up components inside target (element, selector or list; default the whole page), or only the named ones. Safe to call again: already initialised elements are skipped. ldcss.refresh is the old name.
ldcss.destroy(target?, names?) Tear down. With a target: that subtree (or just the named components). With no arguments: the whole page, which also stops the mutation observer and removes every document and window listener ldcss attached.
ldcss.reinit(target, names?) Destroy then init. Use after replacing the content of a component, or after changing a data-ld-* setting that is only read at setup.
ldcss.component(name) A handle: init, destroy, reinit, has(el), instances(root?), on(type, fn, options), once(...). Throws with the list of known names when the name is wrong.
ldcss.components(), ldcss.isInitialized(el, name?) The registered names; whether an element is set up (for one component, or any).
Component names progress, animate, pagination, rating, stepper, tabs, segmented, scrollspy, carousel, input-clear, autosize, disclosure, context-menu, notification, combobox, resizable, search, table, tooltip, carousel-a11y.
ld:{component}:init, ld:{component}:destroy Events on the element when one component is set up or torn down; detail.component is the name. They bubble and reach ldcss.on and the "*" listener.
ldcss.on(type, fn, { root: el }) A listener owned by el: it is removed automatically when el (or an ancestor) is destroyed or removed from the page.
data-ld-destroyed Set by destroy on the element. While present, ldcss ignores events from inside it. init clears it.
Always-on pieces Components that work only through document-level listeners (modal, dropdown, toggles) have nothing per element to tear down; destroying a subtree closes anything open inside it and silences it. Accessibility attributes added by the accessibility layer are not removed.

Initialisation and observer #

Add markup whenever you like

ldcss starts a MutationObserver on <body>, so components in markup added later (from fetch, innerHTML or a framework) work with no extra call, and components in markup that is removed are destroyed. It pauses while it initialises, so what it builds itself can never cause a loop.

<body data-ld-observe="false">      <!-- opt out for the whole page -->
<div data-ld-no-observe> … </div>   <!-- opt out for one subtree -->

ldcss.unobserve();  ldcss.observe();   // runtime switch
ldcss.refresh(someElement);            // manual init of one subtree
Class / attribute What it does
data-ld-observe="false" On <body>: do not watch the page for added markup.
data-ld-no-observe On any element: markup inside it is left alone.
ldcss.observe(), ldcss.unobserve() Start or stop watching.
ldcss.refresh(root?) The old name of ldcss.init(root); see Component lifecycle.
ldcss.destroy() Everything: all components, the mutation observer and every document/window listener. ldcss.init() starts it all again. See Component lifecycle for the targeted form.
ldcss.debounce(fn, ms) The debounce helper ldcss uses internally.
ldcss.version The version string.

Live announcements #

Tell screen readers something happened

Class / attribute What it does
ldcss.announce(message, politeness?) Speaks message through a shared live region. politeness is "polite" (default) or "assertive". Saying the same sentence twice still announces.

JavaScript

Accessibility

What ldcss does for you, and a checker for what it cannot.

What is built in #

Fills in what the markup did not say

ldcss adds missing ARIA and keyboard behaviour on load and for every element added later. It never overrides an attribute you set yourself.

Component What ldcss adds
Nav, sidebar and pager links aria-current follows data-ld-active (page, or location for in-page links).
Dropdown role="menu"/menuitem, aria-haspopup, aria-expanded, aria-controls; ↑ ↓ Home End and type-ahead; Esc returns focus.
Modal, offcanvas, command palette Named from their first heading; everything outside is inert while open; Esc closes.
Combobox role="combobox", aria-expanded, aria-controls, aria-activedescendant, aria-busy, and a live region for result counts.
Tooltip Becomes the accessible name of icon-only controls, or aria-description; Esc hides it.
Progress bar, chip role="progressbar" with min/max/label; aria-pressed on chips, kept in sync.
Carousel aria-roledescription, "n of m" slide labels, labelled dots; autoplay stops under reduced motion and while focus is inside.
Tables Sort buttons with aria-sort; status line announces sort, filter and page changes; scroll area is focusable and labelled.
Forms in ld-validate aria-invalid once a field has been touched.
Toasts, copy button Polite live region; danger toasts use role="alert"; "Copied to clipboard" is announced.
Form controls Edges use --ld-border-control (3:1 or better). Focus shows a border colour change plus a ring, and a transparent outline that Windows High Contrast turns into a real one; the range slider shows a ring on its thumb.
Small controls Dots, tag remove buttons and the like keep their look but get a 24 × 24px hit area (WCAG 2.5.8).
Dialogs Stacked dialogs each return focus to what opened them; Escape closes one layer at a time; the focus trap skips hidden and disabled elements; only the top dialog is reachable.
Keyboard events Handlers tolerate keydown events with no key (sent by browser autofill) or with a non-element target.
Context menu, resize handle role="menu" with roving focus; role="separator" with aria-valuenow/min/max.

Audit #

Find the common mistakes ldcss cannot fix

ldcss.a11y.audit() checks the page (or a subtree) for the mistakes that cannot be fixed automatically and returns them as a list. It also prints a table to the console. It is a quick lint, not a full audit: automated tools find only a part of real accessibility problems.

const problems = ldcss.a11y.audit();           // whole page
ldcss.a11y.audit(document.querySelector('#form'));  // one subtree

Live example

(results appear here)
Rule Reports
img-alt Images without an alt attribute (use alt="" when decorative).
name Buttons, links and role="button" elements with no accessible name.
label Form fields with no label, or labelled only by a placeholder.
duplicate-id, aria-ref Repeated ids; aria-controls/labelledby/describedby pointing at an id that does not exist.
tabindex, keyboard Positive tabindex; role="button" that cannot take focus.
lang, landmark, title, h1, heading-order Page level: missing lang, <main>, <title>, not exactly one <h1>, skipped heading levels.

User preferences #

Respecting the system

Class / attribute What it does
forced-colors: active, prefers-reduced-transparency No markup change — buttons/cards/inputs drop their shadow under Windows High Contrast Mode and defer to its palette; ld-glass falls back to a solid surface when transparency is reduced

Build

Build & contribute

Edit src/, run the build, ship dist/.

The build #

No dependencies, Node 18+

ldcss builds with one script and nothing to install. npm run build once, or npm run watch to rebuild on every change. Edit src/, never dist/.

npm run build     # writes dist/
npm run watch     # rebuilds when src/ or build/ changes
Path Purpose
src/css/<layer>/sNN-name.css One file per section, one folder per cascade layer (tokens, base, components, utilities, escape, variants). Files join in name order.
src/css/**/*.gen.mjs Generated CSS: the sidebar breakpoints, sizing/flex/grid/transition utilities, hover/focus variants. They read the breakpoint table, so a breakpoint is defined once.
src/css/head.css @font-face for the self-hosted font (outside the layers).
src/js/theme-init.js Becomes dist/ldcss-theme.js, the tiny <head> script that applies a saved theme before first paint.
Automatic dark rules The build copies every rule starting with [data-ld-theme="dark"] into a prefers-color-scheme: dark block (selector :where(:root):not([data-ld-theme]), same specificity). Write dark rules once; the automatic ones follow.
src/js/ldcss.js The core: shared helpers, the original components and the public API.
src/js/modules/NN-name.js One file per newer component (fuzzy, disclosure, context menu, toasts, resizable, combobox, search, tables, accessibility). Inlined into the core in name order, so modules share its helpers.
build/config.mjs Breakpoints and layer order.
dist/ The output: ldcss.css, ldcss.min.css, ldcss.js, ldcss.min.js, fonts/.

Adding things #

Components, utilities, variants

Class / attribute What it does
New component Add src/css/components/sNN-name.css (and a file in src/js/modules/ if it needs behaviour). Behaviour should register with moduleInits.push(fn) so the mutation observer initialises it too, and report through emit().
New utility Add it to a file in src/css/utilities/, or to the table in s38q-sizing-flex-grid-transitions.gen.mjs.
hover:/focus: for a utility Add its name to the lists in src/css/variants/s40-state-variants.gen.mjs.
Change a breakpoint Edit build/config.mjs. Generated CSS follows; hand-written responsive utilities still spell out their widths.
Retired section numbers §16 and §29 are unused and stay unused so existing references remain valid.