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 intoindex.html. resolved()is what the page shows: the choice, or the system's scheme while following it.ColorSchemeToggleflips from that, andColorSchemeSwitchermarks it.themeColorcolours 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 itsprefers-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-themeon<html>, for instance withuseHead({ htmlAttrs: { 'data-theme': scheme } }).Following the OS. Without an attribute, the token stylesheet follows
prefers-color-schemeby itself. Leavedata-themeoff until the reader chooses and the page matches their system with no code.The browser's bars. Send
<meta name="theme-color">, one perprefers-color-schememedia query while following the OS, and update itscontentwhen 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-sizeon<html>. The scales are in rem; put the type size on the body or the subtree. - Leave round corners to the
flx.cornerlayer. 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.