Guide

theme

Users expect dark mode without a white flash on load. @kitr/theme gives you light, dark and system themes that remember the user's choice, design tokens as CSS variables, and the settings kitr ui add uses to copy components into your app. Components come from shadcn/ui-compatible registries.

This guide follows Acme's dashboard at app.acme.com: Ada adds a dark mode toggle, brands the colours and copies in a few components. Toasts have their own package, toast. For utility classes on the same tokens, add tailwind.

Install

kitr add theme

First time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.

Everything kitr add changes
  • Adds react first if the project does not have it, because theme requires it.
  • Installs @kitr/theme, adds it to packages and its provider to providers in kitrrc.ts.
  • Writes config/theme.ts (see Configure) and app/styles/tokens.css, an empty :root block for your token overrides.
  • Loads tokens.css after kitr's tokens, so your values win: with tailwind through @import './tokens.css'; in app/styles/app.css, otherwise by linking it from head() in app/document.tsx.
  • Edits app/document.tsx (or app/routes/__root.tsx) to apply the saved theme before first paint: puts <ThemeScript /> above <HeadContent /> and adds suppressHydrationWarning to <html lang="en">. A document that already has <ThemeScript or no <HeadContent /> is left alone (Avoid a flash on load).
  • Adds a theme section to AGENTS.md.

It adds no environment variables or scripts, skips config/theme.ts and tokens.css when they exist, and asks no questions. --no-install skips the package manager and --dry-run prints the plan.

The app, fullstack, saas, site, realtime, desktop and mobile kits include theme, with a starter set of components (icon, button, input, card, theme-toggle and more).

kitr remove theme removes <ThemeScript />, its import and suppressHydrationWarning, the provider, the packages entry, the dependency, config/theme.ts and the AGENTS.md section. app/styles/tokens.css, its import and app/components/ui/ stay, because they are yours.

The base stylesheet @kitr/theme/styles.css is linked from the app/document.tsx that router writes (or imported by app/styles/app.css with tailwind), so it is in place before you add theme.

Configure

kitr add theme writes this file:

// config/theme.ts
import { defineConfig } from '@kitr/theme'

export default defineConfig({
  default: 'system',
  // Window chrome / <meta name="theme-color"> per theme.
  colors: { light: '#ffffff', dark: '#0a0a0a' },
  // Any shadcn-compatible registry for `kitr ui add`. Default: kitr's.
  // registry: 'https://raw.githubusercontent.com/kitrjs/kitr/main/registry/dist',
  componentsDir: 'app/components/ui',
})
Option Default Description
default 'system' The theme before the user picks one: 'light', 'dark' or 'system'.
storageKey 'kitr-theme' The localStorage key and cookie name that hold the user's choice. Pass the same value to <ThemeScript>.
attribute 'data-theme' The attribute set on <html> to light or dark. Pass the same value to <ThemeScript>.
colors.light none <meta name="theme-color"> while the light theme shows. Browsers and installed PWAs tint their chrome with it.
colors.dark none The same, for the dark theme.
registry kitr's registry Where kitr ui add and kitr ui list fetch components: a URL or a local directory.
componentsDir app/components/ui Where kitr ui add writes components.
tokens.light, tokens.dark none Reserved slot for token overrides; override tokens in app/styles/tokens.css (see Brand the colors).
Variable Used for
KITR_REGISTRY Overrides registry for one kitr ui add or kitr ui list run, such as in CI.

The config is read once, when the app is created. useConfig('theme') from react returns it in a component.

Add a dark mode toggle

useTheme() returns the current choice and the functions that change it:

// app/components/theme-button.tsx
import { useTheme } from '@kitr/theme'

export function ThemeButton() {
  const { resolved, toggle } = useTheme()
  return (
    <button type="button" onClick={toggle}>
      {resolved === 'dark' ? 'Switch to light' : 'Switch to dark'}
    </button>
  )
}

Put it in the document:

// app/document.tsx
// ...
import { ThemeButton } from '#app/components/theme-button'

export function RootDocument() {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <ThemeScript />
        <HeadContent />
      </head>
      <body>
        <nav style={{ display: 'flex', gap: 12, padding: 12 }}>
          <Link to="/">Home</Link>
          <ThemeButton />
        </nav>
        <Outlet />
        <Scripts />
      </body>
    </html>
  )
}

