Quick start

Install, render, declare your own colours, and wire light and dark — the whole path in five steps.

1 · Install

One package and one import. There is no init command, no config file, and nothing to add per component.

npm install kipui

Then one line in the stylesheet that already imports Tailwind. It has to come after @import "tailwindcss", because the variables it declares are what Tailwind builds its utilities from.

app/globals.css
/* app/globals.css */
@import "tailwindcss";
@import "kipui/theme.css";

No @source line any more

Tailwind only generates a utility it has seen written down, and this library's class names live in node_modules. That used to mean a hand-written @source path in your stylesheet, which changed with the file's depth and broke under pnpm workspaces — and getting it wrong rendered every component unstyled with no error. theme.css now declares its own, resolved relative to itself, so there is nothing left to get wrong.

2 · Render something

Before theming anything, prove the two halves are connected. A component with its markup intact and none of its styling is the failure worth catching now rather than later.

app/page.tsx
import { Button } from "kipui";

export default function Page() {
  return <Button>It works</Button>;
}

A filled, rounded button with a hover state means the stylesheet reached the classes. Unstyled text on a transparent background means the import is missing or out of order.

3 · Your own colours

Every component is written in utilities that resolve through var(). Re-theming is redeclaring variables — there is no theme object to build and no provider needed for this part.

Here is the whole surface: 31 colour variables, in both modes, at their shipped defaults. Paste it and edit the values — nothing else has to change for every component to follow.

app/globals.css
/* app/globals.css — after the import, so yours wins. */

:root {
  --background: oklch(0.985 0.004 255);
  --foreground: oklch(0.21 0.03 265);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.21 0.03 265);
  --popover: oklch(1 0 0);
  --popover-foreground: oklch(0.21 0.03 265);
  --primary: oklch(0.55 0.20 258);
  --primary-foreground: oklch(0.99 0.01 255);
  --secondary: oklch(0.96 0.012 255);
  --secondary-foreground: oklch(0.25 0.03 265);
  --muted: oklch(0.96 0.01 255);
  --muted-foreground: oklch(0.52 0.03 258);
  --accent: oklch(0.93 0.04 255);
  --accent-foreground: oklch(0.30 0.08 260);
  --destructive: oklch(0.58 0.222 27);
  --destructive-foreground: oklch(0.99 0.01 255);
  --success: oklch(0.53 0.16 150);
  --warning: oklch(0.78 0.15 75);
  --info: oklch(0.54 0.16 240);
  --success-foreground: oklch(0.99 0.01 255);
  --warning-foreground: oklch(0.20 0.03 75);
  --info-foreground: oklch(0.99 0.01 255);
  --border: oklch(0.90 0.015 255);
  --input: oklch(0.90 0.015 255);
  --ring: oklch(0.55 0.20 258);
  --chart-1: oklch(0.55 0.20 258);
  --chart-2: oklch(0.60 0.16 220);
  --chart-3: oklch(0.65 0.14 280);
  --chart-4: oklch(0.55 0.12 200);
  --chart-5: oklch(0.50 0.18 300);
  --reverse: oklch(0.21 0.03 265);
}

.dark {
  --background: oklch(0.17 0.025 265);
  --foreground: oklch(0.96 0.01 255);
  --card: oklch(0.21 0.03 265);
  --card-foreground: oklch(0.96 0.01 255);
  --popover: oklch(0.21 0.03 265);
  --popover-foreground: oklch(0.96 0.01 255);
  --primary: oklch(0.62 0.19 258);
  --primary-foreground: oklch(0.99 0.01 255);
  --secondary: oklch(0.27 0.03 265);
  --secondary-foreground: oklch(0.96 0.01 255);
  --muted: oklch(0.25 0.03 265);
  --muted-foreground: oklch(0.70 0.03 258);
  --accent: oklch(0.32 0.06 260);
  --accent-foreground: oklch(0.96 0.01 255);
  --destructive: oklch(0.62 0.21 25);
  --destructive-foreground: oklch(0.99 0.01 255);
  --success: oklch(0.53 0.16 150);
  --warning: oklch(0.74 0.15 75);
  --info: oklch(0.54 0.16 240);
  --success-foreground: oklch(0.99 0.01 255);
  --warning-foreground: oklch(0.20 0.03 75);
  --info-foreground: oklch(0.99 0.01 255);
  --border: oklch(0.30 0.025 265);
  --input: oklch(0.32 0.025 265);
  --ring: oklch(0.62 0.19 258);
  --chart-1: oklch(0.62 0.19 258);
  --chart-2: oklch(0.66 0.16 220);
  --chart-3: oklch(0.70 0.14 280);
  --chart-4: oklch(0.60 0.12 200);
  --chart-5: oklch(0.56 0.18 300);
  --reverse: oklch(0.96 0.01 255);
}
  1. 01

    Pairs travel together

    Every --x that things sit on has an --x-foreground for what sits on it. Change one and check the other still reads — that pair is the whole contrast contract.
  2. 02

    Two blocks, not two files

    :root is light, .dark is dark. The same variable name carries both values, which is why almost no component contains a dark: class.
  3. 03

    oklch, because it is doing work

    Lightness is the first number and behaves the way your eye expects, so deriving a hover state or a dark-mode twin is arithmetic rather than guesswork. Hex is accepted anywhere — you lose the arithmetic, not the theming.
  4. 04

    You do not need all of it

    Redeclare only what you are changing. Anything you leave alone keeps tracking the package and picks up tokens added in later releases.
