3 sections
Theme switchers
Light, dark and system as a control a reader can find and use.
Theme as a segmented control
theme-switchers-01The theme as one settings row: the kit's segmented ThemeToggle — Light, System and Dark, each named, the chosen one checked — with the choice in words under it.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ThemeSetting } from "./theme-switchers-01.client"
/**
* The theme as a settings row: the kit's segmented ThemeToggle — Light,
* System and Dark, each named, the chosen one checked — with what the choice
* means in words under it. It sets the app's theme through next-themes, the
* provider the kit's ThemeToggle reads, so it needs one above it.
*
* A server component around the island; there is nothing to read.
*/
export function ThemeSwitchers01() {
const titleId = React.useId()
return (
<section
data-section="theme-switchers-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Appearance</h2>
</CardTitle>
<CardDescription>How the app looks on this device</CardDescription>
</CardHeader>
<CardContent>
<ThemeSetting />
</CardContent>
</Card>
</div>
</section>
)
}
export default ThemeSwitchers01"use client"
import * as React from "react"
import { flushSync } from "react-dom"
import { useTheme } from "next-themes"
import { SunMoonIcon } from "lucide-react"
import { ThemeToggle } from "@/components/ui/theme-toggle"
const subscribeToNothing = () => () => {}
/** The theme is only known in the browser: false through the server render and the one that hydrates it. */
function useMounted(): boolean {
return React.useSyncExternalStore(subscribeToNothing, () => true, () => false)
}
/** What the reader is looking at, in words: "Always dark", "Same as this device — light right now". */
function describe(theme: string | undefined, resolved: string | undefined): string {
if (theme === "light") return "Always light"
if (theme === "dark") return "Always dark"
return `Same as this device — ${resolved === "dark" ? "dark" : "light"} right now`
}
/**
* The theme as one settings row: the kit's segmented ThemeToggle — a radio
* group of Light, System and Dark, each named, the chosen one checked — beside
* the row's title and what it does. Under it, the choice in words, so it reads
* without the fill; a change is said once in the status line.
*/
export function ThemeSetting() {
const id = React.useId()
const { theme, resolvedTheme } = useTheme()
const mounted = useMounted()
const [status, setStatus] = React.useState("")
// Said when a press changes the theme — never when the page loads, and not
// for a press on the option already chosen. The same words for a new change
// (dark, light elsewhere, dark again) are cleared first, so they are said.
function heard(event: React.MouseEvent<HTMLDivElement>) {
const picked = (event.target as HTMLElement).closest<HTMLElement>("[data-slot=theme-toggle-option]")?.dataset.theme
if (!picked || picked === theme) return
const said = `Theme set to ${picked}.`
if (said === status) flushSync(() => setStatus(""))
setStatus(said)
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<span aria-hidden="true" className="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<SunMoonIcon className="size-4" />
</span>
<div className="flex min-w-0 flex-col gap-1">
<p id={`${id}-title`} className="text-sm font-medium">
Theme
</p>
<p id={`${id}-hint`} className="text-sm text-pretty text-muted-foreground">
Light, dark, or the same as this device. It applies to this browser only.
</p>
</div>
</div>
<ThemeToggle
variant="segmented"
aria-labelledby={`${id}-title`}
aria-describedby={`${id}-hint ${id}-now`}
onClick={heard}
className="shrink-0"
/>
</div>
<p id={`${id}-now`} className="min-h-5 text-sm text-foreground">
{mounted ? describe(theme, resolvedTheme) : null}
</p>
<p role="status" aria-live="polite" className="sr-only">
{status}
</p>
</div>
)
}$
npx shadcn@latest add @vibra/theme-switchers-01Theme menu with pictures of each mode
theme-switchers-02A button that says which theme is on, opening a menu of Light, Dark and System — each with a picture of what it looks like and a line on what it does, the chosen one checked and tinted.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ThemeMenu } from "./theme-switchers-02.client"
export type ThemeSwitchers02Props = {
/** Opens the menu on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* The theme in a menu, for where room is short: a button that says which
* theme is on, opening the kit's DropdownMenu on a radio group of Light, Dark
* and System, each with a picture of what it looks like. It sets the app's
* theme through next-themes, so it needs its provider above it.
*
* A server component around the island; there is nothing to read.
*/
export function ThemeSwitchers02({ defaultOpen = false }: ThemeSwitchers02Props) {
const titleId = React.useId()
return (
<section
data-section="theme-switchers-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Theme menu</h2>
</CardTitle>
<CardDescription>For a header or a toolbar, where a row of options would not fit</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-pretty text-muted-foreground">
Light and Dark stay put; System follows this device and changes with it.
</p>
<ThemeMenu defaultOpen={defaultOpen} />
</CardContent>
</Card>
</div>
</section>
)
}
export default ThemeSwitchers02"use client"
import * as React from "react"
import { useTheme } from "next-themes"
import { ChevronDownIcon, MonitorIcon, MoonIcon, SunIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
type Mode = "light" | "dark" | "system"
const MODES: { value: Mode; label: string; icon: typeof SunIcon }[] = [
{ value: "light", label: "Light", icon: SunIcon },
{ value: "dark", label: "Dark", icon: MoonIcon },
{ value: "system", label: "System", icon: MonitorIcon },
]
const subscribeToNothing = () => () => {}
/** The theme is only known in the browser: false through the server render and the one that hydrates it. */
function useMounted(): boolean {
return React.useSyncExternalStore(subscribeToNothing, () => true, () => false)
}
/**
* A window drawn in one mode. The dark one is the palette's own dark scope;
* the light one is drawn in the light tokens and, on a dark page, in their
* inverses — the ink and the paper trade places — so each always shows what
* it names, in whatever palette the page wears.
*/
function Window({ mode }: { mode: "light" | "dark" }) {
return mode === "dark" ? (
<span className="dark flex size-full flex-col gap-1 bg-background p-1.5">
<span className="h-1 w-3/5 rounded-full bg-muted-foreground" />
<span className="flex-1 rounded-sm bg-card ring-1 ring-border" />
<span className="h-1 w-2/5 rounded-full bg-primary" />
</span>
) : (
<span className="flex size-full flex-col gap-1 bg-background p-1.5 dark:bg-foreground">
<span className="h-1 w-3/5 rounded-full bg-muted-foreground dark:bg-background/40" />
<span className="flex-1 rounded-sm bg-card ring-1 ring-border dark:bg-foreground dark:ring-background/30" />
<span className="h-1 w-2/5 rounded-full bg-primary dark:bg-background" />
</span>
)
}
/** A small picture of a mode; System is light on one side and dark on the other. */
function ModePreview({ mode }: { mode: Mode }) {
return (
<span aria-hidden="true" className="flex h-9 w-14 shrink-0 overflow-hidden rounded-md ring-1 ring-border">
{mode === "system" ? (
<>
<span className="w-1/2 overflow-hidden">
<span className="flex h-full w-14">
<Window mode="light" />
</span>
</span>
<span className="flex w-1/2 justify-end overflow-hidden">
<span className="flex h-full w-14 shrink-0">
<Window mode="dark" />
</span>
</span>
</>
) : (
<Window mode={mode} />
)}
</span>
)
}
/**
* The theme in a menu, for where room is short — a header, a toolbar. The
* button says which theme is on; the kit's DropdownMenu opens on a radio
* group of three, each with a picture of what it looks like and a line on
* what it does, the chosen one checked and tinted. It sets the app's theme
* through next-themes.
*/
export function ThemeMenu({ defaultOpen = false }: { defaultOpen?: boolean }) {
const { theme, resolvedTheme, setTheme } = useTheme()
const mounted = useMounted()
const current = MODES.find((mode) => mode.value === theme)
const hint: Record<Mode, string> = {
light: "Always light",
dark: "Always dark",
system: mounted ? `Same as this device, ${resolvedTheme === "dark" ? "dark" : "light"} now` : "Same as this device",
}
return (
<DropdownMenu defaultOpen={defaultOpen}>
<DropdownMenuTrigger
render={
<Button variant="outline" className="gap-2">
{mounted && current ? (
<current.icon aria-hidden="true" />
) : (
<>
<SunIcon aria-hidden="true" className="dark:hidden" />
<MoonIcon aria-hidden="true" className="hidden dark:block" />
</>
)}
<span>
Theme<span className="text-muted-foreground">{mounted && current ? `: ${current.label}` : ""}</span>
</span>
<ChevronDownIcon aria-hidden="true" className="text-muted-foreground" />
</Button>
}
/>
<DropdownMenuContent align="end" className="w-72">
<DropdownMenuGroup>
<DropdownMenuLabel>Theme</DropdownMenuLabel>
<DropdownMenuRadioGroup value={mounted ? theme : undefined} onValueChange={(value) => setTheme(String(value))}>
{MODES.map((mode) => (
<DropdownMenuRadioItem
key={mode.value}
value={mode.value}
className={cn("gap-3 py-1.5", "data-checked:bg-brand-muted data-checked:font-medium")}
>
<ModePreview mode={mode.value} />
<span className="flex min-w-0 flex-col">
<span>{mode.label}</span>
<span className="text-xs font-normal text-muted-foreground">{hint[mode.value]}</span>
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}$
npx shadcn@latest add @vibra/theme-switchers-02Appearance settings with palette previews
theme-switchers-03The mode as three pictured choices and the app's palettes as a grid of choices, each drawn in its own colours, the chosen ones tinted, with what changed said in a status line.
import * as React from "react"
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { AppearanceSettings, type Palette } from "./theme-switchers-03.client"
export type ThemeSwitchers03Props = {
/**
* The palettes the app ships — each a `data-preset` name the app has a
* stylesheet for, and what to call it. Left empty, the card offers the mode
* alone.
*/
palettes?: Palette[]
}
/**
* Appearance as a settings card: the mode as three pictured choices, and the
* palettes the app ships as a grid of choices, each drawn in its own colours.
* The mode goes through next-themes; a palette is the `data-preset` attribute
* the kit's palettes are scoped to.
*
* A server component around the island; the palettes are handed in, never
* written here, so a palette renamed or added is one list to change.
*/
export function ThemeSwitchers03({ palettes = [] }: ThemeSwitchers03Props) {
const titleId = React.useId()
return (
<section
data-section="theme-switchers-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Appearance</h2>
</CardTitle>
<CardDescription>The mode and the palette the app is drawn in, on this device</CardDescription>
</CardHeader>
<AppearanceSettings palettes={palettes} />
</Card>
</div>
</section>
)
}
export default ThemeSwitchers03"use client"
import * as React from "react"
import { useTheme } from "next-themes"
import { CardContent, CardFooter } from "@/components/ui/card"
import { Field, FieldContent, FieldDescription, FieldLabel, FieldLegend, FieldSet, FieldTitle } from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
/** A palette the app ships: its `data-preset` name and what it is called. */
export type Palette = { name: string; label: string }
type Mode = "light" | "dark" | "system"
/** The app's own palette: no `data-preset` at all. */
const DEFAULT = "default"
/** Where the chosen palette is kept, for the app to put back before its first paint. */
export const PALETTE_KEY = "palette"
const MODES: { value: Mode; label: string; hint: string }[] = [
{ value: "light", label: "Light", hint: "Always light" },
{ value: "dark", label: "Dark", hint: "Always dark" },
{ value: "system", label: "System", hint: "Same as this device" },
]
const subscribeToNothing = () => () => {}
/** The theme is only known in the browser: false through the server render and the one that hydrates it. */
function useMounted(): boolean {
return React.useSyncExternalStore(subscribeToNothing, () => true, () => false)
}
/**
* The palette scope a band sits in: the nearest element around it that names
* one — `<html data-preset>` in an app, or a preview's own wrapper when the
* preview is pinned to a palette — else nothing, and the app's own palette.
*/
const scopeOf = (band: Element | null) => band?.parentElement?.closest<HTMLElement>("[data-preset]") ?? null
/** The palette a band wears, followed as any scope around it changes; none on the server. */
function useWornPalette(band: React.RefObject<HTMLElement | null>): string | null {
const subscribe = React.useCallback((changed: () => void) => {
const observer = new MutationObserver(changed)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-preset"], subtree: true })
return () => observer.disconnect()
}, [])
return React.useSyncExternalStore(
subscribe,
() => scopeOf(band.current)?.getAttribute("data-preset") ?? null,
() => null
)
}
/**
* A page drawn in one mode, in whatever palette surrounds it. The dark one is
* the palette's own dark scope; the light one is drawn in the light tokens and,
* on a dark page, in their inverses — the ink and the paper trade places.
*/
function Page({ mode }: { mode: "light" | "dark" }) {
const light = mode === "light"
return (
<span className={light ? "flex size-full gap-1.5 bg-background p-2 dark:bg-foreground" : "dark flex size-full gap-1.5 bg-background p-2"}>
<span className={light ? "w-1/4 rounded-sm bg-muted dark:bg-background/15" : "w-1/4 rounded-sm bg-muted"} />
<span className="flex flex-1 flex-col gap-1.5">
<span className={light ? "h-1.5 w-1/2 rounded-full bg-muted-foreground dark:bg-background/50" : "h-1.5 w-1/2 rounded-full bg-muted-foreground"} />
<span className={light ? "flex-1 rounded-sm bg-card ring-1 ring-border dark:bg-foreground dark:ring-background/25" : "flex-1 rounded-sm bg-card ring-1 ring-border"} />
<span className={light ? "h-1.5 w-1/3 rounded-full bg-primary dark:bg-background" : "h-1.5 w-1/3 rounded-full bg-primary"} />
</span>
</span>
)
}
function ModePicture({ mode }: { mode: Mode }) {
return (
<span aria-hidden="true" className="flex h-24 w-full overflow-hidden rounded-md ring-1 ring-border">
{mode === "system" ? (
<>
<span className="w-1/2 overflow-hidden">
<span className="flex h-full w-[200%]">
<Page mode="light" />
</span>
</span>
<span className="flex w-1/2 justify-end overflow-hidden">
<span className="flex h-full w-[200%] shrink-0">
<Page mode="dark" />
</span>
</span>
</>
) : (
<Page mode={mode} />
)}
</span>
)
}
/** Three drops of a palette, drawn in its own scope: its page, its accent and its second chart colour. */
function Swatches({ preset }: { preset?: string }) {
return (
<span aria-hidden="true" data-preset={preset} className="flex shrink-0 -space-x-1">
{preset ? (
<>
<span className="size-5 rounded-full bg-background ring-1 ring-border" />
<span className="size-5 rounded-full bg-primary ring-2 ring-card" />
<span className="size-5 rounded-full bg-chart-2 ring-2 ring-card" />
</>
) : (
// The app's own palette is the one the page wears once none is chosen: ink on paper.
<>
<span className="size-5 rounded-full bg-background ring-1 ring-border" />
<span className="size-5 rounded-full bg-foreground ring-2 ring-card" />
<span className="size-5 rounded-full bg-muted-foreground ring-2 ring-card" />
</>
)}
</span>
)
}
/**
* Appearance as a settings card: the mode as three pictured choices — Light,
* Dark and System — and, when the app ships palettes, the palette as a grid
* of choices, each showing its own colours. Every choice is a radio named by
* its title, the chosen one tinted; the change takes effect at once and the
* status line says what the app looks like now.
*
* The mode goes through next-themes. A palette is the `data-preset` attribute
* the kit's palettes are scoped to, put where it wins: on the nearest scope
* around the card that already names one — `<html>` in an app, a preview's
* own wrapper when the preview is pinned to a palette — else on `<html>`.
* Default takes every palette around the card off, so the app's own shows.
* The choice is kept under `PALETTE_KEY` for the app to put back before its
* first paint.
*/
export function AppearanceSettings({ palettes }: { palettes: Palette[] }) {
const id = React.useId()
const band = React.useRef<HTMLDivElement>(null)
const { theme, resolvedTheme, setTheme } = useTheme()
const mounted = useMounted()
const preset = useWornPalette(band)
const [status, setStatus] = React.useState("")
const paletteLabel = (name: string | null) => palettes.find((palette) => palette.name === name)?.label ?? "Default"
const modeLabel = (mode: string | undefined) => MODES.find((entry) => entry.value === mode)?.label ?? "System"
function chooseMode(next: Mode) {
setTheme(next)
setStatus(`${modeLabel(next)} mode${palettes.length ? ` · ${paletteLabel(preset)} palette` : ""}.`)
}
function choosePalette(next: string) {
if (next === DEFAULT) {
for (let scope = scopeOf(band.current); scope; scope = scopeOf(scope)) scope.removeAttribute("data-preset")
} else {
const scope = scopeOf(band.current) ?? document.documentElement
scope.setAttribute("data-preset", next)
}
try {
if (next === DEFAULT) window.localStorage.removeItem(PALETTE_KEY)
else window.localStorage.setItem(PALETTE_KEY, next)
} catch {
// A private window keeps nothing: the palette holds for this visit.
}
setStatus(`${modeLabel(theme)} mode · ${paletteLabel(next === DEFAULT ? null : next)} palette.`)
}
return (
<>
<CardContent ref={band} className="flex flex-col gap-6">
<FieldSet className="gap-3">
<FieldLegend id={`${id}-mode`} variant="label">
Mode
</FieldLegend>
<RadioGroup
aria-labelledby={`${id}-mode`}
value={mounted ? (theme ?? "system") : null}
onValueChange={(next) => chooseMode(next as Mode)}
className="grid gap-3 sm:grid-cols-3"
>
{MODES.map((mode) => (
<FieldLabel key={mode.value} htmlFor={`${id}-mode-${mode.value}`}>
<Field className="gap-3">
<ModePicture mode={mode.value} />
<span className="flex items-start justify-between gap-3">
<FieldContent>
<FieldTitle id={`${id}-mode-${mode.value}-title`}>{mode.label}</FieldTitle>
<FieldDescription id={`${id}-mode-${mode.value}-hint`} className="text-xs">
{mode.value === "system" && mounted ? `${mode.hint}, ${resolvedTheme === "dark" ? "dark" : "light"} now` : mode.hint}
</FieldDescription>
</FieldContent>
<RadioGroupItem
id={`${id}-mode-${mode.value}`}
value={mode.value}
aria-labelledby={`${id}-mode-${mode.value}-title`}
aria-describedby={`${id}-mode-${mode.value}-hint`}
className="mt-0.5"
/>
</span>
</Field>
</FieldLabel>
))}
</RadioGroup>
</FieldSet>
{palettes.length > 0 ? (
<FieldSet className="gap-3">
<FieldLegend id={`${id}-palette`} variant="label">
Palette
</FieldLegend>
<RadioGroup
aria-labelledby={`${id}-palette`}
value={mounted ? (preset ?? DEFAULT) : null}
onValueChange={(next) => choosePalette(String(next))}
className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4"
>
{[{ name: DEFAULT, label: "Default" }, ...palettes].map((palette) => (
<FieldLabel key={palette.name} htmlFor={`${id}-palette-${palette.name}`}>
{/* On a phone the name takes a line of its own under the swatches, so a long one is never cut. */}
<Field orientation="horizontal" className="items-center gap-2.5 max-sm:flex-wrap max-sm:gap-y-2">
<Swatches preset={palette.name === DEFAULT ? undefined : palette.name} />
<FieldTitle id={`${id}-palette-${palette.name}-title`} className="min-w-0 flex-1 max-sm:order-last max-sm:w-full max-sm:flex-none">
{palette.label}
</FieldTitle>
<RadioGroupItem
id={`${id}-palette-${palette.name}`}
value={palette.name}
aria-labelledby={`${id}-palette-${palette.name}-title`}
className="max-sm:ms-auto"
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
</FieldSet>
) : null}
</CardContent>
<CardFooter className="gap-0 text-sm">
<p role="status" aria-live="polite" className="min-h-5 text-foreground">
{status}
</p>
{status ? null : <p className="text-muted-foreground">Changes apply at once.</p>}
</CardFooter>
</>
)
}$
npx shadcn@latest add @vibra/theme-switchers-03