Preferences
Define choices such as skin tone or density, save them, and keep components in sync.
Use createPreference to share a user preference across your app. It keeps components in sync when the preference changes and can save it between visits.
You can use it for a theme, like light or dark mode (see createColorScheme), or other user preferences like a preferred package manager or whether the sidebar is open.
Create a Preference
Give the preference its allowed values, a default, and a data-* attribute. Here's a skin-tone preference:
import { createPreference } from "@ui-sdk/core"
const skinTone = createPreference({
values: ["default", "light", "medium-light", "medium", "medium-dark", "dark"],
defaultValue: "default",
attribute: "data-skin-tone",
})
export { skinTone }The attribute gives CSS a way to use the choice. Apply Preferences shows how to put it on the page. You decide what each value looks like.
Read and Change the Value
Call set to change the choice and get to read its state:
skinTone.set("medium")
skinTone.get().valueAfter setting "medium", get().value returns "medium". set only accepts values from the allowed list. It returns true when the value changes, or false if it stays the same or the change is canceled.
Respond to Changes
Add onValueChange to the preference's options to hear a proposed change:
onValueChange: (value, details) => {
console.log(value, details.reason)
},This runs before the value changes. The details include the reason, event, and source. Call details.cancel() if your app needs to keep the current value. Handle Changes covers these details.
Follow Changes
Use subscribe to update something outside a framework component when the value changes:
const stop = skinTone.subscribe(({ value }) => {
console.log(value)
})Read the current state with get() when you set up. Call stop() when you no longer need the subscription. For a framework component, use Read a Preference below.
Remember the Choice
Add storage to save each choice and restore it on the next visit:
import { createLocalStorage, createPreference } from "@ui-sdk/core"
const skinTone = createPreference({
values: ["default", "light", "medium-light", "medium", "medium-dark", "dark"],
defaultValue: "default",
attribute: "data-skin-tone",
storage: createLocalStorage("skin-tone"),
})
export { skinTone }This demo uses localStorage:
import * as React from "react"
import { createLocalStorage, createPreference } from "@ui-sdk/core"
import { Preferences, usePreference } from "@ui-sdk/react/preferences"
import { Radio, RadioGroup, RadioIndicator } from "@ui-sdk/react/radio"
const tones = [
{ value: "default", label: "Default", wave: "๐" },
{ value: "light", label: "Light", wave: "๐๐ป" },
{ value: "medium-light", label: "Medium light", wave: "๐๐ผ" },
{ value: "medium", label: "Medium", wave: "๐๐ฝ" },
{ value: "medium-dark", label: "Medium dark", wave: "๐๐พ" },
{ value: "dark", label: "Dark", wave: "๐๐ฟ" },
] as const
const skinTone = createPreference({
values: tones.map((tone) => tone.value),
defaultValue: "default",
attribute: "data-skin-tone",
storage: createLocalStorage("ui-sdk-skin-tone"),
})
function Wave() {
return (
<span role="img" aria-label="Waving hand" data-slot="skin-tone-wave">
{tones.map((tone) => (
<span key={tone.value} aria-hidden="true" data-show={tone.value}>
{tone.wave}
</span>
))}
</span>
)
}
function TonePicker() {
const { value } = usePreference(skinTone)
return (
<RadioGroup
aria-label="Preferred default emoji skin tone"
value={value}
onValueChange={(next, details) => {
if (next) {
skinTone.set(next, { reason: "item-press", event: details.event })
}
}}
>
{tones.map((tone) => (
<label key={tone.value} data-slot="label">
<Radio value={tone.value} aria-label={tone.label}>
<RadioIndicator />
</Radio>
<span aria-hidden="true">{tone.wave}</span>
</label>
))}
</RadioGroup>
)
}
function Example() {
return (
<Preferences preferences={[skinTone]}>
<div data-slot="skin-tone-card">
<div data-slot="row">
<Wave />
<div data-slot="stack">
<span data-slot="label">Welcome back</span>
<span data-slot="hint">
Emoji in your messages use your skin tone.
</span>
</div>
</div>
<div data-slot="stack">
<span data-slot="label">Preferred default emoji skin tone</span>
<TonePicker />
</div>
</div>
</Preferences>
)
}
export { Example, skinTone }| Storage | Use It For |
|---|---|
createLocalStorage | A choice that lasts between visits and is shared across tabs. |
createSessionStorage | A choice that lasts while the tab is open. |
createCookieStorage | A choice the server needs to read to render content. |
When subscribing restores a different saved value, onValueChange receives the reason "restore". Changes received from another storage instance or tab report "storage-sync". Which tabs can share a value depends on the storage.
If you render on the server, apply saved preferences before the page paints so styles use the saved choice from the start.
When Nothing Is Saved
If there's no saved value, the preference starts with defaultValue. A saved value outside the allowed values uses that default too. For the skin-tone example, that's "default".
When Saving Is Unavailable
A browser can block storage or run out of space. The preference still changes and updates its subscribers, but the new choice may be lost on reload.
skinTone.set(...) returns whether the preference changed. If your interface needs to tell someone whether saving is available, check the storage.
Remember a Sidebar's State
Here's another example. Use createPreference to remember whether a sidebar is open or closed. Define sidebarPreference in its own file:
import { createLocalStorage, createPreference } from "@ui-sdk/core"
const sidebarPreference = createPreference({
values: ["open", "closed"],
defaultValue: "open",
attribute: "data-sidebar",
storage: createLocalStorage("sidebar-state"),
})
export { sidebarPreference }Then read it in your sidebar component:
import { usePreference } from "@ui-sdk/react/preferences"
import { sidebarPreference } from "./sidebar-preference"
function Sidebar() {
const { value } = usePreference(sidebarPreference)
return <aside hidden={value === "closed"}>Sidebar content</aside>
}
export { Sidebar }Call sidebarPreference.set("closed") from your toggle to close the sidebar, or sidebarPreference.set("open") to open it. The component updates when the preference changes, and the choice is saved for the next visit.
Apply Preferences
Pass your preferences to your framework's Preferences component to apply their attributes and keep them current:
import type * as React from "react"
import { Preferences } from "@ui-sdk/react/preferences"
import { skinTone } from "./preferences"
function App({ children }: { children: React.ReactNode }) {
return <Preferences preferences={[skinTone]}>{children}</Preferences>
}
export { App }See Server Rendering to apply saved values before the page hydrates.
On the Whole Page
Set global: true on a preference, then pass it to Preferences. Its attribute goes on <html> instead of the wrapper. For example, a density setting can apply to the whole page:
const density = createPreference({
values: ["comfortable", "compact"],
defaultValue: "comfortable",
attribute: "data-density",
global: true,
})Without global, the attribute stays on its own Preferences element. Use separate preference instances when different parts of a page need their own choices.
Read a Preference
Read the preference's current state in a component:
import { usePreference } from "@ui-sdk/react/preferences"
import { skinTone } from "./preferences"
function CurrentTone() {
const { value } = usePreference(skinTone)
return <span>Selected tone: {value}</span>
}Use in a Primitive
Use initialState and follow when a primitive needs the preference in its own state. Start with initialState so server rendering and hydration agree:
state: () => ({ tone: skinTone.initialState }),Then call follow from the root's bind to keep that state current. Return its cleanup function:
root: slot({
attrs: (state) => ({ "data-skin-tone": state.tone.value }),
bind: (_el, part) => skinTone.follow(part.store, "tone"),
}),Call skinTone.set(...) from the primitive's API to change the choice.
Choose a Color Scheme
createColorScheme uses createPreference internally. Its choice starts with these options:
const choice = createPreference({
values: ["system", "light", "dark"],
defaultValue: "system",
attribute: "data-color-scheme",
})That holds the choice. createColorScheme also reads the browser's preferred scheme and works out which scheme to use. For your app, use the helper:
import { createColorScheme, createLocalStorage } from "@ui-sdk/core"
const colorScheme = createColorScheme({
storage: createLocalStorage("color-scheme"),
})
export { colorScheme }It supports the same set, subscribe, follow, and cancelable onValueChange callback. Pass it to Preferences alongside your other preferences to apply its data-color-scheme attribute.
Call colorScheme.set("dark") to choose Dark. Call colorScheme.set("system") to follow the browser again. The default is "system"; an explicit Light or Dark choice stays the same when the browser's preference changes.
import * as React from "react"
import {
createColorScheme,
createLocalStorage,
createRovingFocus,
definePrimitive,
type ColorSchemeState,
type ColorSchemeValue,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react"
type SchemePickerState = {
colorScheme: ColorSchemeState
focused: string | null
}
const colorScheme = createColorScheme({
storage: createLocalStorage("ui-sdk-color-scheme"),
})
const schemePicker = definePrimitive({
name: "scheme-picker",
state: (): SchemePickerState => ({
colorScheme: colorScheme.initialState,
focused: null,
}),
api: (context) => {
const roving = createRovingFocus<SchemePickerState, { value: string }>(
context,
{
slot: "option",
key: "focused",
orientation: () => "horizontal",
preferred: (option) =>
option.key === context.store.get().colorScheme.value,
claims: (state, option) => option.key === state.colorScheme.value,
}
)
return {
roving,
keydown(event: KeyboardEvent, el: HTMLElement) {
const moved = roving.keydown(event, el)
if (moved) {
colorScheme.set(moved.key as ColorSchemeValue, {
reason: "list-navigation",
event,
})
}
},
}
},
slots: (slot) => ({
root: slot<{}, { colorScheme: ColorSchemeState }>({
state: (state) => ({ colorScheme: state.colorScheme }),
attrs: (state) => ({ "data-color-scheme": state.colorScheme.value }),
bind: (_el, part) => colorScheme.follow(part.store, "colorScheme"),
}),
list: slot({
attrs: () => ({ role: "radiogroup" }),
}),
option: slot<{ value: string }>({
props: { value: "string" },
attrs: (state, part) => ({
type: "button",
role: "radio",
"aria-checked": String(state.colorScheme.value === part.key),
"data-checked": state.colorScheme.value === part.key ? "" : undefined,
tabindex: part.api.roving.tabIndex(state, part),
}),
press: ({ key }, event) =>
colorScheme.set(key as ColorSchemeValue, {
reason: "item-press",
event,
}),
on: {
keydown: (event, { el, api }) => {
if (el) {
api.keydown(event, el)
}
},
},
bind: (el, part) => part.api.roving.bind(el, part),
}),
}),
})
const parts = createParts(schemePicker, {
root: "div",
list: "div",
option: "button",
})
const SchemePicker = parts.Root
const SchemePickerList = parts.List
const SchemePickerOption = parts.Option
const choices: { value: ColorSchemeValue; label: string }[] = [
{ value: "system", label: "Default (System)" },
{ value: "dark", label: "Dark" },
{ value: "light", label: "Light" },
]
function Example() {
return (
<SchemePicker>
{({ colorScheme: { value, resolved } }) => (
<>
<div data-slot="row">
<span data-slot="media">
{value === "system" ? (
<MonitorIcon aria-hidden="true" />
) : resolved === "dark" ? (
<MoonIcon aria-hidden="true" />
) : (
<SunIcon aria-hidden="true" />
)}
</span>
<div data-slot="stack">
<span data-slot="label">
{resolved === "dark"
? "Dark"
: resolved === "light"
? "Light"
: "System"}
</span>
<span data-slot="hint">
{value === "system" ? "Following your system" : "Chosen here"}
</span>
</div>
</div>
<SchemePickerList aria-label="Color scheme">
{choices.map((choice) => (
<SchemePickerOption key={choice.value} value={choice.value}>
{choice.label}
</SchemePickerOption>
))}
</SchemePickerList>
</>
)}
</SchemePicker>
)
}
export {
Example,
SchemePicker,
schemePicker,
SchemePickerList,
SchemePickerOption,
type SchemePickerState,
}Read the Color Scheme
get() returns three fields:
| Field | What It Tells You |
|---|---|
value | The app's choice: "system", "light", or "dark". |
system | The browser's preference: "light", "dark", or "unknown" when it isn't known yet. |
resolved | The scheme to use: value, or system when the choice is "system". |
For example, choosing Light while the browser prefers Dark gives { value: "light", system: "dark", resolved: "light" }.
Use value for your CSS attribute, so CSS can handle "system" before JavaScript runs. Style by Preferences shows how.
Follow the Browser's Scheme
onSystemValueChange reports browser changes while something subscribes or follows:
const colorScheme = createColorScheme({
onSystemValueChange: (value) => console.log(value),
})Use onValueChange for the app's choice and onSystemValueChange for the browser's preference. Browser changes can't be canceled.
Inside a Frame
Pass view to read an iframe's browser preference:
const colorScheme = createColorScheme({ view: frame.contentWindow! })Style by Preferences
When you pass a preference to Preferences, it adds the data attribute you chose with attribute. With global: true, the attribute goes on <html>. Otherwise, it goes on the Preferences element.
The density preference above uses attribute: "data-density" and global: true. When you call density.set("compact"), Preferences updates the attribute:
<html data-density="compact">
โฆ
</html>You can use that attribute in CSS or Tailwind to change how the page looks.
CSS
Match the attribute's value in a selector. For example, reduce the space between rows in compact mode:
.row {
padding-block: 0.5rem;
}
[data-density="compact"] .row {
padding-block: 0.25rem;
}Tailwind CSS
Define a variant that matches the attribute:
@custom-variant compact (&:where([data-density="compact"], [data-density="compact"] *));Then use it alongside your other utilities:
<li class="py-2 compact:py-1">โฆ</li>Color Scheme
Map data-color-scheme to the CSS color-scheme property. Use light-dark() to choose each color:
: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(oklch(1 0 0), oklch(0.145 0 0));
--foreground: light-dark(oklch(0.145 0 0), oklch(0.985 0 0));
}For "system", the browser chooses the scheme. Form controls and scrollbars follow it too. Declare the color variables on each element with its own scheme, so a dark sidebar can use different colors from the page.
For a page with one global scheme, Tailwind's dark: variant can handle both Dark and System:
@custom-variant dark {
&:where([data-color-scheme="dark"], [data-color-scheme="dark"] *) {
@slot;
}
@media (prefers-color-scheme: dark) {
&:where([data-color-scheme="system"], [data-color-scheme="system"] *) {
@slot;
}
}
}Show Matching Content
For a few versions of some content, render each one and let CSS show the matching value. The skin-tone demo uses this for its greeting:
import { skinTone } from "./preferences"
const waves = {
default: "๐",
light: "๐๐ป",
"medium-light": "๐๐ผ",
medium: "๐๐ฝ",
"medium-dark": "๐๐พ",
dark: "๐๐ฟ",
}
function Wave() {
return (
<span className="wave" role="img" aria-label="Waving hand">
{skinTone.values.map((tone) => (
<span key={tone} data-tone={tone} aria-hidden="true">
{waves[tone]}
</span>
))}
</span>
)
}
export { Wave }.wave [data-tone] {
display: none;
}
[data-skin-tone="default"] .wave [data-tone="default"],
[data-skin-tone="light"] .wave [data-tone="light"],
[data-skin-tone="medium-light"] .wave [data-tone="medium-light"],
[data-skin-tone="medium"] .wave [data-tone="medium"],
[data-skin-tone="medium-dark"] .wave [data-tone="medium-dark"],
[data-skin-tone="dark"] .wave [data-tone="dark"] {
display: inline;
}The container supplies the accessible name. CSS shows one hand and hides the others.
Server Rendering
The server can read cookies, but localStorage and sessionStorage are only available in the browser. Choose how to render based on what the preference changes:
| What Depends on the Preference | How to Render It |
|---|---|
| Colors, spacing, or fonts | Style the attribute in CSS. |
| A few versions of some content | Render each version and show one with CSS. |
| Formatted text or content throughout the page | Read a cookie on the server. |
| Content that can update after hydration | Read the state with usePreference. |
Show a Preference Before Hydration
For a preference saved in localStorage or sessionStorage, pass it to Preferences. It renders an inline script that applies the saved choice before the page paints:
- The server renders using
defaultValue. - The script reads the saved choice and sets the preference's data attribute. CSS can use it immediately.
- After hydration,
usePreferenceandfollowpick up the saved value.
For example, if someone saved "medium", CSS sees data-skin-tone="medium" on the first paint. A label rendered from usePreference still starts with "default" and updates after hydration.
When your framework checks those attributes during hydration, account for the script's changes. Pass nonce to Preferences if your Content Security Policy requires one for inline scripts.
For HTML you render yourself, preferencesScript([skinTone]) generates the script. Place it inside the element that holds the preferences. For a single storage value, you can render the storage's script instead.
Read a Cookie on the Server
Use a cookie when the server needs the saved choice to render content, such as an install command for someone's preferred package manager. Inside your request handler, pass the Cookie header to the storage:
import { createCookieStorage, createPreference } from "@ui-sdk/core"
const packageManager = createPreference({
values: ["npm", "pnpm", "yarn", "bun"],
defaultValue: "npm",
attribute: "data-package-manager",
storage: createCookieStorage("package-manager", {
cookie: request.headers.get("cookie"),
}),
})If the cookie contains "pnpm", packageManager.initialState.value and packageManager.get().value both start at "pnpm". A missing or invalid cookie uses "npm".
In the browser, the storage reads document.cookie. Create and share the preference for each request so visitors don't share state. Components can then use the saved choice from their first render.
Browser Preferences on the Server
When content depends on the system's color scheme, a supporting browser can send it in the Sec-CH-Prefers-Color-Scheme request header. Ask for it with these response headers:
Accept-CH: Sec-CH-Prefers-Color-Scheme
Critical-CH: Sec-CH-Prefers-Color-Scheme
Vary: Sec-CH-Prefers-Color-SchemeCritical-CH asks a supporting browser to retry a request that was missing the hint. Vary tells caches that the response depends on it. See color-scheme client hints for browser support.
Read the header with colorSchemeOf and pass it as initial:
import { colorSchemeOf, createColorScheme } from "@ui-sdk/core"
const colorScheme = createColorScheme({
initial: colorSchemeOf(request.headers.get("Sec-CH-Prefers-Color-Scheme")),
})colorSchemeOf returns "unknown" when the header is missing. Keep CSS handling for "system" so styles still follow the browser. Share the same initial value with the browser when hydrating.
Create preferences from a request's cookies or hints per request. This keeps one visitor's choices separate from another's.
API Reference
Preference Options
valuesreadonly Value[]- Every value people can choose. A saved value that isn't one of them is ignored.
- Example: Create a Preference
defaultValueNoInfer<Value>- The value before anything is saved, and after a saved value is removed.
- Example: Create a Preference
attribute`data-${string}`- The attribute that shows the value, which has to start with
data-, like"data-skin-tone". - Example: Create a Preference
global?boolean- Whether the attribute goes on
<html>, for a preference the whole page styles by. Otherwise it goes on thePreferenceselement, for the part of the page inside it. - Example: On the Whole Page
storage?object- Where the value is kept between visits, from
createLocalStorage,createSessionStorageorcreateCookieStorage. - Example: Remember the Choice
onValueChange?((value: Value, details: ChangeDetails<ChangeReason>) => void)- Called before the value changes, with its reason.
details.cancel()keeps the current one. - Example: Respond to Changes
Preference State and Methods
Both createPreference and createColorScheme return these fields. A color scheme's state also includes the browser's scheme and the resolved value.
initialState{ value: string; }- What the server and the first client render show:
defaultValue, or the value a cookie keeps. - Example: Use in a Primitive
get() => { value: string; }- The current state, the same object until the value changes. In the browser it's the saved value even before anything subscribes, for a render in the browser only. Hydrate from
initialState. - Example: Read and Change the Value
set(next: string, change?: { reason?: ChangeReason; event?: Event; }) => boolean- Changes the value, through
onValueChange, which can cancel it, and saves it. The reason defaults to"imperative". Returns whether it changed. - Example: Read and Change the Value
subscribe(listener: (state: { value: string; }) => void) => () => void- Hears every change. The first subscriber reads back the saved value. Returns the function that stops it.
- Example: Follow Changes
follow<Key extends string>(store: PreferenceStore<Key, { value: string; }>, key: Key) => () => void- Keeps
keyin a store set to the preference's state. Call it from a part'sbind: the state starts atinitialState, catches up a microtask later, once hydration is done, and follows every change after that. Returns the cleanup forbind. - Example: Use in a Primitive
Preferences Component
preferencesreadonly PreferenceLike[]- The preferences to show, from
createPreferenceorcreateColorScheme. - Example: Apply Preferences
Color Scheme Options
storage, global, and onValueChange work the same way as in createPreference. These options control the color scheme:
defaultValue?ColorSchemeValue- The scheme the app starts with, like a choice saved in a cookie that the server read.
- Example: Choose a Color Scheme
initial?SystemColorScheme- The browser's preference the server and the first client render assume, so hydration matches. Read the request's
Sec-CH-Prefers-Color-Schemehint withcolorSchemeOf. - Example: Browser Preferences on the Server
onSystemValueChange?((value: "light" | "dark") => void)- Called when the browser's preference changes, while something subscribes or follows.
- Example: Follow the Browser's Scheme
view?Window- The window to read, like an iframe's. Defaults to the page's window.
- Example: Inside a Frame