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.

Spec version
Contrast 0.0

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.

--np-color-background #f7fafe · #0b0f12
--np-color-error #bb1b1b · #ff7164
--np-color-error-container #fe4e44 · #ac0c12
--np-color-inverse-on-surface #9a9da1 · #515559
--np-color-inverse-primary #75ceff · #00668c
--np-color-inverse-surface #0b0f12 · #f7fafe
--np-color-neutral-palette-key-color #73777b · #73777b
--np-color-neutral-variant-palette-key-color #6f787f · #6f787f
--np-color-on-background #2f3336 · #e3e6ea
--np-color-on-error #fff7f6 · #4a0002
--np-color-on-error-container #570003 · #ffb8b0
--np-color-on-primary #f4f9ff · #00435d
--np-color-on-primary-container #00435d · #002637
--np-color-on-primary-fixed #002e41 · #002e41
--np-color-on-primary-fixed-variant #004d6a · #004d6a
--np-color-on-secondary #f4f9ff · #254455
--np-color-on-secondary-container #375567 · #a4c4d9
--np-color-on-secondary-fixed #244354 · #244354
--np-color-on-secondary-fixed-variant #415f71 · #415f71
--np-color-on-surface #2f3336 · #e3e6ea
--np-color-on-surface-variant #5b5f63 · #a8abaf
--np-color-on-tertiary #fff7fc · #623378
--np-color-on-tertiary-container #582a6e · #582a6e
--np-color-on-tertiary-fixed #421358 · #421358
--np-color-on-tertiary-fixed-variant #623378 · #623378
--np-color-outline #777b7f · #727679
--np-color-outline-variant #afb2b6 · #44484c
--np-color-primary #00668c · #75ceff
--np-color-primary-container #75ceff · #4eaad9
--np-color-primary-fixed #75ceff · #75ceff
--np-color-primary-fixed-dim #66c0f0 · #66c0f0
--np-color-primary-palette-key-color #0980ad · #0980ad
--np-color-scrim #000000 · #000000
--np-color-secondary #456375 · #abcbe0
--np-color-secondary-container #c7e7fd · #203f50
--np-color-secondary-fixed #c7e7fd · #c7e7fd
--np-color-secondary-fixed-dim #b9d9ee · #b9d9ee
--np-color-secondary-palette-key-color #5d7b8e · #5d7b8e
--np-color-shadow #000000 · #000000
--np-color-surface #f7fafe · #0b0f12
--np-color-surface-bright #f7fafe · #282d30
--np-color-surface-container #ebeef2 · #161a1d
--np-color-surface-container-high #e5e8ec · #1c2023
--np-color-surface-container-highest #e0e3e7 · #222629
--np-color-surface-container-low #f1f4f8 · #101417
--np-color-surface-container-lowest #ffffff · #000000
--np-color-surface-dim #d7dade · #0b0f12
--np-color-surface-tint #00668c · #75ceff
--np-color-surface-variant #e0e3e7 · #222629
--np-color-tertiary #7c4b92 · #efc2ff
--np-color-tertiary-container #e8affe · #e8affe
--np-color-tertiary-fixed #e8affe · #e8affe
--np-color-tertiary-fixed-dim #d9a2ef · #d9a2ef
--np-color-tertiary-palette-key-color #d29be8 · #d29be8

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-utilities

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

VariantCharacter
CONTENTKeeps the source color itself, good for brand colors
FIDELITYLike content, with tighter fidelity to the source hue
TONAL_SPOTThe classic Material You look, moderate chroma
VIBRANTHigh chroma accents around the source hue
EXPRESSIVEShifts hue away from the source for a playful palette
NEUTRALBarely colorful, close to grey
MONOCHROMEGreyscale, no chroma at all
RAINBOWNeutral surfaces with colorful accents
FRUIT_SALADHue 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.

FamilyWhat it is for
PrimaryThe main action and anything that should read as branded
SecondarySupporting components that should stay quieter than primary
TertiaryAccents that need to contrast with primary
ErrorValidation, destructive actions
SurfaceBackgrounds, from surface-container-lowest up to highest
OutlineBorders and dividers, outline-variant for the subtle ones
InverseInverted surfaces such as snackbars and value labels
FixedColors that keep the same value in both schemes
Palette key colorsThe key color of each tonal palette, useful for tooling
Utilitybackground, shadow, scrim and surface-tint

Shape tokens

Corner radii are shared by every component, so rounding stays consistent across the app.

TokenValue
--np-shape-corner-none0
--np-shape-corner-extra-small0.25rem
--np-shape-corner-small0.5rem
--np-shape-corner-medium0.75rem
--np-shape-corner-large1rem
--np-shape-corner-extra-large1.75rem
--np-shape-corner-full9999px

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.

TokenDuration
--np-motion-expressive-fast-spatial350ms
--np-motion-expressive-default-spatial500ms
--np-motion-expressive-slow-spatial650ms
--np-motion-expressive-fast-effects150ms
--np-motion-expressive-default-effects200ms
--np-motion-expressive-slow-effects300ms
--np-motion-standard-fast-spatial350ms
--np-motion-standard-default-spatial500ms
--np-motion-standard-slow-spatial750ms
--np-motion-standard-fast-effects150ms
--np-motion-standard-default-effects200ms
--np-motion-standard-slow-effects300ms
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.