Add light, dark and system color schemes to your app, saved between visits and right from the first paint.

Let's add dark mode to an app. People pick Light, Dark or System, the choice is saved, and the page shows it from the first paint. Try it on this card:

Choose Dark, then reload the page: the card stays dark. Choose System to follow your device's setting.

The same steps work in any app. The last step adds a cookie for apps that render on the server.

Install

Start by installing @ui-sdk/core and the package for your framework.

npm install @ui-sdk/core @ui-sdk/react

Create the Color Scheme

createColorScheme holds the choice: "light", "dark" or "system". Create one for your app in lib/color-scheme.ts:

lib/color-scheme.ts
import { createColorScheme, createLocalStorage } from "@ui-sdk/core"

const colorScheme = createColorScheme({
  storage: createLocalStorage("color-scheme"),
  global: true,
})

export { colorScheme }
  • The choice starts at "system", which follows the device's setting.
  • storage saves the choice in localStorage, so it's still there on the next visit.
  • global: true puts the choice on <html> as data-color-scheme, so your whole page can use it.

Apply It

Wrap your app in Preferences. It writes data-color-scheme on <html> and keeps it current when the choice changes.

src/app.tsx
import { Preferences } from "@ui-sdk/react/preferences"
import { colorScheme } from "@/lib/color-scheme"
import { Home } from "@/pages/home"

function App() {
  return (
    <Preferences preferences={[colorScheme]}>
      <Home />
    </Preferences>
  )
}

export { App }

Show the Choice Before Your App Loads

An app that renders only in the browser, like a Vite app, applies the saved choice once its JavaScript runs. Until then, someone who picked Dark on a light device can see a light page for a moment.

preferencesScript writes a tiny script that applies the saved choice as the page loads. Put it at the top of your HTML's <head>. With Vite, add it from your config:

vite.config.ts
import { preferencesScript } from "@ui-sdk/core"
import { defineConfig } from "vite"
import { colorScheme } from "./src/lib/color-scheme"

export default defineConfig({
  plugins: [
    {
      name: "preferences-script",
      transformIndexHtml: () => [
        {
          tag: "script",
          children: preferencesScript([colorScheme]),
          injectTo: "head-prepend",
        },
      ],
    },
  ],
})

Keep your framework's plugin in plugins too. The script only reads the saved choice and sets the attribute, before any of your code loads.

Add Your Colors

Now give each scheme its colors. color-scheme tells the browser which scheme an element uses, and light-dark() picks a color for it:

:root,
[data-color-scheme="system"] {
  color-scheme: light dark;
}

[data-color-scheme="light"] {
  color-scheme: light;
}

[data-color-scheme="dark"] {
  color-scheme: dark;
}

