Liqui Design
Handbook

Theming

Twelve custom properties, four global optics defaults, and four multipliers — plus the editor that writes them for you.

liqui is themed along two axes, and they work nothing alike.

Colour is CSS. Every colour a surface paints comes from a --lq-* custom property. Change twelve variables and the whole library changes with them — including the parts React never sees, like a popup portalled to document.body.

Optics are JavaScript. refraction, bezel, radius and profile feed a canvas-generated displacement map and an SVG filter, so they cannot be custom properties. They are props, and they arrive globally through a provider.

The editor

The theme editor turns both halves as dials and hands back exactly the snippets on this page. Three things about it are worth knowing before you open it:

  • It themes the whole site, not the panel next to it. Walk back into the docs and every component page is wearing what you made. That is the only honest way to judge a theme — a swatch grid will not tell you whether your tint survives on a menu.
  • It stays in your browser. The theme is kept in localStorage and nothing is sent anywhere. Copy link encodes it into the URL if you want to hand it to someone.
  • It only ever emits what you changed. Both snippets, and the stored value, are a diff against the shipped theme — so a later liqui release retuning a default you never touched still reaches you.

The tokens

TokenWhat it paints
--lq-textContent on glass.
--lq-text-dimLabels, placeholders, secondary text.
--lq-tintThe glass itself — the light end of the tint gradient.
--lq-tint-deepThe dark end of that gradient.
--lq-rim-hiThe bright catch-light along the top edge.
--lq-rim-loThe dimmer counter-light along the bottom.
--lq-shadowDepth under an elevated surface. Two layers.
--lq-highlightThe hover wash on interactive surfaces.
--lq-accentButtons, focus rings, checked state, selection.
--lq-dangerDestructive fills.
--lq-danger-textThe same red, dark enough to read as text on light glass.
--lq-scrimThe wash behind a modal surface.

Everything else in the --lq- namespace is per-component plumbing — --lq-tail-rim on a popup's arrow, --lq-radius written per surface from the radius prop. Setting those globally does nothing useful.

If you installed through the shadcn CLI, all twelve are already in your globals.css under :root and .dark, and editing them there is the whole job. If you imported the stylesheet instead, override after the import:

@import '@liqui-design/glass/tokens.css';

:root {
  --lq-accent: #8b5cf6;
  --lq-tint: rgba(250, 246, 255, 0.4);
}

[data-theme='dark'],
.dark {
  --lq-accent: #b794ff;
  --lq-tint: rgba(32, 22, 56, 0.58);
}

Dark mode is keyed on both [data-theme='dark'] and .dark: the first is liqui's own convention, the second is what next-themes writes. Write your overrides under both, or they will apply in only one of the two setups.

The two selectors have the same specificity as :root, so an override block written after the import wins — but a :root override written after it also beats the stylesheet's dark block. Always write the pair.

The optics provider

import { LiquiThemeProvider } from '@liqui-design/glass';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <LiquiThemeProvider
      theme={{
        glass: {
          frost: 0.2,
          dispersion: 0.35,
          refractionScale: 1.25,
        },
      }}
    >
      {children}
    </LiquiThemeProvider>
  );
}

The provider takes colour too — theme.light and theme.dark accept the same twelve tokens and are written into a <style> at :root. That is for a theme that changes at runtime, like a user-facing appearance setting. A theme that is fixed at build time belongs in your stylesheet, where it costs nothing.

Absolute defaults vs multipliers

The glass theme has two halves, and the difference is the whole design.

Absolutematerial, profile, frost, specular, dispersion, saturation. These are the dials components mostly leave alone, so the theme supplies the default. A component that sets one for itself keeps it: the preview-card asks for frost: 0.6 because its design needs it, and a theme that says 0.2 does not take it away.

MultipliersradiusScale, refractionScale, bezelScale, blurScale. These are the dials every component sets for itself, and it sets them to different numbers on purpose:

radiusrefractionbezel
Scroll-area thumb6164
Button124511
Popover1812024
Drawer2615034

The numbers scale with the surface. A global "set refraction to 150" would turn that scrollbar thumb into a smear. A multiplier moves all four rows together and keeps them in proportion, which is what "make the glass thicker" actually means.

// Every surface's bezel, 30% wider than the component asked for.
<LiquiThemeProvider theme={{ glass: { bezelScale: 1.3 } }}>

Precedence

An explicit prop beats the theme, and the theme beats liqui's default:

// frost: 0.9 — the prop wins over any theme.
<LiquiGlass frost={0.9} />

// frost: whatever the theme says, else 0.35.
<LiquiGlass />

Multipliers are not overridable this way, because they are not values — they apply to whatever value ends up in play, prop or default.

An untouched theme is a no-op by construction: every multiplier is 1, every absolute equals liqui's own default, and the provider emits no <style> at all until a token actually differs. Mounting it changes nothing.

Making a theme

Start from the accent and the tint, in that order. The accent is the one colour a visitor will read as yours; the tint is what decides whether the material reads as glass or as a panel.

Then check three things the dials will not tell you:

Look at it in Safari. Refraction only renders in Chromium today, and a theme tuned at frost: 0.06 can be unreadable once the lens is gone and the tint is carrying legibility alone. The glass handbook has the full degradation table.

Look at both modes. The editor edits one token set at a time, and it is easy to leave the other on a palette that no longer matches.

Look at the danger text. --lq-danger-text is the only token that has to pass as body text rather than as a fill, and it is the first one to fail contrast when a red is picked for how the button looks.

On this page