app/globals.css
/* Or change three and inherit the other twenty-eight. */
:root {
  --primary: oklch(0.62 0.19 265);
  --primary-foreground: oklch(0.99 0 0);
  --ring: oklch(0.62 0.19 265);   /* the focus ring usually matches --primary */
}

.dark {
  --primary: oklch(0.68 0.18 265); /* lighter, so it holds against a dark field */
  --ring: oklch(0.68 0.18 265);
}

Or generate the whole set

The theme generator takes one brand colour, derives all 31 tokens for both modes with the contrast checked, previews them on real components, and hands back a block shaped exactly like the one above.

Shape, not just colour

The same trick covers the rest of the design language. Four variables move the whole system — see Tokens for the full reference.

VariableMovesTry
--radiusThe whole rounded-* scale0rem for square
--shadow-strengthEvery shadow's opacity0 for flat
--spacingThe grid p-* and m-* multiply0.2rem for dense
--motionDuration of the animate-* presets0 for instant

4 · Light and dark

Dark mode is the dark class on <html>. If you already have a switcher that sets it, you are finished — skip to the next page. If not, the package ships one.

app/layout.tsx
// app/layout.tsx
import { ThemeProvider, themeScript } from "kipui/theme";
import "./globals.css";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        {/* Before the first paint. Not a component — anything waiting for
            React has already lost the race. */}
        <script dangerouslySetInnerHTML={{ __html: themeScript() }} />
      </head>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}
  1. 01

    themeScript runs first, and outside React

    It is synchronous and in <head>, so the browser puts the stored theme on <html> before painting the body. A preference read after hydration is read one paint too late, and the visitor sees the wrong theme flash.
  2. 02

    ThemeProvider takes over after hydration

    It owns both axes, writes the dark class and data-theme, persists each to localStorage, and follows the OS while the mode is "system".
  3. 03

    suppressHydrationWarning on <html>

    The script has already changed the class the server rendered. That is the intended difference, and this is how you tell React so.
  4. 04

    Both are optional

    Nothing in a component reads either one. They move a class and an attribute on the root; anything else that moves the same class works identically, next-themes included.

ThemeProvider props

PropDefaultWhat it does
defaultTheme"system"Mode before anything is stored. Also accepts light and dark.
defaultPalette"sapphire"Palette before anything is stored.
palettesthe five built inThe palette names your stylesheet defines. A stored value outside this list is ignored.
storageKey"theme"localStorage key for the mode. Pass the same to themeScript.
paletteStorageKey"palette"localStorage key for the palette.
enableStoragetrueSet false to keep the choice for the session only.

5 · A switcher

useTheme is the whole runtime API. Six values, and the two you render from are resolvedTheme and palette.

components/mode-toggle.tsx
"use client";

import { useTheme } from "kipui/theme";
import { Button } from "kipui";

export function ModeToggle() {
  const { resolvedTheme, setTheme } = useTheme();
  const next = resolvedTheme === "dark" ? "light" : "dark";

  return (
    <Button
      variant="ghost"
      aria-label={`Switch to ${next} mode`}
      onClick={() => setTheme(next)}
    >
      {resolvedTheme === "dark" ? "Light" : "Dark"}
    </Button>
  );
}
From useTheme()TypeWhat it is
theme"light" | "dark" | "system"The stored preference, which may be system.
resolvedTheme"light" | "dark"What system currently means. Render from this, not from theme.
setTheme(mode) => voidSets and persists the mode.
palettestringThe active palette, as written to data-theme.
setPalette(name) => voidSets and persists it.
palettesreadonly string[]Everything the provider was told about, for building a menu.

Render from resolvedTheme

theme can be "system", which is not a thing you can paint. resolvedTheme is always light or dark, and it updates when the OS setting changes underneath a visitor who chose system.

The second axis

Palette and mode are independent. Five palettes times two modes is ten themes, and one control that cycled both would make some combinations unreachable — so each gets its own.

components/palette-menu.tsx
"use client";

import { useTheme } from "kipui/theme";

export function PaletteMenu() {
  const { palette, setPalette, palettes } = useTheme();

  return (
    <div role="radiogroup" aria-label="Colour palette">
      {palettes.map((name) => (
        <button
          key={name}
          role="radio"
          aria-checked={palette === name}
          onClick={() => setPalette(name)}
        >
          {name}
        </button>
      ))}
    </div>
  );
}

Your own palettes work the same way. Declare a [data-theme="…"] block and its .dark twin, then name it on the provider — the provider only writes the string, so the names are yours.

app/globals.css
/* Name them whatever you like; the provider only writes the string. */
[data-theme="ocean"] {
  --primary: oklch(0.65 0.14 220);
  /* … */
}
[data-theme="ocean"].dark {
  --primary: oklch(0.72 0.13 220);
}
app/layout.tsx
<ThemeProvider palettes={["ocean", "sand"]} defaultPalette="ocean">
  {children}
</ThemeProvider>

Where to go next

That is install, colour, mode and switcher. The rest is reference.

Conventions

The four API rules every component keeps, so learning one teaches you the rest. Read it.

Tokens

Every variable the library reads — colour, radius, shadow, typography, spacing, z-index. Read it.

Dark mode

The two axes in detail, the no-flash script, and how to write components that survive both modes. Read it.