Hone UI

Theming

Pick a look, customize it with createTheme, and control dark mode.

Theming uses two pieces. createTheme builds a theme and ThemeProvider applies it. The work happens at build time and in CSS. A theme becomes CSS variables, and the stylesheet does the rest, so switching themes or color schemes never re-runs JavaScript styling.

1. Import a look

Every app imports exactly one look, once, at its root. Import your own global CSS before it:

import '@hone-ui/react/styles/material.css'; // the default appearance
// or
import '@hone-ui/react/styles/minimal.css'; // neutral greys, thin borders, 6px radius

Each look sets every theme variable for light and dark and styles every component. In the apps we measured, the CSS came to 125 to 137 kB, or 18 to 19 kB gzipped. All of it sits in @layer hone-ui, so your own CSS overrides it without !important.

The same rule applies to your global CSS. Unlayered CSS beats every layered rule, whatever its specificity, and it beats compiled sx too. That includes the src/index.css of a Vite template and the app/globals.css of create-next-app. A box-sizing: border-box reset is safe, because no component size depends on the box model, and so is font: inherit on input. A reset that sets margin: 0 or padding: 0 on *, or font: inherit on button, removes spacing and fonts that the components set on purpose, and nothing inside the layer can win that. Put such a reset in an earlier layer:

@layer reset, hone-ui;

@layer reset {
  *,
  *::before,
  *::after {
    margin: 0;
    padding: 0;
  }
}

The first line only works if this file loads before the look. The look's CSS starts with @layer hone-ui, so whichever file loads first fixes the layer order. Import this file above the look, as in the layout in the dark mode section below. Rules outside any layer, such as body colors, still beat the library, so delete or layer those too.

2. Customize with createTheme

createTheme takes the usual theme options: palette, colorSchemes, typography, spacing, shape, breakpoints and components. To bring in a theme written for another library, check five things. Keys under components are plain component names such as Button. Selectors in styleOverrides must target the hn- classes this library renders. Function overrides are skipped, so move them to sx. Numbers in styleOverrides go through the same maths as sx, so borderRadius: 16 means 16 times the theme's shape unit, not 16px. Write '16px' for pixels. components.<Name>.variants is not supported. Rules that depend on props need a styling engine in the browser, and this library ships compiled CSS only. A theme that sets variants logs one warning in development and nothing in production. Move those rules into styleOverrides slots or sx.

theme.tsx
'use client';

import { createTheme, ThemeProvider } from '@hone-ui/react/styles';
import type { ReactNode } from 'react';

const theme = createTheme({
  colorSchemes: {
    light: { palette: { primary: { main: '#6750a4' } } },
    dark: { palette: { primary: { main: '#d0bcff' } } },
  },
  typography: { fontFamily: 'Inter, sans-serif' },
  shape: { borderRadius: 8 },
});

export function AppTheme({ children }: { children: ReactNode }) {
  return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
}

ThemeProvider writes only the variables your theme changes. Options that rescale everything with Material's math, such as typography.fontSize or palette.tonalOffset, write only the values that end up different, and those replace the Minimal look's own values. A custom primary.main also yields primary.light, primary.dark and a readable primary.contrastText. Everything you leave out keeps the look's value, which is why the same theme works under either look.

Custom colors in sx

sx reads a custom color such as 'neutral.main' only when the build plugin knows its palette key. Put the palette in a shared module, then pass it to createTheme and to the plugin. Without the plugin's copy, the build warns and sx writes the value as raw CSS.

theme-options.ts
export const palette = { neutral: { main: '#64748B' } };
export const breakpoints = { values: { mobile: 0, tablet: 640 } };
theme.ts
import { createTheme } from '@hone-ui/react/styles';
import { breakpoints, palette } from './theme-options';

export const theme = createTheme({ palette, breakpoints });
vite.config.ts
import react from '@vitejs/plugin-react';
import sx from '@hone-ui/sx/vite';
import { defineConfig } from 'vite';
import { breakpoints, palette } from './src/theme-options';

export default defineConfig({
  plugins: [sx({ palette, breakpoints }), react()],
});

Any custom color works in sx with the shades main, light, dark and contrastText, such as bgcolor: 'neutral.main', with no plugin option. Another shade name, such as 'neutral.500', needs the name in paletteKeys: sx({ paletteKeys: ['neutral'] }).

Types for custom colors

Palette, PaletteOptions and BreakpointOverrides live in @hone-ui/react/styles, so augment that module. Augment @hone-ui/react and TypeScript ignores it.

Don't name this file theme.d.ts next to theme.tsx. TypeScript skips a .d.ts file whose base name matches a .ts or .tsx file, so the augmentation does nothing and next build fails. Use a different name, such as hone-ui.d.ts.

hone-ui.d.ts
import '@hone-ui/react/styles';

