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 radiusEach 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.
'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.
export const palette = { neutral: { main: '#64748B' } };
export const breakpoints = { values: { mobile: 0, tablet: 640 } };import { createTheme } from '@hone-ui/react/styles';
import { breakpoints, palette } from './theme-options';
export const theme = createTheme({ palette, breakpoints });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.
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.
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.
<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.