Configuration

Set up HelProvider, the config, fonts, tokens and themes, and override any value without touching a component.

Hel UI is configured in three places, and each one has a different job.

  • HelProvider and 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 added

index.css pulls it together. The order matters: Hel UI first, then your overrides, so yours win.

src/styles/index.css
@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.

src/providers.tsx
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>
  )
}
PropWhat it doesDefault
configThe app configuration, described below. Pass false to leave the provider out.{}
directionReading direction, "ltr" or "rtl". Pass false to leave it out."ltr"
themeProps for the theme provider, like defaultTheme and storageKey. Pass false if you set data-theme yourself.{}
navigateConnects every Link to your router, for example (path) => router.push(path). Without it, links are plain anchors.none
toasterMounts the toast system. Pass true, or an object of toaster props. Needed for useToast.false
providersProviders 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.

src/hel.config.ts
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.

KeyWhat it controls
fontsThe Latin and Arabic font families and the fallback list.
localeThe locale used to format dates, times and numbers. When it is missing, the page lang and then the browser language are used.
fallbackLocalesMaps a language to a locale your browsers can format, such as { ckb: "ar-IQ" }.
numbersCompact notation, digits, rounding and the tone of positive and negative numbers.
moneyThe default currency, how it is displayed, and per-currency settings.
datesThe calendar, time zone, clock, named formats, relative times and durations.
inputsPhone input defaults and named masks, like { taxId: "####-######" }.
shortcutsWhether keyboard shortcut hints (Kbd chips) show. Set visible: false to hide them all.
colorColor picker defaults: format, alpha, and the row of swatches.
errorsA describe function that turns your own errors into messages.
messagesTranslations 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.

LayerPrefixRole
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.

src/styles/tokens/semantic.css
: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:

dark theme
: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.

src/styles/themes/ocean.css
[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

PropWhat it doesDefault
themesThe theme names. Each needs a matching [data-theme] block.["light", "dark"]
defaultThemeThe theme to start with when nothing is stored."system"
systemThemesWhich themes the "system" option maps to.{ light: "light", dark: "dark" }
storageKeyWhere the choice is remembered. Pass null to not remember it."hel-theme"
forcedThemeLocks one theme and ignores the stored choice. Useful for previews.none
targetThe 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);
}