Light, dark and system cards drawn in the real palettes, with a sliding ring.
01Preview
Interface theme
Pick a theme, or let it follow your device.
LightDarkSystem
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion @base-ui/react03Usage
import { ThemePicker } from "@/components/ui/theme-picker";
<ThemePicker
defaultValue="system"
onValueChange={(theme) => {
document.documentElement.dataset.theme = theme === "system" ? "" : theme;
localStorage.setItem("theme", theme);
}}
/>04Source
"use client";
import { Radio } from "@base-ui/react/radio";
import { RadioGroup } from "@base-ui/react/radio-group";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useId, useState, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";
export type Theme = "light" | "dark" | "system";
export type ResolvedTheme = "light" | "dark";
const query = "(prefers-color-scheme: dark)";
const subscribe = (fn: () => void) => {
const mq = window.matchMedia(query);
mq.addEventListener("change", fn);
return () => mq.removeEventListener("change", fn);
};
/** What the device prefers right now, following changes live. Null on the server and during hydration. */
export function useSystemTheme(): ResolvedTheme | null {
return useSyncExternalStore(
subscribe,
() => (window.matchMedia(query).matches ? "dark" : "light"),
() => null,
);
}
/** Resolves a choice to the theme to paint: System follows the device. */
export function useResolvedTheme(theme: Theme): ResolvedTheme | null {
const system = useSystemTheme();
return theme === "system" ? system : theme;
}
const defaults: Record<Theme, string> = {
light: "Light",
dark: "Dark",
system: "System",
};
export type ThemePickerProps = Omit<
React.ComponentProps<"div">,
"defaultValue" | "onChange"
> & {
value?: Theme;
defaultValue?: Theme;
/** Apply the theme here: set a data attribute, write a cookie, save to the account. */
onValueChange?: (theme: Theme) => void;
/** Which cards to show and in what order. */
themes?: Theme[];
/** Rename the cards, for another language or house style. */
labels?: Partial<Record<Theme, React.ReactNode>>;
disabled?: boolean;
/** Submitted with a form. */
name?: string;
};
/**
* Three cards, each a small drawing of the app in that theme. One ring slides
* to the chosen card; System shows what it resolves to right now.
*/
export function ThemePicker({
value,
defaultValue = "system",
onValueChange,
themes = ["light", "dark", "system"],
labels,
disabled = false,
name,
className,
...rest
}: ThemePickerProps) {
const id = useId();
const reduce = useReducedMotion();
const system = useSystemTheme();
const [theme, setTheme] = useControllableState<Theme>({
value,
defaultValue,
onChange: onValueChange,
});
// Arrow keys move the choice instantly; a click earns the slide.
const [keyboard, setKeyboard] = useState(false);
return (
<RadioGroup
value={theme}
onValueChange={(v) => setTheme(v as Theme)}
disabled={disabled}
name={name}
onKeyDownCapture={() => setKeyboard(true)}
onPointerDownCapture={() => setKeyboard(false)}
className={cn("grid w-full gap-3", disabled && "opacity-50", className)}
style={{
gridTemplateColumns: `repeat(${themes.length}, minmax(0, 1fr))`,
}}
{...rest}
>
{themes.map((t) => {
const checked = theme === t;
return (
<Radio.Root
key={t}
value={t}
className={cn(
"group/card relative flex min-w-0 cursor-default select-none flex-col gap-2 rounded-xl outline-none",
// Focus sits outside the selection ring so the two never merge.
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-[6px] focus-visible:outline-fg-3",
"data-disabled:cursor-not-allowed",
)}
>
<span className="relative block">
{checked && (
<motion.span
layoutId={`${id}-ring`}
aria-hidden
className="pointer-events-none absolute -inset-[3px] z-[1] rounded-[13px] border-[1.5px] border-fg"
transition={
reduce || keyboard ? { duration: 0 } : spring.snappy
}
/>
)}
<span
className={cn(
"relative block aspect-[8/5] overflow-hidden rounded-[10px] border border-line-2",
"transition-[border-color,scale] duration-150 ease-out group-active/card:scale-[0.98] group-active/card:duration-75",
"group-hover/card:border-fg-4 group-data-disabled/card:group-hover/card:border-line-2 group-data-disabled/card:group-active/card:scale-100",
)}
>
{t === "system" ? (
<>
<Preview theme="light" />
{/* The same drawing twice, the dark one clipped to the right half. */}
<span className="absolute inset-0 block [clip-path:inset(0_0_0_50%)]">
<Preview theme="dark" />
</span>
</>
) : (
<Preview theme={t} />
)}
</span>
</span>
<span className="flex min-w-0 items-center gap-2 px-0.5">
<Indicator checked={checked} reduce={!!reduce} />
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
<span
className={cn(
"truncate text-[13px] leading-5 transition-colors duration-150",
checked ? "text-fg" : "text-fg-2 group-hover/card:text-fg",
)}
>
{labels?.[t] ?? defaults[t]}
</span>
{t === "system" && system && (
<span
className="hidden truncate text-[12px] text-fg-4 min-[420px]:inline"
suppressHydrationWarning
>
{system === "dark" ? "Dark now" : "Light now"}
</span>
)}
</span>
</span>
</Radio.Root>
);
})}
</RadioGroup>
);
}
function Indicator({ checked, reduce }: { checked: boolean; reduce: boolean }) {
return (
<span
aria-hidden
className={cn(
"relative grid size-3.5 shrink-0 place-items-center rounded-full border transition-colors duration-150",
checked
? "border-fg bg-fg"
: "border-line-2 group-hover/card:border-fg-4",
)}
>
<AnimatePresence initial={false}>
{checked && (
<motion.svg
key="check"
viewBox="0 0 16 16"
className="size-2.5 text-frame"
fill="none"
stroke="currentColor"
strokeWidth={2.2}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.5, transition: { duration: 0.1 } }
}
transition={reduce ? { duration: 0.12 } : spring.pop}
>
<motion.path
d="M3.5 8.5 6.5 11.5 12.5 4.5"
initial={reduce ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.26, ease: ease.out, delay: 0.05 }}
/>
</motion.svg>
)}
</AnimatePresence>
</span>
);
}
// A small drawing of an app window. Setting data-theme on it swaps every token
// underneath, so the drawing is always in the real palette of that theme.
function Preview({ theme }: { theme: ResolvedTheme }) {
// Sized in container units so the drawing keeps its proportions from a phone to a wide card.
return (
<span
data-theme={theme}
aria-hidden
className="absolute inset-0 block bg-page [container-type:size]"
>
<span className="absolute inset-0 flex pt-[13cqh] pl-[10cqw]">
<span className="flex flex-1 overflow-hidden rounded-tl-[6px] border-t border-l border-line-2 bg-frame shadow-[var(--shadow)]">
<span className="flex w-[30%] flex-col gap-[8cqh] border-r border-line px-[3cqw] pt-[11cqh]">
<span className="flex items-center gap-[2cqw]">
<span className="size-[7cqh] shrink-0 rounded-full bg-fg-3" />
<span className="h-[4.5cqh] flex-1 rounded-full bg-fg-4" />
</span>
<span className="h-[4.5cqh] w-[80%] rounded-full bg-line-2" />
<span className="h-[4.5cqh] w-[60%] rounded-full bg-line-2" />
<span className="h-[4.5cqh] w-[70%] rounded-full bg-line-2" />
</span>
<span className="flex flex-1 flex-col items-start gap-[8cqh] px-[5cqw] pt-[11cqh]">
<span className="h-[5cqh] w-[45%] rounded-full bg-fg-2" />
<span className="flex w-full flex-col gap-[5cqh] rounded-[4px] border border-line bg-raised p-[6cqh]">
<span className="h-[4.5cqh] w-[85%] rounded-full bg-line-2" />
<span className="h-[4.5cqh] w-[60%] rounded-full bg-line-2" />
</span>
<span className="h-[10cqh] w-[34%] rounded-full bg-fg" />
</span>
</span>
</span>
</span>
);
}05Props
ThemePicker
| Prop | Type | Default | Description |
|---|---|---|---|
| value | "light" | "dark" | "system" | — | Controlled choice. |
| defaultValue | "light" | "dark" | "system" | "system" | Uncontrolled initial choice. |
| onValueChange | (theme: Theme) => void | — | Where you apply it: a data attribute, a cookie, the account. |
| themes | Theme[] | ["light", "dark", "system"] | Which cards to show, in order. |
| labels | Partial<Record<Theme, ReactNode>> | — | Rename the cards. |
| disabled | boolean | false | Dims the group and ignores input. |
| name | string | — | Submits the choice with a form. |
useResolvedTheme
| Prop | Type | Default | Description |
|---|---|---|---|
| theme* | Theme | — | Returns the theme to paint, following the device live for System. Null until hydrated. |
useSystemTheme
| Prop | Type | Default | Description |
|---|---|---|---|
| (none) | () => "light" | "dark" | null | — | The device preference, updated when it changes. Null on the server. |
06Notes
Behavior
- Each preview sets data-theme on itself, so it is drawn with that theme's actual tokens and stays true when the palette changes.
- System shows both halves and names what it resolves to right now, following the device if it switches while open.
- The drawings are sized in container units, so they keep their proportions from a phone to a wide settings page.
- Nothing is read from the device during render: the resolved theme is null on the server and fills in after hydration.
Motion
- One 1.5px ring slides between cards on the snappy spring, so the eye follows the choice instead of watching two borders swap.
- The check pops on the pop spring and draws in 260ms. Cards press to 0.98 in 75ms.
- Arrow keys move the ring instantly. Reduced motion jumps the ring and fades the check.
Accessibility
- A Base UI radio group: one Tab stop, arrow keys move the choice, each card is a radio named by its label.
- The previews are decorative and hidden from assistive tech; the choice never depends on seeing them.
- Focus draws a ring outside the selection ring, so focus and selection stay distinguishable.