declare module '@hone-ui/react/styles' {
  interface Palette {
    neutral: { main: string; light: string; dark: string; contrastText: string };
  }
  interface PaletteOptions {
    neutral?: { main: string; light?: string; dark?: string; contrastText?: string };
  }
  interface BreakpointOverrides {
    xs: false;
    sm: false;
    md: false;
    lg: false;
    xl: false;
    mobile: true;
    tablet: true;
    laptop: true;
    desktop: true;
  }
}

The color prop of these components already accepts any string, so color="neutral" type-checks. These components render a custom palette key as color: Alert, AppBar, Badge, Button, Checkbox, Chip, CircularProgress, FormControl, IconButton, LinearProgress, Radio, Slider, Switch and TextField.

A custom variant also needs a type for the variant prop of Typography. Add its name to TypographyPropsVariantOverrides with declare module '@hone-ui/react/Typography' { interface TypographyPropsVariantOverrides { poster: true } }, and variant="poster" type-checks. Mapping a built-in name to false removes it from the prop type.

Variants can also set textTransform and fontStyle. typography: { button: { textTransform: 'none' } } turns off the capital letters on every Button.

The 'use client' line matters in Next.js. A theme holds functions such as theme.spacing, so it can't be passed from a Server Component to a Client Component.

Restyle a component for the whole app

components.<Name>.styleOverrides restyles every instance of a component. Each key is a slot, and a slot is the hn-<Name>-<slot> class the component renders, such as root for .hn-Button-root.

const theme = createTheme({
  components: {
    Button: {
      styleOverrides: {
        root: { textTransform: 'none', px: 3, '&:hover': { opacity: 0.9 } },
        startIcon: { marginRight: 1 },
      },
    },
  },
});

Values resolve as they do in sx: color: 'primary.main' reads the theme color and px: 3 multiplies the spacing unit. A nested key is a selector, where & stands for the slot ('&:hover', '& .child') or an at-rule such as '@media (hover: none)'. A map like { xs: 1, md: 2 } or an array sets a value per breakpoint.

The rules sit in @layer hone-ui-overrides, between the look and sx. An override beats the look without !important, while sx and your own className rules beat the override.

Overrides must be plain objects. A function value, such as root: (props) => ({ ... }), is skipped with a warning in development. Put anything that depends on props in sx. Only the root ThemeProvider writes overrides, so a nested provider's styleOverrides have no effect.

Styles for one color scheme

theme.applyStyles(mode, styles) returns styles wrapped so they apply only in that color scheme. Spread the result into a slot:

const base = createTheme();

const theme = createTheme({
  components: {
    Button: {
      styleOverrides: {
        root: {
          boxShadow: 'none',
          ...base.applyStyles('dark', { border: '1px solid', borderColor: 'divider' }),
        },
      },
    },
  },
});

The result is two keys, a selector that matches the nearest [data-color-scheme='dark'] and a media query for prefers-color-scheme that matches while the attribute is absent. Both compile to plain CSS, so the scheme switch costs no JavaScript. Use 'light' for the other side.

applyStyles belongs in styleOverrides. The @hone-ui/sx plugin can't evaluate a call, so sx={{ ...theme.applyStyles(...) }} does not compile; in a static sx, write the scheme selector yourself ('[data-color-scheme="dark"] &': { … }). A function sx runs in the browser and turns into inline styles, which can't hold selectors. There it keeps the plain styles, drops the scheme rules and warns in development. For a value that changes with the scheme at runtime, use theme.vars instead, because a variable already switches with the scheme.

3. Dark mode

Both looks ship light and dark values. By default the page follows the device setting. Read and change the mode with useColorScheme:

'use client';

import { useColorScheme } from '@hone-ui/react/styles';

export function ModeToggle() {
  const { mode, systemMode, setMode } = useColorScheme();
  if (!mode) return null; // undefined until hydrated

  // The default mode is 'system': toggle from the scheme the page shows.
  const current = mode === 'system' ? systemMode : mode;
  return (
    <button onClick={() => setMode(current === 'dark' ? 'light' : 'dark')}>
      {current === 'dark' ? 'Light mode' : 'Dark mode'}
    </button>
  );
}

mode is what was chosen: 'light', 'dark' or 'system'. While it is 'system', systemMode holds the device's scheme.

setMode remembers the choice in localStorage and updates other open tabs. setMode('system') follows the device again.

To stop a stored dark mode from flashing light on page load, render InitColorSchemeScript at the top of <body>. It runs before the page paints.

app/layout.tsx
import './globals.css'; // your own CSS first, so a `@layer reset, hone-ui;` line takes effect
import '@hone-ui/react/styles/material.css';
import InitColorSchemeScript from '@hone-ui/react/InitColorSchemeScript';
import { AppTheme } from './theme';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <InitColorSchemeScript />
        <AppTheme>{children}</AppTheme>
      </body>
    </html>
  );
}

suppressHydrationWarning is needed because the script sets data-color-scheme on <html> before React hydrates. If you pass defaultMode or modeStorageKey to ThemeProvider, pass the same values to InitColorSchemeScript.

