Add Dark Mode
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:
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/reactCreate the Color Scheme
createColorScheme holds the choice: "light", "dark" or "system". Create one for your app in 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. storagesaves the choice in localStorage, so it's still there on the next visit.global: trueputs the choice on<html>asdata-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.
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:
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-schemetoo, 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.
"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 }valueis the choice itself:"light","dark"or"system". The color scheme also tells you the device's setting assystem, and the scheme in use asresolved.setsaves the new choice, andPreferencesupdates<html>right away.- The switcher has no styles of its own. Segmented Control shows how to style it, indicator included.
Use a Cookie on the Server
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:
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 }readColorSchemereads the saved choice from a request'sCookieheader on the server, and fromdocument.cookiein the browser.createAppColorSchemecreates 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
Cookieheader. 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:
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:
"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.