Click it and the page turns dark: <html> gets data-theme="dark", every --kitr-* variable switches to its dark value, and the choice is saved, so a reload keeps it.

Field What it is
theme The user's choice: 'light', 'dark' or 'system'.
resolved What shows on screen: 'light' or 'dark'. With 'system', it follows the operating system and updates when the OS setting changes.
setTheme(theme) Sets the choice and saves it to localStorage and a one-year cookie.
toggle() Switches to the opposite of resolved and saves that, which leaves 'system'.

For a styled toggle with an icon, kitr ui add theme-toggle writes app/components/ui/theme-toggle.tsx (plus button and icon); render <ThemeToggle />.

Let users pick light, dark or system

toggle() is a two-way switch. To offer "match my system" as well, call setTheme from a select:

// app/components/settings/appearance.tsx
import { type Theme, useTheme } from '@kitr/theme'

export function AppearanceSetting() {
  const { theme, setTheme } = useTheme()
  return (
    <label>
      Appearance
      <select value={theme} onChange={(e) => setTheme(e.target.value as Theme)}>
        <option value="system">Match system</option>
        <option value="light">Light</option>
        <option value="dark">Dark</option>
      </select>
    </label>
  )
}

With system saved, resolved follows the OS without a reload, so the dashboard goes dark when Linus's laptop does at sunset. A stored value other than light, dark or system is ignored and default applies.

Avoid a flash on load

React applies the theme after it hydrates, a moment after first paint, so a dark-mode user would see a white flash. <ThemeScript /> is a tiny inline script that reads the saved choice and sets data-theme before anything paints. Put it first in the document head:

// app/document.tsx
import { HeadContent, Outlet, Scripts } from '@kitr/router'
import { ThemeScript } from '@kitr/theme'
import css0 from '@kitr/theme/styles.css?url'

export function head() {
  return {
    meta: [{ charSet: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }],
    links: [{ rel: 'stylesheet', href: css0 }],
  }
}

export function RootDocument() {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <ThemeScript />
        <HeadContent />
      </head>
      <body>
        <Outlet />
        <Scripts />
      </body>
    </html>
  )
}

suppressHydrationWarning on <html> tells React that the script's change to the attribute is expected. kitr add theme and the kits add both for you. The script only reads localStorage, so it works in ssr, static and spa modes and in desktop and mobile shells.

If you changed storageKey or attribute in config/theme.ts, pass the same values, because the script reads its props, not the config: <ThemeScript storageKey="acme-theme" attribute="data-mode" />.

Render the saved theme on the server

In ssr mode the server can send the right theme in the HTML. setTheme writes a cookie as well as localStorage, and themeFromCookie reads it from the request:

// app/lib/theme/read-theme.ts
import { createServerFn } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
import { themeFromCookie } from '@kitr/theme/server'

/** Runs on the server only: the visitor's theme, straight from the request cookie. */
export const readTheme = createServerFn({ method: 'GET' }).handler(
  () => themeFromCookie(getRequest().headers.get('cookie')) ?? null,
)
// app/routes.ts
import { head, RootDocument } from './document'
import { readTheme } from '#app/lib/theme/read-theme'

r.root({ head, loader: () => readTheme(), component: RootDocument })
// app/document.tsx
// ...
import { getRouteApi } from '@kitr/router'

const root = getRouteApi('__root__')

export function RootDocument() {
  const theme = root.useLoaderData()
  return (
    <html lang="en" data-theme={theme ?? undefined} suppressHydrationWarning>
      <head>
        <ThemeScript />
        <HeadContent />
      </head>
      <body>
        <Outlet />
        <Scripts />
      </body>
    </html>
  )
}

themeFromCookie returns only 'light' or 'dark':

import { themeFromCookie } from '@kitr/theme/server'

themeFromCookie('session=abc; kitr-theme=dark') // => 'dark'
themeFromCookie('kitr-theme=system') // => undefined (the server cannot know the OS setting)
themeFromCookie(null) // => undefined
themeFromCookie('acme-theme=light', 'acme-theme') // => 'light', with a custom storageKey

When it returns undefined, <ThemeScript /> resolves system in the browser. Keep it in any case: it covers static pages and the shells, where there is no request.

