Skip to content
Nizivo UI

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.

app/layout.tsx
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:

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

  • system follows the operating system, with no script and no flash.
  • light or dark forces 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:

app/layout.tsx
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.
  • system is 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

app/orders/page.tsx
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 @nizivo client module in any build. Which components ship client JavaScript, and when to use IconButtonStatic or FieldStatic, is in Static or interactive.
  • Compound components work from Server Components through the namespace (Select.Root) and the flat names (SelectRoot).
  • Pass render as 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:

next.config.mjs
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:

app/layout.tsx
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.

On this page