Hel UI is configured in three places, and each one has a different job.
HelProviderand the config hold everything that is data: fonts, locale, currency, date formats and the text components show.- Tokens are CSS variables. They hold every color, size, radius and font stack the components use.
- Themes decide which token values apply right now, like light or dark.
You never edit the library. You override values from your own files, and every component picks them up.
Suggested folder structure#
Keep your overrides in one place so it is obvious what you changed. This layout mirrors how the library is organized, so a token lives in the same kind of file it comes from.
src/
hel.config.ts the config (fonts, locale, money, messages)
providers.tsx HelProvider, wrapped around the app
styles/
index.css the one file your app imports
fonts.css your own @font-face rules, if you use custom fonts
tokens/
primitives.css overrides for --brand-* (colors, scale, font weights)
semantic.css overrides for --alias-* (accent, radius, border width)
components.css overrides for --mapped-* sizes and spacing
themes/
light.css your tweaks to a built-in theme
ocean.css a theme you addedindex.css pulls it together. The order matters: Hel UI first, then your overrides, so yours win.
@import "tailwindcss";
@import "@hel/ui/styles.css";
@import "@hel/ui/fonts.css";
@import "./tokens/primitives.css";
@import "./tokens/semantic.css";
@import "./tokens/components.css";
@import "./themes/ocean.css";You don't need every file. Create the ones you use, and delete the rest.
HelProvider#
HelProvider wraps your app once and sets up everything the components expect, in the right order: the theme, the config, text direction, links and toasts. Every prop is optional.
import { HelProvider } from "@hel/ui/provider"
import { helConfig } from "./hel.config"
export function Providers({ children }: { children: React.ReactNode }) {
return (
<HelProvider
config={helConfig}
direction="ltr"
theme={{ defaultTheme: "system" }}
toaster
>
{children}
</HelProvider>
)
}| Prop | What it does | Default |
|---|---|---|
config | The app configuration, described below. Pass false to leave the provider out. | {} |
direction | Reading direction, "ltr" or "rtl". Pass false to leave it out. | "ltr" |
theme | Props for the theme provider, like defaultTheme and storageKey. Pass false if you set data-theme yourself. | {} |
navigate | Connects every Link to your router, for example (path) => router.push(path). Without it, links are plain anchors. | none |
toaster | Mounts the toast system. Pass true, or an object of toaster props. Needed for useToast. | false |
providers | Providers from other packages, nested inside. Build each entry with provider(Component, props). | [] |
Using the pieces on their own#
HelProvider is a convenience. Each part works alone, so you can mount only what you need.
import { HelConfigProvider } from "@hel/ui/config"
import { ThemeProvider } from "@hel/ui/theme"
<ThemeProvider>
<HelConfigProvider config={helConfig}>
<App />
</HelConfigProvider>
</ThemeProvider>The config#
Write your config with defineConfig. It gives you autocomplete and fills in anything you leave out from the defaults.
import { defineConfig } from "@hel/ui/config"
export const helConfig = defineConfig({
fonts: {
latin: { body: "Geist" },
arabic: { body: "Cairo" },
},
locale: "en-GB",
money: { currency: "EUR" },
})Objects are merged key by key, so you only write what changes. Arrays, like fonts.fallback, are replaced as a whole.
| Key | What it controls |
|---|---|
fonts | The Latin and Arabic font families and the fallback list. |
locale | The locale used to format dates, times and numbers. When it is missing, the page lang and then the browser language are used. |
fallbackLocales | Maps a language to a locale your browsers can format, such as { ckb: "ar-IQ" }. |
numbers | Compact notation, digits, rounding and the tone of positive and negative numbers. |
money | The default currency, how it is displayed, and per-currency settings. |
dates | The calendar, time zone, clock, named formats, relative times and durations. |
inputs | Phone input defaults and named masks, like { taxId: "####-######" }. |
shortcuts | Whether keyboard shortcut hints (Kbd chips) show. Set visible: false to hide them all. |
color | Color picker defaults: format, alpha, and the row of swatches. |
errors | A describe function that turns your own errors into messages. |
messages | Translations for the text components show, by language. |
Messages#
The library ships with English only. To translate, add a language under messages, and list only the components you change.
export const helConfig = defineConfig({
messages: {
de: {
autocomplete: { clear: "Löschen", noResults: "Keine Ergebnisse." },
},
},
})A language you don't list falls back to English. A de-CH entry wins over de for that region, and a component's own messages prop wins over everything here.
Reading the config#
Use useHelConfig() inside any component to read the resolved config.
import { useHelConfig } from "@hel/ui/config"
function Currency() {
const { money } = useHelConfig()
return <span>{money.currency}</span>
}Fonts#
Fonts work through the config and through CSS variables. The config writes the variables for you.
defineConfig({
fonts: {
latin: { body: "Geist", heading: "Geist" },
arabic: { body: "Cairo" },
fallback: ["system-ui", "sans-serif"],
},
})Each language has a body font and an optional heading font. When heading is missing, it uses the body font. The two languages are listed together in one stack, so the browser picks the right font per character. Latin comes first in left-to-right layouts, and Arabic comes first in right-to-left layouts, in lang="ar" content, and inside any element with dir="rtl".
You can also set the variables directly in CSS:
:root {
--hel-font-latin-body: "Geist";
--hel-font-latin-heading: var(--hel-font-latin-body);
--hel-font-arabic-body: "Cairo";
--hel-font-arabic-heading: var(--hel-font-arabic-body);
--hel-font-fallback: system-ui, sans-serif;
}The config only names a font. It doesn't load it. @hel/ui/fonts.css loads Inter and IBM Plex Sans Arabic. If you choose other fonts, drop that import and load yours with @font-face or a font package. The names must match.
Tokens#
Tokens come in three layers, and each one only points at the layer below. Colors are written in OKLCH, so two colors with the same lightness look equally light.
| Layer | Prefix | Role |
|---|---|---|
| Primitives | --brand-* | The raw values. The only place a literal is allowed. |
| Semantic | --alias-* | Gives a primitive a job, like "accent" or "error". |
| Component | --mapped-* | What components actually read: sizes, spacing and, through themes, colors. |
Override at the lowest layer that makes sense. Change --brand-color-blue-300 and everything blue changes. Change --alias-color-accent-50 and only the accent changes.
Primitives#
The color ramps run from step 50 to 1000, and the dark theme reads them from light to dark. Gray, blue, red, green, orange, amber and champagne all follow the same step pattern. Gray and blue are shown here.
:root {
--brand-color-black: oklch(0.0% 0.0 0);
--brand-color-white: oklch(100.0% 0.0 0);
}:root {
--brand-color-gray-0: oklch(100.0% 0.0 0); /* #ffffff · accessible text */
--brand-color-gray-50: oklch(94.91% 0.0 0); /* #eeeeee · accessible text */
--brand-color-gray-100: oklch(76.99% 0.0 0); /* #b4b4b4 · accessible text */
--brand-color-gray-200: oklch(58.29% 0.0 0); /* #7b7b7b · solid / saturated */
--brand-color-gray-300: oklch(53.82% 0.0 0); /* #6e6e6e · solid / saturated */
--brand-color-gray-400: oklch(48.91% 0.0 0); /* #606060 · borders & separators */
--brand-color-gray-500: oklch(40.17% 0.0 0); /* #484848 · borders & separators */
--brand-color-gray-600: oklch(34.85% 0.0 0); /* #3a3a3a · borders & separators */
--brand-color-gray-700: oklch(31.32% 0.0 0); /* #313131 · interactive components */
--brand-color-gray-800: oklch(28.5% 0.0 0); /* #2a2a2a · interactive components */
--brand-color-gray-900: oklch(25.2% 0.0 0); /* #222222 · interactive components */
--brand-color-gray-950: oklch(21.34% 0.0 0); /* #191919 · backgrounds */
--brand-color-gray-1000: oklch(17.76% 0.0 0); /* #111111 · backgrounds */
}:root {
--brand-color-blue-50: oklch(90.71% 0.051 238.45); /* #c2e6ff · accessible text */
--brand-color-blue-100: oklch(76.42% 0.1257 249.46); /* #70b8ff · accessible text */
--brand-color-blue-200: oklch(68.84% 0.1693 251.4); /* #3b9eff · solid / saturated */
--brand-color-blue-300: oklch(64.93% 0.193 251.78); /* #0090ff · solid / saturated */
--brand-color-blue-400: oklch(54.06% 0.1395 253.17); /* #2870bd · borders & separators */
--brand-color-blue-500: oklch(47.41% 0.1219 253.09); /* #205d9e · borders & separators */
--brand-color-blue-600: oklch(41.6% 0.1133 252.01); /* #104d87 · borders & separators */
--brand-color-blue-700: oklch(36.71% 0.1059 250.7); /* #004074 · interactive components */
--brand-color-blue-800: oklch(32.01% 0.0968 252.35); /* #003362 · interactive components */
--brand-color-blue-900: oklch(27.45% 0.0663 253.93); /* #0d2847 · interactive components */
--brand-color-blue-950: oklch(21.29% 0.0303 261.25); /* #111927 · backgrounds */
--brand-color-blue-1000: oklch(19.36% 0.0255 256.49); /* #0d1520 · backgrounds */
}Overlay colors are fixed colors at fixed opacities. There are matching ramps for red, green, amber and blue.
:root {
--brand-color-alpha-black-50: oklch(0% 0 0 / 4%);
--brand-color-alpha-black-100: oklch(0% 0 0 / 8%);
--brand-color-alpha-black-200: oklch(0% 0 0 / 12%);
--brand-color-alpha-black-300: oklch(0% 0 0 / 16%);
--brand-color-alpha-black-400: oklch(0% 0 0 / 24%);
--brand-color-alpha-black-500: oklch(0% 0 0 / 32%);
--brand-color-alpha-white-50: oklch(100% 0 0 / 4%);
--brand-color-alpha-white-100: oklch(100% 0 0 / 8%);
--brand-color-alpha-white-200: oklch(100% 0 0 / 12%);
--brand-color-alpha-white-300: oklch(100% 0 0 / 16%);
--brand-color-alpha-white-400: oklch(100% 0 0 / 24%);
--brand-color-alpha-white-500: oklch(100% 0 0 / 32%);
}Fonts and the spacing scale. The scale is in rem and assumes a 16px root font size.
:root {
--brand-font-family-body: Inter;
--brand-font-family-body-serif: "Gilda Display";
--brand-font-family-heading-serif: "Gilda Display";
--brand-font-family-headings: Inter;
--brand-font-weight-light: 300;
--brand-font-weight-regular: 400;
--brand-font-weight-medium: 500;
--brand-font-weight-semi-bold: 600;
--brand-font-weight-bold: 700;
--brand-font-weight-extra-bold: 800;
}:root {
--brand-scale-0: 0rem; /* 0px */
--brand-scale-25: 0.0625rem; /* 1px */
--brand-scale-50: 0.125rem; /* 2px */
--brand-scale-100: 0.25rem; /* 4px */
--brand-scale-150: 0.375rem; /* 6px */
--brand-scale-200: 0.5rem; /* 8px */
--brand-scale-250: 0.625rem; /* 10px */
--brand-scale-300: 0.75rem; /* 12px */
--brand-scale-350: 0.875rem; /* 14px */
--brand-scale-400: 1rem; /* 16px */
--brand-scale-500: 1.25rem; /* 20px */
--brand-scale-600: 1.5rem; /* 24px */
--brand-scale-700: 1.75rem; /* 28px */
--brand-scale-800: 2rem; /* 32px */
--brand-scale-900: 2.5rem; /* 40px */
--brand-scale-1000: 3rem; /* 48px */
--brand-scale-1100: 4rem; /* 64px */
}Semantic tokens#
Semantic tokens answer "what job does this color do". Accent, link, neutral, error, info, success and warning each have a 50 to 1000 ramp pointing at a primitive. The accent currently mirrors neutral, so the system is monochrome. To give it a hue, point each step at a different ramp.
:root {
--alias-color-accent-50: var(--brand-color-champagne-50);
--alias-color-accent-100: var(--brand-color-champagne-100);
--alias-color-accent-200: var(--brand-color-champagne-200);
--alias-color-accent-300: var(--brand-color-champagne-300);
--alias-color-accent-400: var(--brand-color-champagne-400);
--alias-color-accent-500: var(--brand-color-champagne-500);
--alias-color-accent-600: var(--brand-color-champagne-600);
--alias-color-accent-700: var(--brand-color-champagne-700);
--alias-color-accent-800: var(--brand-color-champagne-800);
--alias-color-accent-900: var(--brand-color-champagne-900);
--alias-color-accent-950: var(--brand-color-champagne-950);
--alias-color-accent-1000: var(--brand-color-champagne-1000);
}The default accent, for reference:
:root {
--alias-color-accent-50: var(--alias-color-neutral-50);
--alias-color-accent-100: var(--alias-color-neutral-100);
--alias-color-accent-200: var(--alias-color-neutral-200);
--alias-color-accent-300: var(--alias-color-neutral-300);
--alias-color-accent-400: var(--alias-color-neutral-400);
--alias-color-accent-500: var(--alias-color-neutral-500);
--alias-color-accent-600: var(--alias-color-neutral-600);
--alias-color-accent-700: var(--alias-color-neutral-700);
--alias-color-accent-800: var(--alias-color-neutral-800);
--alias-color-accent-900: var(--alias-color-neutral-900);
--alias-color-accent-950: var(--alias-color-neutral-950);
--alias-color-accent-1000: var(--alias-color-neutral-1000);
}Every status ramp is built the same way. Here is error, which points at red:
:root {
--alias-color-error-50: var(--brand-color-red-50);
--alias-color-error-100: var(--brand-color-red-100);
--alias-color-error-200: var(--brand-color-red-200);
--alias-color-error-300: var(--brand-color-red-300);
--alias-color-error-400: var(--brand-color-red-400);
--alias-color-error-500: var(--brand-color-red-500);
--alias-color-error-600: var(--brand-color-red-600);
--alias-color-error-700: var(--brand-color-red-700);
--alias-color-error-800: var(--brand-color-red-800);
--alias-color-error-900: var(--brand-color-red-900);
--alias-color-error-950: var(--brand-color-red-950);
--alias-color-error-1000: var(--brand-color-red-1000);
}Radius and border width are semantic too. Lower the radius values for sharper corners, or raise them for rounder ones.
:root {
--alias-border-radius-none: var(--brand-scale-0);
--alias-border-radius-sm: var(--brand-scale-50);
--alias-border-radius-md: var(--brand-scale-100);
--alias-border-radius-lg: var(--brand-scale-200);
--alias-border-radius-xl: var(--brand-scale-300);
--alias-border-radius-2xl: var(--brand-scale-400);
--alias-border-radius-3xl: var(--brand-scale-500);
--alias-border-radius-4xl: var(--brand-scale-600);
--alias-border-radius-pill: 624.9375rem; /* 9999px */
}:root {
--alias-border-width-none: var(--brand-scale-0);
--alias-border-width-sm: var(--brand-scale-25);
--alias-border-width-md: var(--brand-scale-50);
--alias-border-width-lg: var(--brand-scale-100);
}Component tokens#
These set the spacing and the size of controls. Components read them for padding, gaps, and the height of buttons and fields.
:root {
--mapped-spacing-none: var(--brand-scale-0);
--mapped-spacing-px: var(--brand-scale-25);
--mapped-spacing-3xs: var(--brand-scale-50);
--mapped-spacing-2xs: var(--brand-scale-100);
--mapped-spacing-xs: var(--brand-scale-150);
--mapped-spacing-sm: var(--brand-scale-200);
--mapped-spacing-md: var(--brand-scale-250);
--mapped-spacing-lg: var(--brand-scale-300);
--mapped-spacing-xl: var(--brand-scale-400);
--mapped-spacing-2xl: var(--brand-scale-500);
--mapped-spacing-3xl: var(--brand-scale-600);
--mapped-spacing-4xl: var(--brand-scale-800);
--mapped-spacing-full: 62500rem;
}:root {
--mapped-control-size-xs: var(--brand-scale-600); /* 24px */
--mapped-control-size-sm: var(--brand-scale-700); /* 28px */
--mapped-control-size-md: var(--brand-scale-800); /* 32px */
--mapped-control-size-xl: var(--brand-scale-900); /* 40px */
--mapped-control-size-lg: 2.25rem; /* 36px */
--mapped-height-control-xs: var(--mapped-control-size-xs);
--mapped-height-control-sm: var(--mapped-control-size-sm);
--mapped-height-control-md: var(--mapped-control-size-md);
--mapped-height-control-lg: var(--mapped-control-size-lg);
--mapped-height-control-xl: var(--mapped-control-size-xl);
--mapped-width-control-xs: var(--mapped-control-size-xs);
--mapped-width-control-sm: var(--mapped-control-size-sm);
--mapped-width-control-md: var(--mapped-control-size-md);
--mapped-width-control-lg: var(--mapped-control-size-lg);
--mapped-width-control-xl: var(--mapped-control-size-xl);
--mapped-height-badge-sm: 1.125rem; /* 18px */
--mapped-height-badge-md: var(--brand-scale-500); /* 20px */
}Typography tokens#
Font sizes and line heights are tokens too. Each step has a size, a line height and a paragraph spacing.
:root {
--responsive-h1-font-size: 3.75rem; /* 60px */
--responsive-h1-line-height: 4.25rem; /* 68px */
--responsive-h1-paragraph-spacing: 0rem; /* 0px */
--responsive-h2-font-size: 3rem; /* 48px */
--responsive-h2-line-height: 3.25rem; /* 52px */
--responsive-h2-paragraph-spacing: 0rem; /* 0px */
--responsive-h3-font-size: 2.5rem; /* 40px */
--responsive-h3-line-height: 2.75rem; /* 44px */
--responsive-h3-paragraph-spacing: 0rem; /* 0px */
--responsive-h4-font-size: 2rem; /* 32px */
--responsive-h4-line-height: 2.25rem; /* 36px */
--responsive-h4-paragraph-spacing: 0rem; /* 0px */
--responsive-h5-font-size: 1.5rem; /* 24px */
--responsive-h5-line-height: 1.75rem; /* 28px */
--responsive-h5-paragraph-spacing: 0rem; /* 0px */
--responsive-h6-font-size: 1.25rem; /* 20px */
--responsive-h6-line-height: 1.5rem; /* 24px */
--responsive-h6-paragraph-spacing: 0rem; /* 0px */
--responsive-paragraph-2xs-font-size: 0.625rem; /* 10px */
--responsive-paragraph-2xs-line-height: 0.75rem; /* 12px */
--responsive-paragraph-2xs-paragraph-spacing: 0rem; /* 0px */
--responsive-paragraph-xs-font-size: 0.75rem; /* 12px */
--responsive-paragraph-xs-line-height: 1rem; /* 16px */
--responsive-paragraph-xs-paragraph-spacing: 0rem; /* 0px */
--responsive-paragraph-sm-font-size: 0.875rem; /* 14px */
--responsive-paragraph-sm-line-height: 1rem; /* 16px */
--responsive-paragraph-sm-paragraph-spacing: 0.1875rem; /* 3px */
--responsive-paragraph-md-font-size: 1rem; /* 16px */
--responsive-paragraph-md-line-height: 1.25rem; /* 20px */
--responsive-paragraph-md-paragraph-spacing: 0rem; /* 0px */
--responsive-paragraph-lg-font-size: 1.25rem; /* 20px */
--responsive-paragraph-lg-line-height: 1.5rem; /* 24px */
--responsive-paragraph-lg-paragraph-spacing: 0rem; /* 0px */
}Hel UI also gives you ready-made text classes: text-body-2xs to text-body-lg, and text-heading-h1 to text-heading-h6. Body classes take a weight utility, like text-body-sm font-medium. Headings have a fixed semibold weight.
Using tokens in your own code#
The color tokens are registered with Tailwind, so you can use them as utilities in your own components and they follow the active theme.
<Stack className="bg-surface-base-page text-fg-base-primary">
<p className="text-fg-base-muted">Follows the theme</p>
</Stack>Themes#
A theme is a block of CSS variables scoped to a data-theme attribute. Light and dark are built in. Dark is also set on :root, so it applies when no theme is chosen.
Themes only set the --mapped-* colors. Those are the tokens components read, grouped as border, icon, ring, surface and text. This is the whole dark theme, which you can copy as a starting point:
:root,
[data-theme="dark"] {
color-scheme: dark;
--mapped-border-primary: var(--alias-color-accent-50);
--mapped-border-primary-hover: var(--alias-color-accent-300);
--mapped-border-base-default: var(--alias-color-neutral-600);
--mapped-border-base-hairline: var(--alias-color-neutral-800);
--mapped-border-base-primary: var(--alias-color-neutral-50);
--mapped-border-base-strong: var(--alias-color-neutral-500);
--mapped-border-base-subtle: var(--alias-color-neutral-700);
--mapped-border-destructive-default: var(--alias-color-overlay-red-300);
--mapped-border-destructive-emphasis: var(--alias-color-error-300);
--mapped-border-destructive-error: var(--alias-color-overlay-red-500);
--mapped-border-destructive-strong: var(--alias-color-overlay-red-400);
--mapped-border-destructive-subtle: var(--alias-color-error-500);
--mapped-border-interactive-control: var(--alias-color-neutral-700);
--mapped-border-status-info: var(--alias-color-info-300);
--mapped-border-status-success: var(--alias-color-success-300);
--mapped-border-status-warning: var(--alias-color-warning-300);
--mapped-border-alert-neutral: var(--alias-color-overlay-white-200);
--mapped-border-alert-info: var(--alias-color-overlay-blue-300);
--mapped-border-alert-success: var(--alias-color-overlay-green-300);
--mapped-border-alert-warning: var(--alias-color-overlay-amber-300);
--mapped-border-alert-error: var(--alias-color-overlay-red-300);
--mapped-border-thumb: var(--alias-color-neutral-700);
--mapped-border-system-disabled: var(--alias-color-neutral-700);
--mapped-icon-solid: var(--alias-color-neutral-900);
--mapped-icon-base-muted: var(--alias-color-neutral-200);
--mapped-icon-base-primary: var(--alias-color-neutral-50);
--mapped-icon-destructive-emphasis: var(--alias-color-error-300);
--mapped-icon-destructive-solid: var(--alias-color-neutral-50);
--mapped-icon-required: var(--alias-color-error-300);
--mapped-icon-status-danger: var(--alias-color-error-300);
--mapped-icon-status-error: var(--alias-color-error-300);
--mapped-icon-status-info: var(--alias-color-info-300);
--mapped-icon-status-success: var(--alias-color-success-400);
--mapped-icon-status-warning: var(--alias-color-warning-400);
--mapped-icon-system-disabled: var(--alias-color-neutral-500);
--mapped-ring-error: oklch(from var(--alias-color-error-300) l c h / 60%);
--mapped-ring-focus: oklch(from var(--alias-color-accent-50) l c h / 40%);
--mapped-surface-primary: var(--alias-color-accent-50);
--mapped-surface-primary-active: var(--alias-color-accent-50);
--mapped-surface-primary-hover: var(--alias-color-accent-100);
--mapped-surface-secondary: var(--alias-color-overlay-white-100);
--mapped-surface-secondary-hover: var(--alias-color-overlay-white-200);
--mapped-surface-base-muted: var(--alias-color-neutral-900);
--mapped-surface-base-page: var(--alias-color-neutral-1000);
--mapped-surface-base-raised: var(--alias-color-neutral-950);
--mapped-surface-destructive-emphasis: var(--alias-color-overlay-red-300);
--mapped-surface-destructive-emphasis-active: var(--alias-color-overlay-red-300);
--mapped-surface-destructive-emphasis-hover: var(--alias-color-overlay-red-400);
--mapped-surface-interactive-control: var(--alias-color-overlay-white-100);
--mapped-surface-interactive-hover: var(--alias-color-overlay-white-100);
--mapped-surface-interactive-panel: var(--alias-color-overlay-white-50);
--mapped-surface-interactive-selected: var(--alias-color-overlay-white-300);
--mapped-surface-scroll-thumb: var(--alias-color-overlay-white-300);
--mapped-surface-scroll-thumb-hover: var(--alias-color-overlay-white-400);
--mapped-surface-skeleton: var(--alias-color-overlay-white-100);
--mapped-surface-skeleton-shimmer: var(--alias-color-overlay-white-300);
--mapped-surface-amount-neutral: var(--alias-color-overlay-white-100);
--mapped-surface-amount-positive: var(--alias-color-overlay-green-100);
--mapped-surface-amount-negative: var(--alias-color-overlay-red-100);
--mapped-surface-amount-caution: var(--alias-color-overlay-amber-100);
--mapped-surface-status-danger: var(--alias-color-overlay-red-100);
--mapped-surface-status-error: var(--alias-color-overlay-red-100);
--mapped-surface-status-info: var(--alias-color-overlay-blue-100);
--mapped-surface-status-success: var(--alias-color-overlay-green-100);
--mapped-surface-status-warning: var(--alias-color-overlay-amber-100);
--mapped-surface-system-disabled: var(--alias-color-neutral-800);
--mapped-surface-system-scrim: var(--alias-color-overlay-black-500);
--mapped-text-link: var(--alias-color-link-100);
--mapped-text-link-hover: var(--alias-color-link-200);
--mapped-text-solid: var(--alias-color-neutral-900);
--mapped-text-base-muted: var(--alias-color-neutral-100);
--mapped-text-base-placeholder: var(--alias-color-neutral-200);
--mapped-text-base-primary: var(--alias-color-neutral-50);
--mapped-text-amount-neutral: var(--alias-color-neutral-50);
--mapped-text-amount-positive: var(--alias-color-success-400);
--mapped-text-amount-negative: var(--alias-color-error-300);
--mapped-text-amount-caution: var(--alias-color-warning-400);
--mapped-text-destructive-danger: var(--alias-color-error-300);
--mapped-text-destructive-emphasis: var(--alias-color-error-300);
--mapped-text-destructive-solid: var(--alias-color-neutral-50);
--mapped-text-status-error: var(--alias-color-error-300);
--mapped-text-status-info: var(--alias-color-info-400);
--mapped-text-status-success: var(--alias-color-success-400);
--mapped-text-status-warning: var(--alias-color-warning-400);
--mapped-text-system-disabled: var(--alias-color-neutral-500);
}A good theme points its values at --alias-color-* and not at literal colors, so changing the tokens still changes the theme. A token you leave out falls back to the dark value, so define all of them.
Add a theme#
Create the file
Copy the light theme into a new file, change the selector to your theme's name, and change the values.
[data-theme="ocean"] {
color-scheme: dark;
--mapped-surface-base-page: oklch(20% 0.04 240);
--mapped-surface-base-raised: oklch(24% 0.05 240);
--mapped-text-base-primary: oklch(96% 0.01 240);
/* ...and the rest of the --mapped-* tokens */
}Import it
Import the file in your index.css, after @hel/ui/styles.css.
@import "./themes/ocean.css";Tell the provider about it
Add the name to the themes list.
<HelProvider theme={{ themes: ["dark", "light", "ocean"] }}>
{children}
</HelProvider>Switch themes#
useTheme() returns the chosen theme, the one actually applied, the list of themes and a setter.
import { useTheme } from "@hel/ui/theme"
export function ThemeSwitch() {
const { theme, themes, setTheme } = useTheme()
return (
<select value={theme} onChange={(event) => setTheme(event.target.value)}>
<option value="system">System</option>
{themes.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
)
}theme is what the visitor picked, and it can be "system". resolvedTheme is what is applied right now.
Theme provider options#
| Prop | What it does | Default |
|---|---|---|
themes | The theme names. Each needs a matching [data-theme] block. | ["light", "dark"] |
defaultTheme | The theme to start with when nothing is stored. | "system" |
systemThemes | Which themes the "system" option maps to. | { light: "light", dark: "dark" } |
storageKey | Where the choice is remembered. Pass null to not remember it. | "hel-theme" |
forcedTheme | Locks one theme and ignores the stored choice. Useful for previews. | none |
target | The element that gets data-theme. Pass one to theme only part of the page. | the page root |
Quick recipes#
Make everything a bit rounder:
:root {
--alias-border-radius-md: 0.5rem;
--alias-border-radius-lg: 0.75rem;
}Make controls taller:
:root {
--mapped-control-size-md: 2.25rem;
--mapped-control-size-lg: 2.5rem;
}Change one color in dark mode only:
[data-theme="dark"] {
--mapped-surface-base-page: oklch(15% 0.01 260);
}