Color the window chrome and status bar

colors in config/theme.ts keeps <meta name="theme-color"> in step with the theme. Browsers tint their toolbar, and installed PWAs their title bar, with it:

// config/theme.ts
import { defineConfig } from '@kitr/theme'

export default defineConfig({
  default: 'system',
  colors: { light: '#ffffff', dark: '#0b1020' },
  componentsDir: 'app/components/ui',
})

On every change of resolved, the provider sets the tag's content (creating the tag if needed). Pick the same colour as --kitr-color-background so the chrome blends in. Inside the desktop and mobile shells, whether the window or status bar follows it depends on the shell's web view.

Use the tokens in CSS

@kitr/theme/styles.css defines every token as a --kitr-* variable on :root, with dark values under [data-theme="dark"]. Use them in any stylesheet and the component follows the theme:

/* app/components/invoices/invoice-card.module.css */
.card {
  background: var(--kitr-color-background);
  color: var(--kitr-color-foreground);
  border: 1px solid var(--kitr-color-border);
  border-radius: var(--kitr-radius-md);
  box-shadow: var(--kitr-shadow-sm);
  font-family: var(--kitr-font-sans);
}
.overdue {
  border-color: var(--kitr-color-destructive);
}
Token Light Dark
--kitr-color-background oklch(99% 0 0) oklch(16% 0.01 260)
--kitr-color-foreground oklch(20% 0.01 260) oklch(95% 0.005 260)
--kitr-color-muted oklch(96% 0.005 260) oklch(24% 0.01 260)
--kitr-color-mutedForeground oklch(50% 0.02 260) oklch(70% 0.02 260)
--kitr-color-border oklch(90% 0.01 260) oklch(30% 0.01 260)
--kitr-color-primary oklch(55% 0.2 262) oklch(70% 0.17 262)
--kitr-color-primaryForeground oklch(99% 0 0) oklch(16% 0.01 260)
--kitr-color-destructive oklch(55% 0.22 27) oklch(65% 0.2 27)
--kitr-color-destructiveForeground oklch(99% 0 0) oklch(16% 0 0)
--kitr-color-ring oklch(55% 0.2 262) oklch(70% 0.17 262)
--kitr-radius-sm, -md, -lg 0.25rem, 0.5rem, 0.75rem same
--kitr-font-sans, --kitr-font-mono the system UI and monospace stacks same
--kitr-shadow-sm, --kitr-shadow-md soft shadows at 6% and 8% black same

The same tokens exist in JavaScript:

import { darkTokens, defaultTokens } from '@kitr/theme'

defaultTokens.var('color.primary') // => 'var(--kitr-color-primary)'
defaultTokens.name('radius.md') // => '--kitr-radius-md'
defaultTokens.tokens.radius.md // => '0.5rem'
darkTokens.tokens.color.background // => 'oklch(16% 0.01 260)'

Brand the colors

To change a token for the whole app, override its variable in app/styles/tokens.css. Acme's brand is a teal primary:

/* app/styles/tokens.css */
:root {
  --kitr-color-primary: oklch(60% 0.12 190);
  --kitr-color-ring: oklch(60% 0.12 190);
  --kitr-radius-md: 0.375rem;
}
[data-theme="dark"] {
  --kitr-color-primary: oklch(72% 0.11 190);
  --kitr-color-ring: oklch(72% 0.11 190);
}

kitr add theme loads this file for you. If you move it, keep it after @kitr/theme/styles.css so your values win the cascade. Without tailwind, link it from the document:

// app/document.tsx
import css0 from '@kitr/theme/styles.css?url'
import tokens from '#app/styles/tokens.css?url'

export function head() {
  return {
    links: [
      { rel: 'stylesheet', href: css0 },
      { rel: 'stylesheet', href: tokens },
    ],
  }
}

With tailwind, use @import './tokens.css'; in app/styles/app.css after the theme import instead.

Define your own tokens

defineTokens turns a nested object into CSS variables with typed names, for values kitr has no token for, such as Acme's invoice status colours:

// app/styles/acme-tokens.ts
import { defineTokens } from '@kitr/theme'

