Skip to content
Nizivo UI

Vite

Set up Nizivo in a Vite 8 single-page app.

Everything on this page is checked by the Vite fixture (fixtures/vite, Vite 8.3.4 with @vitejs/plugin-react 6.1.2, vite build and vite preview) on the packed packages, except where a note says otherwise.

Install

pnpm add @nizivo/react@next @nizivo/tokens@next

@nizivo/react needs react and react-dom ^19.2.0.

Import the CSS once

Nizivo's JavaScript never imports CSS. In main.tsx, before rendering, import the tokens, the accessibility floor (base.css), then one stylesheet per component you use:

src/main.tsx
import '@nizivo/tokens/tokens.css';
import '@nizivo/react/css/base.css';
import '@nizivo/react/css/button.css';
import '@nizivo/react/css/select.css';

Or import one file, @nizivo/react/styles.css, which holds the tokens, base.css and every component's stylesheet. Import either it or the files above, never both (it already holds a byte-identical copy of tokens.css). The fixture checks the granular files.

Set the CSS target

Nizivo's colours are light-dark() values. Vite 8 minifies CSS for its default target, which is older than light-dark(), so a plain build rewrites every one of them into a fallback that cannot follow a nested data-nz-theme scope or an inverse surface (a tone="inverse" Card, a Tooltip). Set the CSS target to Nizivo's support floor:

vite.config.js
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [react()],
  build: {
    cssTarget: ['chrome123', 'edge123', 'firefox129', 'safari17.5', 'ios17.5'],
  },
});

Measured by the fixture: without cssTarget, Vite 8.3.4 kept 0 of the 68 light-dark() values in tokens.css; with it, all 68. A browserslist in package.json does not change this, because Vite reads only build.cssTarget.

Theme

Set the theme on <html> in index.html. data-nz-theme="system" follows the operating system with no script and no flash; light or dark forces one theme. A switch that remembers the user's choice must apply it before first paint. Nizivo's themeScript does that from <head>: it reads the saved choice from localStorage (key nz-theme, value light, dark or system), sets data-nz-theme on <html>, and sets data-nz-platform so Kbd shows ⌘ or Ctrl from the first frame. Let a small plugin inject it from the installed package, so the copy never drifts from the version you run:

vite.config.js
import { themeScript } from '@nizivo/react/theme-script';

function nizivoThemeScript() {
  return {
    name: 'nizivo-theme-script',
    transformIndexHtml: () => [{ tag: 'script', children: themeScript, injectTo: 'head' }],
  };
}
// plugins: [react(), nizivoThemeScript()]
index.html
<!doctype html>
<html lang="en" data-nz-theme="system">
  <head>
    <meta charset="UTF-8" />
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

With nothing stored, or storage blocked, the attribute stays as index.html set it. Your theme switch saves the choice with localStorage.setItem('nz-theme', theme) and sets the attribute itself; see Next.js for the details and the CSP hash.

The fixture checks this setup on every frame from first paint: the OS preference, a stored choice and a static attribute are right from the first frame, and a theme set late is caught as a flash.

The dev server

vite (dev) injects the CSS from JavaScript, so the first few frames of a page load paint before any stylesheet exists. With a dark theme (the OS, a stored choice or the attribute), the fixture measured about 3 frames (under 40 ms) of the browser's default light canvas before the styles apply; after that every frame is right. A production build (vite build) links the CSS in <head> and has none. It is a dev-only effect of how Vite serves CSS, not of Nizivo. If it bothers you, a few inline rules in index.html paint the right canvas before the styles arrive (not checked by the fixture):

index.html
<style>
  :root[data-nz-theme='dark'] {
    color-scheme: dark;
  }
  :root[data-nz-theme='system'] {
    color-scheme: light dark;
  }
</style>

Imports and bundle size

Import from subpaths (@nizivo/react/button) or from the barrel (@nizivo/react): Vite drops every module a page does not use, so both give the same bundle. In the fixture, a page with three Buttons through the barrel was the same size as the same page through the subpath, within a few bytes, and a page that imports Select through the barrel shipped only Select's modules. @nizivo/icons tree-shakes the same way: three imported icons ship three icon modules and the shared base.

Vite 8.3.4 prints no warning for the 'use client' directives in @nizivo/react; a single-page app treats them as plain strings.

Server rendering

Vite's own SSR (vite build --ssr) needs no ssr.noExternal entry: Nizivo's JavaScript never imports CSS, so the server build keeps @nizivo/* external and Node loads every module as published. Link the stylesheets from the client build, as above, and hydrate with hydrateRoot. The Vite SSR fixture (fixtures/vite-ssr: renderToString behind a plain node:http server) checks this: both builds print no warning, every page hydrates with no console message, and Select, Dialog, Tooltip, Menu, Tabs and the form controls work after hydration. For React Router, see React Router.

Not covered by the fixtures yet

The fixtures run in Chromium; they check the dev server's theme frames (above), and run axe in light, dark, forced colours and with reduced motion. Streaming SSR (renderToPipeableStream with Suspense) is checked in the React Router and Next.js fixtures, not in the Vite SSR one.

On this page