To force a mode, pass mode="dark" to ThemeProvider. A theme that sets any palette option without colorSchemes also locks the page to one mode: light unless palette.mode says 'dark'. That keeps a pasted theme's colors from being swapped for the look's dark defaults. Add colorSchemes to let users switch.

In dark mode the Material look lightens raised surfaces: Paper, Card, the Dialog paper and the Select popup mix 5% to 16% white into the paper color, more at higher elevation. Light mode and the Minimal look keep the plain paper color.

A scheme for one section

Put data-color-scheme="light" or "dark" on any element to give its subtree that scheme, such as a light brand band on a dark page. The nearest attribute wins.

<Box data-color-scheme="light" sx={{ bgcolor: 'background.default', p: 4 }}>
  <TextField label="Email" />
</Box>

Give the section its own background, because the page behind it keeps its scheme. Theme variables and components follow the nearest scope at any depth. Rules you write with theme.applyStyles and Tailwind dark: classes follow it one level deep, so a dark scope inside a light scope inside a dark page keeps their light styles.

Render one root ThemeProvider per page. A nested provider is fine and changes what useTheme() returns, but two separate roots share the page's single color-scheme attribute, so roots with different modes overwrite each other.

Without Next.js

InitColorSchemeScript is a React component, so a Vite single-page app has no server to render it. Paste the script it renders into index.html instead, at the top of <body>. This is the output for the defaults: the 'hn-mode' storage key and the 'system' mode.

index.html
<body>
  <script>
    (function () {
      try {
        var m;
        try {
          m = localStorage.getItem('hn-mode');
        } catch (e) {}
        if (m !== 'light' && m !== 'dark' && m !== 'system') m = 'system';
        var d = document.documentElement;
        if (m === 'system') d.removeAttribute('data-color-scheme');
        else d.setAttribute('data-color-scheme', m);
      } catch (e) {}
    })();
  </script>
  <div id="root"></div>
  <script type="module" src="/src/main.tsx"></script>
</body>

If you pass defaultMode or modeStorageKey to ThemeProvider, change "system" and "hn-mode" in the script to match.

If your site sends a Content Security Policy that blocks inline styles or scripts, pass the same nonce to ThemeProvider and InitColorSchemeScript.

CSS variables

Each theme value has a variable named after its path: theme.palette.primary.main is --hn-palette-primary-main, and theme.shape.borderRadius is --hn-shape-borderRadius. Use them in your own CSS:

.banner {
  background: var(--hn-palette-primary-main);
  color: var(--hn-palette-primary-contrastText);
  padding: calc(2 * var(--hn-spacing));
  border-radius: var(--hn-shape-borderRadius);
}

For translucent colors, alpha, lighten and darken accept a variable and return color-mix():

alpha('var(--hn-palette-primary-main)', 0.12);
// 'color-mix(in srgb, var(--hn-palette-primary-main) 12%, transparent)'

useTheme().palette holds the default values merged with your options. Those match the Material look. Under the Minimal look, read the CSS variable when you need the color the page actually shows.

theme.vars

theme.vars mirrors the theme, with a var(--hn-...) string at every leaf. It covers palette (custom colors too), typography, shape.borderRadius, spacing, shadows and zIndex.

function Badge() {
  const theme = useTheme();
  return (
    <span
      style={{
        color: theme.vars.palette.primary.contrastText,
        background: theme.vars.palette.primary.main,
        borderRadius: theme.vars.shape.borderRadius,
        boxShadow: theme.vars.shadows[2],
      }}
    />
  );
}

Unlike theme.palette, these strings are the same in light and dark, and they follow the look. The browser resolves them, so they also work in a function sx, where applyStyles can't.

Limits

Server Components. Box, Stack, Typography, Paper, Card, CardContent, CardActions, CardHeader, CardMedia, DialogContent, DialogContentText, DialogActions and CssBaseline can render on the server, so they can't read the theme. components.<Name>.defaultProps has no effect on them. ThemeProvider warns once in development and names the components. Set those props on the element, or wrap the component in your own 'use client' component that sets them. styleOverrides works for all of them, because it compiles to CSS.

Strict CSP. The direction and spacing props of Stack, responsive or not, and the color prop of Typography write CSS custom properties in an inline style attribute. A policy with style-src-attr 'none' blocks them, and nonce doesn't help because attributes can't carry one. The theme's <style> element and the compiled sx don't have this problem. Under such a policy, leave those props off and set flexDirection, gap and color in sx or styleOverrides instead.

Tailwind

If your app uses Tailwind v4, import our tokens after Tailwind:

@import 'tailwindcss';
@import '@hone-ui/react/tailwind.css';

You then get classes such as bg-primary, text-primary-foreground, bg-paper, border-divider, rounded-shape and shadow-elevation-2. They read the same variables, so they follow your theme and the color scheme. The file also points Tailwind's dark: variant at data-color-scheme, so dark: agrees with useColorScheme.

On this page