export const acme = defineTokens(
  {
    status: { paid: 'oklch(65% 0.15 150)', overdue: 'oklch(60% 0.2 27)' },
    space: { card: 16 },
  },
  { prefix: 'acme' },
)

acme.name('status.paid') // => '--acme-status-paid'
acme.var('status.overdue') // => 'var(--acme-status-overdue)'
acme.flat()
// => { '--acme-status-paid': 'oklch(65% 0.15 150)', '--acme-status-overdue': 'oklch(60% 0.2 27)', '--acme-space-card': '16' }
acme.css()
// => ':root {\n  --acme-status-paid: oklch(65% 0.15 150);\n  --acme-status-overdue: oklch(60% 0.2 27);\n  --acme-space-card: 16;\n}\n'

acme.var('status.unpaid') is a type error, because paths are checked against the object. Numbers are written without a unit; write '16px' when CSS needs one. The prefix defaults to kitr.

Emit the variables once from the root layout, or spread flat() into a style to scope them to one element:

// app/components/invoices/invoice-status.tsx
import { acme } from '#app/styles/acme-tokens'

export function InvoiceStatus({ status }: { status: 'paid' | 'overdue' }) {
  return (
    <span style={{ ...acme.flat(), color: acme.var(`status.${status}`) }}>
      {status === 'paid' ? 'Paid' : 'Overdue'}
    </span>
  )
}
// app/document.tsx
import { acme } from '#app/styles/acme-tokens'

// inside <head>, after <HeadContent />:
<style>{acme.css()}</style>

Style with CSS Modules

Without Tailwind, CSS Modules plus three small helpers cover most styling. kitr css:types writes a .d.ts next to each *.module.css so class names are checked:

kitr css:types

First time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.

// app/components/invoices/invoice-card.module.css.d.ts
// Generated by `kitr css:types`. Do not edit.
declare const classes: {
  readonly card: string
  readonly overdue: string
}
export default classes

pick(styles, ...parts) joins class names, with conditions in an object:

// app/components/invoices/invoice-card.tsx
import { pick } from '@kitr/theme'
import styles from './invoice-card.module.css'

export function InvoiceCard({ id, total, overdue }: { id: string; total: number; overdue: boolean }) {
  return (
    <article className={pick(styles, 'card', { overdue })}>
      {id}: ${total}
    </article>
  )
}
// overdue → class="card_x1 overdue_x2" (the hashed names from the module)

pick skips false, null and undefined, so pick(styles, 'card', isOpen && 'overdue') works. pick(styles, 'crad') is a type error. To type a module by hand, use CssModule<'card' | 'overdue'>.

Pass values to CSS

vars() builds a style object of CSS variables, so a component passes a value to its stylesheet:

// app/components/projects/progress-bar.tsx
import { vars } from '@kitr/theme'
import styles from './progress-bar.module.css'

export function ProgressBar({ done }: { done: number }) {
  return <div className={styles.track} style={vars({ '--progress': `${done}%` })} />
}
// vars({ '--progress': '40%', '--gap': 8 }) => { '--progress': '40%', '--gap': '8' }
/* app/components/projects/progress-bar.module.css */
.track {
  height: 0.5rem;
  border-radius: var(--kitr-radius-sm);
  background: linear-gradient(
    to right,
    var(--kitr-color-primary) var(--progress),
    var(--kitr-color-muted) var(--progress)
  );
}

Style states with data attributes

dataAttrs() turns an object into data-* attributes, so plain CSS can target a component's state:

// app/components/orders/order-row.tsx
import { dataAttrs } from '@kitr/theme'

export function OrderRow({ status, selected }: { status: 'open' | 'shipped'; selected: boolean }) {
  return <tr className="order-row" {...dataAttrs({ status, isSelected: selected })} />
}
// dataAttrs({ status: 'open', isSelected: true }) => { 'data-status': 'open', 'data-is-selected': '' }
// dataAttrs({ status: 'shipped', isSelected: false }) => { 'data-status': 'shipped' }
/* app/styles/orders.css */
.order-row[data-status="shipped"] {
  color: var(--kitr-color-mutedForeground);
}
.order-row[data-is-selected] {
  background: var(--kitr-color-muted);
}

Keys become kebab-case. true gives an empty attribute; false, null and undefined leave it out.

Build component variants

