Next.js
Set up Nizivo in a Next.js 16 App Router project.
Everything on this page is checked by the Next.js fixture (fixtures/next, Next 16.4.0 with
Turbopack, webpack and next dev) on the packed packages, except where a note says otherwise. This
docs site is itself a Next.js 16.4 App Router app that uses the same setup.
Install
pnpm add @nizivo/react@next @nizivo/tokens@next@nizivo/react needs react and react-dom ^19.2.0. Install @nizivo/tokens as a direct dependency
too: your CSS imports its tokens.css.
Import the CSS once
Nizivo's JavaScript never imports CSS. Import the stylesheets once, in the root layout, in this order:
the tokens, the accessibility floor (base.css), then one file per component you use.
import '@nizivo/tokens/tokens.css';
import '@nizivo/react/css/base.css';
import '@nizivo/react/css/button.css';
import '@nizivo/react/css/select.css';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" data-nz-theme="system">
<body>{children}</body>
</html>
);
}Every stylesheet starts with the same @layer statement, so the order in which the bundler emits the
files never changes which rule wins. Each component page lists its stylesheet under Import.
Or import one file, @nizivo/react/styles.css: the tokens, base.css and every component's
stylesheet, in that order. Import either it or the files above, never both: it already holds a
byte-identical copy of tokens.css from the same release, so the two cannot drift apart. The single
file is the simplest start; the granular files load less CSS on a page that uses a few components.
The fixture checks the granular files; styles.css is checked by the package's own build tests.
Keep light-dark() intact
Nizivo's colours are light-dark() values. Next 16.4's default bundler, Turbopack, lowers
light-dark() for browsers older than its default targets, and the lowered form cannot follow a nested
data-nz-theme scope or an inverse surface (a tone="inverse" Card, a Tooltip). Set your browser
targets to Nizivo's support floor in package.json:
{
"browserslist": [
"chrome >= 123",
"edge >= 123",
"firefox >= 129",
"safari >= 17.5",
"ios_saf >= 17.5"
]
}Measured by the fixture with Next 16.4.0: without these targets, Turbopack rewrote all 68
light-dark() values in tokens.css into a fallback that ignores nested scopes, and an explicit
data-nz-theme="dark" scope painted light; with them, it kept all 68 and every nested scope
painted its own theme. A webpack build (next build --webpack) kept them either way.
Theme
Nizivo is light unless you opt in. Set data-nz-theme on <html>:
systemfollows the operating system, with no script and no flash.lightordarkforces one theme.
Any element with data-nz-theme starts a nested scope. See Theming.
A theme switch that remembers the user's choice must set the attribute before first paint. Render
Nizivo's ThemeScript in <head>: it reads the saved choice from localStorage (key nz-theme,
value light, dark or system) and sets data-nz-theme on <html> before <body> exists. It also
sets data-nz-platform (apple or other), so Kbd and shortcuts show ⌘ or Ctrl from the first
frame. Put suppressHydrationWarning on <html>, because the script changes attributes React
rendered:
import { ThemeScript } from '@nizivo/react/theme-script';
<html lang="en" data-nz-theme="system" suppressHydrationWarning>
<head>
<ThemeScript />
</head>- With nothing stored, the script leaves
data-nz-theme="system"alone, and the page keeps following the operating system, including a change made while the page is open and a visit with JavaScript off. - A stored choice replaces the value the server rendered: that value is your app's default, the stored one is the reader's. Any other stored value is ignored, and so is storage the browser blocks (a sandboxed frame, disabled site data); the script never throws.
systemis set as it is; CSS resolves it. The script runs no media query and adds no listener.
Your theme switch saves the choice and applies it in one step:
function setTheme(theme: 'light' | 'dark' | 'system') {
try {
localStorage.setItem('nz-theme', theme);
} catch {}
document.documentElement.setAttribute('data-nz-theme', theme);
}Under a strict CSP, pass the request's nonce (<ThemeScript nonce={nonce} />) or allow the script by
hash, 'sha256-nA1HUQWDdDUFqqoxWbjzCzcYSjzWLEe0pwDdRCMGqOw='. A test pins the hash, and a change to the
script is called out in the changelog. It is 379 bytes.
The fixture runs this pattern (data-nz-theme="system" and ThemeScript) in every build mode and
records every frame from first paint: with the OS in dark mode, a stored choice or a static
attribute, every frame is already in the right theme, and a theme set late by a script at the end of
<body> is caught as a flash.
A library such as next-themes works for the switch itself. Give it the same key
(storageKey="nz-theme") so ThemeScript restores what it stored; its own script runs inside
<body>, which is too late to rule out a flash on its own. With attribute="data-nz-theme",
next-themes writes the resolved light or dark once it runs, where ThemeScript wrote system;
the paint is the same. One catch: next-themes' script copies any stored value onto <html>
unchecked, so a value ThemeScript ignored (a foreign or corrupt nz-theme) would still reach the
attribute and paint the page light under a dark OS. This docs site therefore lets next-themes write
only its class and sets data-nz-theme itself after hydration, from the checked choice.
Import from subpaths
import { Button } from '@nizivo/react/button';
import { Select } from '@nizivo/react/select';
export default function Page() {
return (
<Select.Root defaultValue="30d" name="period" aria-label="Period">
<Select.Trigger />
<Select.Popup>
<Select.Item value="7d">Last 7 days</Select.Item>
<Select.Item value="30d">Last 30 days</Select.Item>
</Select.Popup>
</Select.Root>
);
}- Pages are Server Components. Server-safe components (Button, Card, Text and the rest of the week 2
set) ship no client JavaScript: the fixture's server-only pages load no
@nizivoclient module in any build. Which components ship client JavaScript, and when to useIconButtonStaticorFieldStatic, is in Static or interactive. - Compound components work from Server Components through the namespace (
Select.Root) and the flat names (SelectRoot). - Pass
renderas an element from server code (render={<Button variant="tertiary" />}). The function form only works in client code.
The barrel and webpack
import { Button } from '@nizivo/react' works too. Under next build --webpack, a page that renders
one client part through the barrel also gets every other client module the barrel reaches. Turbopack
does not do this, and neither does any subpath import. If you build with webpack and prefer the barrel,
add:
export default {
experimental: { optimizePackageImports: ['@nizivo/react', '@nizivo/icons'] },
};@nizivo/icons has no client modules, so its barrel does not leak this way: a client component that
imports three icons from @nizivo/icons ships exactly those three in every build the fixture runs,
and icons rendered by a Server Component ship nothing. Listing it in optimizePackageImports, or
importing from subpaths (@nizivo/icons/calendar), lets the bundler skip the barrel's 1,880
re-exports; the build-time saving is not measured yet.
Fonts
The library ships no font files. The tokens read var(--font-geist-sans, "Geist") and
var(--font-geist-mono, "Geist Mono"), then fall back to the system stack. With the geist package
(OFL-1.1), set the variables through next/font:
import { GeistMono } from 'geist/font/mono';
import { GeistSans } from 'geist/font/sans';
<html className={`${GeistSans.variable} ${GeistMono.variable}`} data-nz-theme="system">The fonts load from your own build; nothing is fetched from a font service.
Routes kept by cacheComponents
With cacheComponents, Next keeps recent routes under React's <Activity>, hidden, instead of
unmounting them. A Menu, Select, Popover, Tooltip, Dialog or Drawer that is open when its route is
hidden closes: the page you go to is not scroll-locked or inert, and the overlay is closed when you
come back. An uncontrolled Dialog closes itself; a controlled one gets
onOpenChange(false, { reason: 'hidden' }), and your state decides. React 19.2 leaves a portal on
screen under a hidden <Activity> (19.3 hides it), so on 19.2 an overlay your state keeps open stays
visible until its route is back: close it when the reason is 'hidden'.
Render <Toaster /> in the root layout, outside the routes, so toasts outlive navigation. A Toaster
inside a hidden route hides its stack until the route is shown again.
The overlay lanes check this with <Activity> on React 19.2 and 19.3 (conformance case O-15); the
Next fixture itself does not turn cacheComponents on yet.
Not covered by the fixture yet
Pages Router, cacheComponents in the fixture itself, CSP nonces and next@canary are not checked
yet.