GitHub ↗

theming

Three orthogonal axes, color scheme, accent, and design language, driven by CSS attributes.


Fluixi UI themes through three independent attributes on any element (usually <html>), so they compose freely, dark + violet + Material all at once:

Axis Attribute Values
Color scheme data-theme light · dark · high-contrast
Accent data-accent blue (default) · violet · rose · green
Design language data-ui fluixi (default) · material · soft · paper · glass
Glass transparency data-ui-transparency solid · medium (default) · liquid · ultra
Glass backdrop data-ui-backdrop graphite (default) · aurora · tide · dusk · orchard · bloom
Corners data-ui-corner default (the skin's own) · round

Every component reads semantic CSS variables (--flx-ui-color-primary, ...), so changing an attribute re-themes the whole tree with no per-component work.

Setup

Import the token variables and the default skin:

import '@fluixi-ui/tokens/tokens.css';
import '@fluixi-ui/styles/all.css';

Prefer the stylesheet over the runtime injectTokens(). The component rules are in <head> from the first byte; injecting the variables they reference from JavaScript means the first paint happens with no --flx-ui-* defined, which is a visible flash on every server-rendered page. injectTokens() is still there for a runtime that has no bundler step, and both carry the same variables, scales, semantics, dark, high-contrast, and the accent presets.

Light and dark

ColorSchemeProvider drives the data-theme axis. Put it at the app root and a switch anywhere below:

import { ColorSchemeProvider, ColorSchemeSwitcher, ColorSchemeToggle } from '@fluixi-ui/theme';

<ColorSchemeProvider storageKey="app-scheme" themeColor={{ light: '#ffffff', dark: '#0a0a0a' }}>
  <Header>
    <ColorSchemeToggle>◐</ColorSchemeToggle>
  </Header>
  <Settings>
    <ColorSchemeSwitcher labels={{ light: 'Light', dark: 'Dark' }} />
  </Settings>
  <App />
</ColorSchemeProvider>

It follows the operating system until the reader chooses, then keeps the choice. It is @fluixi/core's color scheme, re-exported, so everything below holds with or without these components:

  • The choice lives in a cookie, so a server render reads it and sends <html data-theme> already set. A prerendered page cannot read it; the provider sends a small script with the page that applies it before the first paint. Either way there is no flash and no script to copy into index.html.
  • resolved() is what the page shows: the choice, or the system's scheme while following it. ColorSchemeToggle flips from that, and ColorSchemeSwitcher marks it.
  • themeColor colours the browser around the page: Chrome's address bar on Android, Safari's bars on iOS. While following the system both colours go out, each for its prefers-color-scheme; after a choice, the chosen one. <meta name="color-scheme"> follows too, so scrollbars and native controls match.
  • Create it during a render. The provider does. A scheme made at module scope never sees the request on the server, so it always renders the default.

Read it anywhere below with useColorScheme():

const scheme = useColorScheme();
scheme.value();    // 'system' | 'light' | 'dark'
scheme.resolved(); // 'light' | 'dark'
scheme.set('dark');

The attribute holds light or dark while following the system (systemAs: 'resolved', the default), which is what the token stylesheet selects on. storage keeps the choice somewhere else, or null in memory only.

The other axes

An accent, a skin, a density: any other axis is a createTheme from @fluixi/core, one per attribute, with the same cookie, head attribute and before-paint script. Make them at the root and hand them all down with ThemeProvider:

import { ThemeProvider, createColorScheme, createTheme, useTheme, type Theme } from '@fluixi/core';

function Root(props) {
  const scheme = createColorScheme({ storageKey: 'app-scheme' });
  const accent = createTheme({
    attribute: 'data-accent',
    themes: ['blue', 'violet', 'rose', 'green'],
    default: 'blue',
    storageKey: 'app-accent',
  });
  return <ThemeProvider themes={{ scheme, accent }}>{props.children}</ThemeProvider>;
}

// anywhere below
const accent = useTheme<Theme<'blue' | 'violet' | 'rose' | 'green'>>('accent');
accent.set('violet');

useColorScheme() reads the one named scheme. Nested providers add to the axes above them. createTheme also takes a longer list for the scheme itself (['light', 'dark', 'high-contrast']) when you need more than light and dark; toggle() then cycles through it.

SkinProvider and SkinSwitcher do this for the data-ui axis, below.

Accent palettes

data-accent swaps only the accent scale, blue is the built-in default; violet, rose, and green are alternates (Radix violet / crimson / grass). Every color-primary / focus / selection token derives from the accent, so a single attribute recolors the whole UI. Add your own by extending the token stylesheet with a [data-accent='...'] block that overrides --flx-ui-accent-1...12.

Design language (skins)

data-ui switches the visual language. Each skin is a package of its own:

Skin Package Look
fluixi none, the default clean and neutral
material @fluixi-ui/skin-material Material 3: pill buttons, tonal surfaces, filled fields with an underline
soft @fluixi-ui/skin-soft calm and content-first: a grey canvas, white sheets, 8px controls
paper @fluixi-ui/skin-paper warm canvas, hairline rings, recessed fields
glass @fluixi-ui/skin-glass soft's structure in translucent, blurred sheets over the page

material, soft and glass bring their own palette, light and dark, and their own primary colour. Set data-accent to anything but blue and that accent wins over the skin's; blue, like no accent, means the built-in one. high-contrast always uses the token palette.

For a single axis, SkinProvider loads a skin's CSS only when it's active with the load hook. To offer every choice at once, see Appearance controls below.

import { BUILTIN_SKINS, SkinProvider, SkinSwitcher } from '@fluixi-ui/theme';

const sheets = {
  material: () => import('@fluixi-ui/skin-material/styles.css'),
  soft: () => import('@fluixi-ui/skin-soft/styles.css'),
  paper: () => import('@fluixi-ui/skin-paper/styles.css'),
  glass: () => import('@fluixi-ui/skin-glass/styles.css'),
};

<SkinProvider skins={BUILTIN_SKINS} load={(s) => sheets[s]?.()}>
  <SkinSwitcher />
  <App />
</SkinProvider>

Glass transparency

One value drives the glass: --flx-ui-glass-opacity, the opacity of a sheet, from 0.2 to 0.96 (default 0.72). Lowering it clears the sheets and raises the blur, saturation and shadow together, so text stays readable. Set it on the skin root, for a slider, or use a preset on any element above the skin:

<html data-ui="glass" data-ui-transparency="liquid">
Preset Opacity For
solid 0.92 dense dashboards
medium 0.72 the default
liquid 0.48 sheets that show the page
ultra 0.28 mostly clear, heavily blurred

What shows through is --flx-ui-glass-canvas, painted on the skin root. Without an attribute it is an ambient light; data-ui-backdrop picks one of the skin's named backdrops (graphite, aurora, tide, dusk, orchard, bloom), each with a light and a dark version. AppearanceProvider starts a skin on its first backdrop, graphite for glass, until the visitor picks another. Add your own the same way, setting both variables under your own name:

[data-ui-backdrop='forest'] {
  --flx-ui-glass-backdrop-light: url(/forest-day.jpg) center / cover;
  --flx-ui-glass-backdrop-dark: url(/forest-night.jpg) center / cover;
}

With prefers-reduced-transparency, or in a browser without backdrop-filter, the sheets go near-opaque.

SkinProvider is a createTheme on data-ui, kept in a cookie like the scheme; useSkin() reads it below.

Round corners

data-ui-corner="round" turns controls and single-line fields into capsules in every skin: buttons, toggles, inputs, selects, comboboxes, date and number fields, the menubar and the toolbar. Without it each skin keeps its own corners.

<html data-ui="paper" data-ui-corner="round">

Layers opened from those controls follow with a wider curve: menus, select and combobox lists, popovers, hover cards, date pickers, the navigation menu, command palettes and toasts take at least 1rem, dialogs and bottom sheets at least 1.5rem, tooltips 0.5rem. A skin whose curve is already wider keeps it. Menus and lists pad 6px so their items sit concentric inside, and calendar days and steppers become circles.

Cards, tables, textareas, tag inputs and PIN cells keep their corners either way. So do tabs: an underlined tab would bend its line, so only skins whose tabs are pills (soft, glass) round them. The attribute works on any element, so one region of a page can be round on its own.

Material's filled fields take the shape of its search bar: a capsule has no straight edge for the underline, so focus and errors show as a ring inside the field instead.

Appearance controls

AppearanceProvider holds every choice in one place: light or dark, skin, accent, corners, and, for a skin that reads them, transparency and backdrop. Each is an attribute on <html> kept in a cookie, so the server renders the visitor's choice and nothing flashes. Transparency and backdrop stay off <html> while the active skin does not read them, and come back, as they were, when it does. The exact opacity is clamped to TRANSPARENCY_RANGE (0.2 to 0.96).

Every skin package exports a descriptor from /skin. It tells the provider how to load the skin's CSS (only once the skin is first chosen), what to run while it is active (material's ripple, glass's light), and whether it reads transparency and backdrops. A skin of your own is a plain object of the same shape.

import { AppearanceProvider, fluixiSkin } from '@fluixi-ui/theme';
import { AppearancePanel } from '@fluixi-ui/appearance';
import { materialSkin } from '@fluixi-ui/skin-material/skin';
import { softSkin } from '@fluixi-ui/skin-soft/skin';
import { paperSkin } from '@fluixi-ui/skin-paper/skin';
import { glassSkin } from '@fluixi-ui/skin-glass/skin';

<AppearanceProvider skins={[fluixiSkin, materialSkin, softSkin, paperSkin, glassSkin]}>
  <App />
  <AppearancePanel />
</AppearanceProvider>

@fluixi-ui/appearance has the controls, built from fluixi-ui's own components:

Control What it sets
SkinSelect the skin
ColorSchemePicker system, light or dark
AccentPicker the accent, one swatch each, in the scheme on screen; the first shows the skin's own colour
TransparencyControl glass opacity on a slider, and the presets
BackdropSelect the glass backdrop
CornerPicker the skin's own corners, or round ones
AppearancePanel all of the above, labelled; transparency and backdrop only while the skin reads them

Every word comes in through props (labels, and names for the values), for your own translations. For a control of your own, useAppearance() returns each axis: skin, accent, scheme, transparency (opacity(), setOpacity(), preset(), setPreset()), backdrop and corner, with supportsTransparency() and backdrops() for the active skin. defaultCorner="round" on the provider makes round the starting choice.

The cookies belong to the page's own host. Sites on sibling subdomains, such as an app and its sign-in pages, share the choices when the provider writes them for the parent domain:

<AppearanceProvider skins={skins} cookieDomain="example.com">

A skin that brings its own colour publishes it as --flx-ui-skin-primary, so a swatch can show it while another accent is chosen.

Doing it yourself

None of the above is required. The contract between your code and the components is the attributes alone, so anything that sets data-theme, data-accent or data-ui themes the UI: a store of your own, a server that already knows the user's settings, a plain button.

// The whole runtime: set the attribute, remember the choice.
function setScheme(scheme: 'light' | 'dark') {
  document.documentElement.dataset.theme = scheme;
  localStorage.setItem('scheme', scheme);
}

What the helpers do for you, and what you take on without them:

  • Before the first paint. A server-rendered or prerendered page arrives with whatever the server wrote. To avoid showing the wrong theme until your bundle runs, set the attribute from a small inline script in <head>, reading wherever you stored the choice:

    <script>
      try {
        var s = localStorage.getItem('scheme');
        if (s) document.documentElement.dataset.theme = s;
      } catch (e) {}
    </script>
    
  • On the server. Keep the choice in a cookie instead and the server can render the attribute itself, with no script at all for a returning visitor: read the cookie in your render and put data-theme on <html>, for instance with useHead({ htmlAttrs: { 'data-theme': scheme } }).

  • Following the OS. Without an attribute, the token stylesheet follows prefers-color-scheme by itself. Leave data-theme off until the reader chooses and the page matches their system with no code.

  • The browser's bars. Send <meta name="theme-color">, one per prefers-color-scheme media query while following the OS, and update its content when the reader chooses.

The helpers are those four pieces, kept in agreement. Take them over one at a time if you need to: the components never know the difference.

Writing a skin

A skin is a stylesheet in the flx.skin layer, scoped to [data-ui='name']. Most of it should be token values. Set the global scales first (--flx-ui-radius-*, --flx-ui-size-*, --flx-ui-font-*, --flx-ui-shadow-*, motion), then the component tokens below. The components read each one with today's value as the fallback, so nothing is declared until a skin sets it.

Token Read by Default
--flx-ui-control-radius buttons radius-md
--flx-ui-control-padding-x buttons, medium size space-4
--flx-ui-control-press a pressed button's transform none
--flx-ui-field-height input, input group, select trigger, combobox size-md
--flx-ui-field-radius the same radius-md
--flx-ui-field-bg / --flx-ui-field-focus-bg the same, at rest and focused color-bg
--flx-ui-field-border the same, border colour color-border-strong
--flx-ui-field-focus-ring the same, focused ctl-ring
--flx-ui-card-bg / -border / -radius / -shadow card color-bg, 1px color-border, radius-lg, shadow-sm
--flx-ui-surface-bg / -radius surface color-bg, radius-lg
--flx-ui-floating-bg / -border / -radius / -shadow / -padding menu, open listbox, popover color-bg, 1px color-border, radius-lg, shadow-lg, space-1
--flx-ui-item-radius / -padding menu items, options radius-sm, space-2 space-3
--flx-ui-item-active-bg / -fg the highlighted item color-primary-subtle, color-primary-active
--flx-ui-dialog-bg / -border / -radius / -shadow dialog, alert dialog color-bg, 1px color-border, radius-xl, shadow-xl
--flx-ui-tooltip-bg / -fg / -radius tooltip color-fg, color-bg, radius-sm

Then write selector rules only for what a token cannot say, keyed on the components' data attributes and roles, inside :where(). Three things to know:

  • Restate every state you override. The default look's states are :where() too, so a skin's resting rule beats its hover. A rule for a button's background needs its hover and active alongside it.
  • Declare colour tokens in every theme scope. A custom property resolves where it is declared, so a token that names a colour (--flx-ui-card-bg: var(--flx-ui-color-bg)) goes on [data-ui='name'], [data-ui='name'] [data-theme], or a nested dark section inherits the light value.
  • Never set font-size on <html>. The scales are in rem; put the type size on the body or the subtree.
  • Leave round corners to the flx.corner layer. It sits above the skins and rounds the shared controls whatever the skin set. A part it leaves alone that should round in your skin, such as pill tabs, goes under [data-ui='name'][data-ui-corner='round'] and [data-ui='name'] [data-ui-corner='round'].

Scoped theming

The attributes work on any element, not just the root, so a subtree can carry its own scheme or accent. The semantic layer is re-emitted in every scope, so nested regions re-resolve correctly:

<section data-theme="dark" data-accent="rose">
  <!-- dark + rose here, regardless of the page theme -->
</section>

A skin nests the same way. data-ui on an element inside another skin starts that subtree from the default tokens, in the scheme and accent around it, and the outer skin's rules stop at its edge:

<html data-ui="glass">
  <section data-ui="paper">
    <!-- paper here, glass everywhere else -->
  </section>
</html>

One arrangement is out of reach: a skin inside another skin inside itself, glass in paper in glass, leaves the innermost part in neither.

AppearanceScope from @fluixi-ui/theme sets those attributes for you, corners and backdrop included, and takes care of what renders outside the subtree: menus, dialogs and popovers opened inside it mount into a twin of the scope at the end of the body, so they wear its skin and scheme too. A data-ui-corner inside a round page stops the round rules the same way.

import { AppearanceScope } from '@fluixi-ui/theme';

<AppearanceScope skin="paper" scheme="dark" corner="default">
  <Select>...</Select>
</AppearanceScope>

Overriding tokens

The token variables and the default skin live in CSS @layers (flx.tokens, flx.components, flx.skin, in that order). Your CSS stays unlayered, so it always wins, override a token anywhere:

:root { --flx-ui-color-primary: #7c3aed; }

Part of the Fluixi UI component library. Made with ☕ by the Fluixi team.