@kitr/theme re-exports class-variance-authority as cva (with VariantProps) and clsx as clsx and cx, which the registry components use:

// app/components/ui/badge.tsx
import { cva, cx, type VariantProps } from '@kitr/theme'

const badge = cva('badge', {
  variants: {
    tone: { neutral: 'badge-neutral', success: 'badge-success', danger: 'badge-danger' },
  },
  defaultVariants: { tone: 'neutral' },
})

export function Badge({
  tone,
  className,
  children,
}: VariantProps<typeof badge> & { className?: string; children: string }) {
  return <span className={cx(badge({ tone }), className)}>{children}</span>
}
// badge({ tone: 'success' }) => 'badge badge-success'
// cx('badge', { active: true, muted: false }) => 'badge active'

With tailwind, use its cn() instead of cx: it also merges conflicting utility classes.

Add components

kitr ui add copies components from a shadcn-compatible registry into app/components/ui/. The files are yours to edit.

kitr ui add button input card dialog

First time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.

// app/components/projects/new-project-card.tsx
import { Button } from '#components/ui/button'
import { Card } from '#components/ui/card'

export function NewProjectCard() {
  return (
    <Card>
      <h2>New project</h2>
      <Button onClick={() => console.info('create project')}>Create</Button>
    </Card>
  )
}

The components use the --kitr-* tokens and cva, so they follow the theme and your overrides. kitr ui add also copies registry dependencies (theme-toggle brings button and icon) and installs the npm packages they need.

Component What it is
app-shell Sidebar and top bar layout with navigation links, a user menu slot and the theme toggle.
badge Status badge.
button Button with variants and sizes, built on cva.
card Card with header, content and footer.
checkbox Native checkbox with kitr styling.
data-table TanStack Table with sorting, pagination and URL-bound state via table.
dialog Modal dialog on the native <dialog> element.
empty-state Centered empty state with title, description and action.
form Styled FormField, FormMessage and SubmitButton over forms.
icon Hugeicons in the stroke-rounded style.
input, label, select, textarea Form controls.
page-header Title, description and actions row for a page.
skeleton Loading placeholder.
theme-toggle Light and dark toggle using useTheme.
list-page A list route: page header, data table bound to search params, empty state. Writes a page, not a component.
sign-in-page A sign-in route using forms. Writes a page, not a component.

For your company's registry, set registry in config/theme.ts, pass --registry, or set KITR_REGISTRY. A registry is any URL or directory serving registry.json and one <name>.json per component in the shadcn format.

Use icons

Icons are Hugeicons in the stroke-rounded style, drawn in currentColor. kitr ui add icon gives you <Icon>:

// app/components/projects/add-project-button.tsx
import { Add01Icon } from '@hugeicons/core-free-icons'
import { Button } from '#components/ui/button'
import { Icon } from '#components/ui/icon'

export function AddProjectButton() {
  return (
    <Button>
      <Icon icon={Add01Icon} size={18} /> New project
    </Button>
  )
}

The registry components, kit pages and devtools overlay all use this set. Use one icon set across the app, not emoji or text glyphs.

Change the storage key or attribute

Two apps on one domain, such as app.acme.com and admin.acme.com sharing cookies, need different keys. Set them in config and pass the same values to the script:

// config/theme.ts
import { defineConfig } from '@kitr/theme'

export default defineConfig({
  default: 'system',
  storageKey: 'acme-admin-theme',
  attribute: 'data-mode',
  colors: { light: '#ffffff', dark: '#0a0a0a' },
  componentsDir: 'app/components/ui',
})
// app/document.tsx (inside <head>)
<ThemeScript storageKey="acme-admin-theme" attribute="data-mode" />

Use ThemeProvider on its own

The provider in kitrrc.ts renders ThemeProvider with your config. Outside a kitr app, such as Storybook or an embedded widget, render it yourself:

// app/widget/main.tsx
import { ThemeProvider } from '@kitr/theme'
import { createRoot } from 'react-dom/client'
import { InvoiceStatus } from '../components/invoices/invoice-status'