:root,
[data-color-scheme] {
  --background: light-dark(#ffffff, #0a0a0a);
  --foreground: light-dark(#171717, #ededed);
  --muted: light-dark(#737373, #a3a3a3);
}

body {
  background: var(--background);
  color: var(--foreground);
}
  • With "system", the browser picks the scheme from the device's setting, so the page is right even before any JavaScript runs.
  • Form controls and scrollbars follow color-scheme too, so they match without extra styles.
  • In Tailwind, dark: covers both a Dark choice and System on a dark device.

Add a Switcher

Let people choose. A Segmented Control fits three options: read the choice with usePreference, and change it with set.

components/color-scheme-switcher.tsx
"use client"

import { usePreference } from "@ui-sdk/react/preferences"
import {
  SegmentedControl,
  SegmentedControlIndicator,
  SegmentedControlItem,
} from "@ui-sdk/react/segmented-control"
import { colorScheme } from "@/lib/color-scheme"

function ColorSchemeSwitcher() {
  const { value } = usePreference(colorScheme)

  return (
    <SegmentedControl
      aria-label="Color scheme"
      value={value}
      onValueChange={(next) => next && colorScheme.set(next)}
    >
      <SegmentedControlIndicator />
      <SegmentedControlItem value="light">Light</SegmentedControlItem>
      <SegmentedControlItem value="dark">Dark</SegmentedControlItem>
      <SegmentedControlItem value="system">System</SegmentedControlItem>
    </SegmentedControl>
  )
}

export { ColorSchemeSwitcher }
  • value is the choice itself: "light", "dark" or "system". The color scheme also tells you the device's setting as system, and the scheme in use as resolved.
  • set saves the new choice, and Preferences updates <html> right away.
  • The switcher has no styles of its own. Segmented Control shows how to style it, indicator included.

localStorage only exists in the browser. If your app renders on the server, the server can't see the choice. Preferences still applies it before the first paint, but anything that renders from it, like the switcher, starts at System until the page loads.

A cookie travels with every request, so the server can render the saved choice from the first byte. Change lib/color-scheme.ts to keep the choice in a cookie:

lib/color-scheme.ts
import {
  createColorScheme,
  createCookieStorage,
  type ColorSchemeValue,
} from "@ui-sdk/core"

function readColorScheme(cookie?: string | null) {
  return createColorScheme({
    storage: createCookieStorage("color-scheme", { cookie }),
  }).get().value
}

function createAppColorScheme(value: ColorSchemeValue) {
  return createColorScheme({
    defaultValue: value,
    storage: createCookieStorage("color-scheme"),
    global: true,
  })
}

type AppColorScheme = ReturnType<typeof createAppColorScheme>

export { createAppColorScheme, readColorScheme, type AppColorScheme }
  • readColorScheme reads the saved choice from a request's Cookie header on the server, and from document.cookie in the browser.
  • createAppColorScheme creates your app's color scheme, starting from that choice. Each request gets its own, so visitors never share one.
  • Hand your app only the choice, never the whole Cookie header. The header can carry other cookies, like a session, that shouldn't end up in the page.

Then read the cookie where your app meets the request, and write the choice on <html>:

With Next.js, read the request in the root layout:

app/layout.tsx
import { headers } from "next/headers"
import { readColorScheme } from "@/lib/color-scheme"
import { ColorSchemeProvider } from "./color-scheme-provider"
import "./globals.css"

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const colorScheme = readColorScheme((await headers()).get("cookie"))

  return (
    <html lang="en" data-color-scheme={colorScheme}>
      <body>
        <ColorSchemeProvider value={colorScheme}>
          {children}
        </ColorSchemeProvider>
      </body>
    </html>
  )
}

The provider creates the app's color scheme once and shares it through context:

app/color-scheme-provider.tsx
"use client"

import type { ColorSchemeValue } from "@ui-sdk/core"
import { Preferences } from "@ui-sdk/react/preferences"
import { createContext, use, useState } from "react"
import { createAppColorScheme, type AppColorScheme } from "@/lib/color-scheme"

const ColorSchemeContext = createContext<AppColorScheme | null>(null)

function ColorSchemeProvider({
  value,
  children,
}: {
  value: ColorSchemeValue
  children: React.ReactNode
}) {
  const [colorScheme] = useState(() => createAppColorScheme(value))

  return (
    <ColorSchemeContext value={colorScheme}>
      <Preferences preferences={[colorScheme]}>{children}</Preferences>
    </ColorSchemeContext>
  )
}

function useColorScheme() {
  const colorScheme = use(ColorSchemeContext)
  if (!colorScheme) {
    throw new Error("useColorScheme needs a ColorSchemeProvider above it.")
  }
  return colorScheme
}

export { ColorSchemeProvider, useColorScheme }

In the switcher, take the color scheme from context instead of importing it:

const colorScheme = useColorScheme()
const { value } = usePreference(colorScheme)

<html> already has the right data-color-scheme from the server, so it doesn't need suppressHydrationWarning. Reading the request's headers renders each page on request, which the cookie needs anyway.

Apps that only render in the browser, like a Vite app, keep localStorage and the script from Apply It.

Check It

Try each of these in your app:

  • Pick Dark and reload. The page stays dark, with no flash of light while it loads.
  • Pick System and change your device's setting. The page follows it right away.

For a server-rendered app, view the page's source: <html> already carries the data-color-scheme you picked.

Next