Theming
Every color, corner, easing curve and shadow in Noph UI comes from a CSS custom property. Components never hard code a value, so a theme is nothing more than a set of variables you own. Change one and every component that reads it follows, at build time or while the page is running.
How the theme works
A theme declares 54 color roles on :root. Each one holds both schemes at once, in a light-dark() pair, and color-scheme: light dark tells the browser it may pick either.
:root {
color-scheme: light dark;
--np-color-primary: light-dark(#00668c, #75ceff);
--np-color-on-primary: light-dark(#f4f9ff, #00435d);
--np-color-primary-container: light-dark(#75ceff, #4eaad9);
--np-color-on-primary-container: light-dark(#00435d, #002637);
}The same file also carries the shape, motion and elevation tokens, which are listed further down. Because it is plain CSS, you can ship it as a stylesheet, inline it, or overwrite single values from a component.
Using the default theme
Import the default theme once, in your root layout.
import 'noph-ui/defaultTheme'Then hand the page background and text color to the theme.
body {
background-color: var(--np-color-background);
color: var(--np-color-on-background);
}The default theme is the source color #5fb9e9 run through the Content variant on the 2025
spec with no contrast adjustment. That is why the generator below starts out matching the shipped file
exactly, token for token.
Generate your own theme
Pick a source color and Noph UI derives all 54 roles for both schemes. Every change here applies to this page right away, so you can judge a palette on real components instead of swatches. Copy CSS puts a complete replacement for the default theme on your clipboard. Reset brings back the shipped theme.
Each swatch shows the value for the color scheme you are viewing. The two hex values below a token are its light and its dark value.
Generating a scheme in code
The generator is roughly twenty lines on top of material-color-utilities. Install it as a dev dependency. It produces CSS, so it never reaches your users and Noph UI stays free of runtime dependencies.
npm install -D @materialx/material-color-utilitiesBuild one scheme per color scheme from the same source color, then write both hex values into a light-dark() pair. Role names map to tokens by turning camel case into dashes, so onPrimaryContainer becomes --np-color-on-primary-container.
import {
argbFromHex,
DynamicScheme,
Hct,
hexFromArgb,
SpecVersion,
Variant,
} from '@materialx/material-color-utilities'
const options = {
sourceColorHct: Hct.fromInt(argbFromHex('#5fb9e9')),
variant: Variant.CONTENT,
specVersion: SpecVersion.SPEC_2025,
contrastLevel: 0,
}
const light = DynamicScheme.from({ ...options, isDark: false })
const dark = DynamicScheme.from({ ...options, isDark: true })
const token = (role) => '--np-color-' + role.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
const declarations = ['primary', 'onPrimary', 'primaryContainer', 'onPrimaryContainer'].map(
(role) =>
token(role) + ': light-dark(' + hexFromArgb(light[role]) + ', ' + hexFromArgb(dark[role]) + ');',
)
console.log(':root {\n\t' + declarations.join('\n\t') + '\n}')Write the result to a CSS file and import it instead of noph-ui/defaultTheme, or set
the properties on document.documentElement to switch themes without a reload.
Variants
The variant decides how far the palette travels from the source color. Try each one in the generator above, the differences are easier to see than to describe.
| Variant | Character |
|---|---|
CONTENT | Keeps the source color itself, good for brand colors |
FIDELITY | Like content, with tighter fidelity to the source hue |
TONAL_SPOT | The classic Material You look, moderate chroma |
VIBRANT | High chroma accents around the source hue |
EXPRESSIVE | Shifts hue away from the source for a playful palette |
NEUTRAL | Barely colorful, close to grey |
MONOCHROME | Greyscale, no chroma at all |
RAINBOW | Neutral surfaces with colorful accents |
FRUIT_SALAD | Hue shifted primary and secondary, the loudest option |
Contrast
contrastLevel runs from -1 to 1. Zero is the design as
specified, one is maximum contrast, minus one the lowest. Raising it is the quickest way to meet a
stricter contrast requirement without picking new colors by hand.
Spec versions
SPEC_2021 is the original Material 3 color system. SPEC_2025 is the Material
3 Expressive revision, with brighter containers and different surface tones, and it is what the default
theme uses.
Light and dark color schemes
Because every token is a light-dark() pair, the browser follows the operating system setting
on its own. No JavaScript, no class toggling.
To force one scheme, set data-theme on the html element. Valid values
are light and dark.
<html lang="en" data-theme="dark">If visitors get to choose, store the choice and set the attribute in an inline <script> in the <head> of your app.html, before
the page paints. That avoids a flash of the wrong scheme.
const stored = localStorage.getItem('theme')
if (stored === 'light' || stored === 'dark') {
document.documentElement.setAttribute('data-theme', stored)
}Applying a theme at runtime
Setting the custom properties on the root element beats swapping stylesheets, since only the changed values repaint. This is exactly what the generator above does.
const setTheme = (tokens) => {
for (const [token, value] of Object.entries(tokens)) {
document.documentElement.style.setProperty(token, value)
}
}
setTheme({ '--np-color-primary': 'light-dark(#00668c, #75ceff)' })Call removeProperty with the same token names to fall back to the stylesheet again.
Color roles
Roles come in families. Every accent family has a base color, an on- color for
content drawn on top of it, a container variant and an on- color for that container. The
generator above lists all 54 with the values your current settings produce.
| Family | What it is for |
|---|---|
| Primary | The main action and anything that should read as branded |
| Secondary | Supporting components that should stay quieter than primary |
| Tertiary | Accents that need to contrast with primary |
| Error | Validation, destructive actions |
| Surface | Backgrounds, from surface-container-lowest up to highest |
| Outline | Borders and dividers, outline-variant for the subtle ones |
| Inverse | Inverted surfaces such as snackbars and value labels |
| Fixed | Colors that keep the same value in both schemes |
| Palette key colors | The key color of each tonal palette, useful for tooling |
| Utility | background, shadow, scrim and surface-tint |
Shape tokens
Corner radii are shared by every component, so rounding stays consistent across the app.
| Token | Value |
|---|---|
--np-shape-corner-none | 0 |
--np-shape-corner-extra-small | 0.25rem |
--np-shape-corner-small | 0.5rem |
--np-shape-corner-medium | 0.75rem |
--np-shape-corner-large | 1rem |
--np-shape-corner-extra-large | 1.75rem |
--np-shape-corner-full | 9999px |
Motion tokens
Motion tokens pair a duration with an easing curve. Spatial tokens move things, effects tokens change color and opacity. The expressive curves overshoot slightly and settle, the standard curves do not.
| Token | Duration |
|---|---|
--np-motion-expressive-fast-spatial | 350ms |
--np-motion-expressive-default-spatial | 500ms |
--np-motion-expressive-slow-spatial | 650ms |
--np-motion-expressive-fast-effects | 150ms |
--np-motion-expressive-default-effects | 200ms |
--np-motion-expressive-slow-effects | 300ms |
--np-motion-standard-fast-spatial | 350ms |
--np-motion-standard-default-spatial | 500ms |
--np-motion-standard-slow-spatial | 750ms |
--np-motion-standard-fast-effects | 150ms |
--np-motion-standard-default-effects | 200ms |
--np-motion-standard-slow-effects | 300ms |
transition: background-color var(--np-motion-expressive-default-effects);Elevation tokens
Three layered shadows, from the lightest lift to the most pronounced. Cards, menus and dialogs use them, and so can you.
box-shadow: var(--np-elevation-1);
box-shadow: var(--np-elevation-2);
box-shadow: var(--np-elevation-3);Overriding a single component
Components expose their own properties, named --np-<component>-<part>-<role>. Set one as a style prop to restyle
a single instance, or on any ancestor to restyle a whole region.
<Button variant="filled">Default</Button>
<Button
variant="filled"
--np-filled-button-container-color="var(--np-color-tertiary)"
--np-filled-button-label-text-color="var(--np-color-on-tertiary)"
>
Tertiary
</Button>
<Button variant="filled" shape="square" --np-button-shape="var(--np-shape-corner-extra-small)">
Square
</Button>Read the token a component documents before you set it. --np-button-shape holds the
corner radius of the square button shape, so it only takes effect together with shape="square". A round button is a pill, and its radius follows its height.
Point these at theme tokens rather than raw hex values and your overrides keep working when the theme changes. Each component page lists the properties it understands in its own Theming section.