createRoot(document.getElementById('acme-widget')!).render(
  <ThemeProvider defaultTheme="dark" storageKey="acme-widget-theme" colors={{ dark: '#0b1020' }}>
    <InvoiceStatus status="paid" />
  </ThemeProvider>,
)
Prop Default Description
defaultTheme 'system' The theme before the user picks one.
storageKey 'kitr-theme' The localStorage key and cookie name.
attribute 'data-theme' The attribute set on <html>.
initialResolved 'light' resolved before the first effect runs, for example from themeFromCookie, so the first render matches the server.
colors none <meta name="theme-color"> per resolved theme.

Commands

Command What it does
kitr ui add <names...> Copy components into your app: kitr ui add button.
kitr ui list Lists the components in the registry with a one-line description. Flag: --registry.
kitr css:types Type CSS Module class names. Comes from web.
kitr ui add theme-toggle

First time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.

This writes theme-toggle.tsx, button.tsx and icon.tsx into app/components/ui/ and installs class-variance-authority, @hugeicons/react and @hugeicons/core-free-icons:

// app/components/ui/theme-toggle.tsx
import { Moon02Icon, Sun03Icon } from '@hugeicons/core-free-icons'
import { useTheme } from '@kitr/theme'
import { Button } from '#components/ui/button'
import { Icon } from '#components/ui/icon'

export function ThemeToggle() {
  const { resolved, toggle } = useTheme()
  return (
    <Button
      variant="ghost"
      size="icon"
      onClick={toggle}
      aria-label={`Switch to ${resolved === 'dark' ? 'light' : 'dark'} theme`}
    >
      <Icon icon={resolved === 'dark' ? Sun03Icon : Moon02Icon} size={18} />
    </Button>
  )
}
kitr ui list

First time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.

  app-shell        Sidebar + top bar layout with navigation links, user menu slot and theme toggle.
  badge            Status badge.
  button           Button with variants and sizes, built on cva.
  ...
  list-page        A list route: page header, data table bound to URL search params, empty state.  (page)

Errors

Error When What to do
[@kitr/theme] useTheme() must be used inside <ThemeProvider>. A component called useTheme with no theme provider above it. Add the theme provider with kitr add theme, or render inside ThemeProvider. In tests, boot an app with themeProvider.
Component "<name>" not found at <registry> (404) kitr ui add asked the registry for a component it does not have. Check the name with kitr ui list, or the registry setting.
Component "<name>" not found in <dir> The same, for a registry that is a local directory. Check the directory has <name>.json.
No error: the file is kept kitr ui add found the component file already there. It keeps yours and still reports the component as added. Pass --overwrite to replace it with the registry's version.

Testing

Boot an app with the theme provider and render with renderWithKitr from testing. Config goes in through createTestApp:

// app/components/theme-button.test.tsx
import themeProvider from '@kitr/theme/provider'
import { createTestApp, renderWithKitr } from '@kitr/testing/react'
import { act } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { ThemeButton } from './theme-button'

describe('ThemeButton', () => {
  it('switches Ada to dark mode and remembers it', async () => {
    const app = await createTestApp({
      providers: [themeProvider],
      config: { theme: { default: 'light' } },
    })
    const screen = renderWithKitr(<ThemeButton />, { app })

    const button = screen.getByRole('button', { name: 'Switch to dark' })
    await act(async () => button.click())

    expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
    expect(localStorage.getItem('kitr-theme')).toBe('dark')
    expect(document.cookie).toContain('kitr-theme=dark')
    expect(screen.getByRole('button', { name: 'Switch to light' })).toBeTruthy()
  })
})

The theme is saved to localStorage and a cookie, which the test DOM keeps between tests. Clear them in afterEach:

// test/setup.ts
import { afterEach } from 'vitest'

afterEach(() => {
  localStorage.clear()
  document.cookie = 'kitr-theme=; max-age=0; path=/'
  document.documentElement.removeAttribute('data-theme')
})

To test dark mode without clicking, save the choice first: localStorage.setItem('kitr-theme', 'dark'). For system, the provider asks window.matchMedia('(prefers-color-scheme: dark)'); replace it to control the OS setting:

// test/dark-system.ts
import { vi } from 'vitest'

export function prefersDark(dark: boolean) {
  window.matchMedia = vi.fn(() => ({
    matches: dark,
    addEventListener: () => {},
    removeEventListener: () => {},
  })) as unknown as typeof window.matchMedia
}

Next steps