Theme Toggle
Light and dark mode controls on next-themes: a one-click toggle, a Light / Dark / System menu, and a three-way switcher with arrow-key support. Icons swap with CSS, so the button is right before hydration.
Example
Installation
npx shadcn@latest add https://hirael.com/r/theme-toggle.jsonUsage
import { ThemeToggle, ThemeSelect, ThemeSwitcher } from "@/components/theme-toggle"Paths assume the default @/ alias from your components.json. The example above shows the parts composed.
API
<ThemeToggle />
+ native element props| Prop | Type | Default |
|---|---|---|
labels | Partial<ThemeLabels> | none |
asChild | boolean | none |
size | "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null | 'icon' |
variant | "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | 'ghost' |
<ThemeSelect />
+ native element props| Prop | Type | Default |
|---|---|---|
labels | Partial<ThemeLabels> | none |
alignMenu alignment against the trigger. | "center" | "start" | "end" | 'end' |
asChild | boolean | none |
size | "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null | 'icon' |
variant | "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | 'outline' |
<ThemeSwitcher />
+ native element props| Prop | Type | Default |
|---|---|---|
labels | Partial<ThemeLabels> | none |
Component source
'use client';
import * as React from 'react';
import { Monitor, Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { cn } from '@/lib/utils';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/registry/hirael/bases/radix/ui/dropdown-menu';
export type ThemeMode = 'light' | 'dark' | 'system';
export interface ThemeLabels {
light: string;
dark: string;
system: string;
/** Accessible name of the toggle and the menu trigger. */
toggle: string;
}
const DEFAULT_LABELS: ThemeLabels = { light: 'Light', dark: 'Dark', system: 'System', toggle: 'Toggle theme' };
interface ThemeModeOption {
value: ThemeMode;
icon: typeof Sun;
}
const MODES: ThemeModeOption[] = [
{ value: 'light', icon: Sun },
{ value: 'system', icon: Monitor },
{ value: 'dark', icon: Moon },
];
const noopSubscribe = () => () => {};
// The theme is only known in the browser, so selection renders after hydration to avoid a mismatch.
const useMounted = () =>
React.useSyncExternalStore(
noopSubscribe,
() => true,
() => false,
);
// Both icons render and CSS picks one, so the button is correct before hydration too.
const ThemeIcon = () => {
return (
<>
<Sun aria-hidden className="scale-100 rotate-0 transition-transform duration-300 dark:scale-0 dark:-rotate-90" />
<Moon
aria-hidden
className="absolute scale-0 rotate-90 transition-transform duration-300 dark:scale-100 dark:rotate-0"
/>
</>
);
};
export interface ThemeToggleProps extends Omit<React.ComponentProps<typeof Button>, 'children' | 'onClick'> {
labels?: Partial<ThemeLabels>;
}
/** One button that flips between light and dark, starting from whatever is showing now. */
const ThemeToggle = ({ labels, variant = 'ghost', size = 'icon', className, ...props }: ThemeToggleProps) => {
const { resolvedTheme, setTheme } = useTheme();
const text = { ...DEFAULT_LABELS, ...labels };
return (
<Button
type="button"
variant={variant}
size={size}
aria-label={text.toggle}
data-slot="theme-toggle"
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
className={cn('relative', className)}
{...props}
>
<ThemeIcon />
</Button>
);
};
export interface ThemeSelectProps extends Omit<React.ComponentProps<typeof Button>, 'children'> {
labels?: Partial<ThemeLabels>;
/** Menu alignment against the trigger. */
align?: React.ComponentProps<typeof DropdownMenuContent>['align'];
}
/** An icon button that opens a Light / Dark / System menu. */
const ThemeSelect = ({
labels,
align = 'end',
variant = 'outline',
size = 'icon',
className,
...props
}: ThemeSelectProps) => {
const { theme, setTheme } = useTheme();
const mounted = useMounted();
const text = { ...DEFAULT_LABELS, ...labels };
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant={variant}
size={size}
aria-label={text.toggle}
data-slot="theme-select-trigger"
className={cn('relative', className)}
{...props}
>
<ThemeIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} data-slot="theme-select-content">
<DropdownMenuRadioGroup value={mounted ? theme : undefined} onValueChange={(value) => setTheme(value)}>
{MODES.map(({ value, icon: Icon }) => (
<DropdownMenuRadioItem key={value} value={value} data-slot="theme-select-item">
<Icon aria-hidden />
{text[value]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
};
export interface ThemeSwitcherProps extends Omit<React.ComponentProps<'div'>, 'children'> {
labels?: Partial<ThemeLabels>;
}
/** Three icon buttons side by side, with the current mode pressed. */
const ThemeSwitcher = ({ labels, className, ...props }: ThemeSwitcherProps) => {
const { theme, setTheme } = useTheme();
const mounted = useMounted();
const text = { ...DEFAULT_LABELS, ...labels };
const refs = React.useRef<(HTMLButtonElement | null)[]>([]);
const current = mounted ? (theme ?? 'system') : undefined;
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
const rtl = getComputedStyle(event.currentTarget).direction === 'rtl';
let step: number;
switch (event.key) {
case 'ArrowDown':
step = 1;
break;
case 'ArrowUp':
step = -1;
break;
case 'ArrowRight':
step = rtl ? -1 : 1;
break;
case 'ArrowLeft':
step = rtl ? 1 : -1;
break;
default:
return;
}
event.preventDefault();
const next = (index + step + MODES.length) % MODES.length;
setTheme(MODES[next].value);
refs.current[next]?.focus();
};
return (
<div
role="radiogroup"
aria-label={text.toggle}
data-slot="theme-switcher"
className={cn(
'inline-flex items-center gap-0.5 rounded-full border border-border bg-background p-0.5',
className,
)}
{...props}
>
{MODES.map(({ value, icon: Icon }, index) => {
const checked = current === value;
return (
<button
key={value}
ref={(node) => {
refs.current[index] = node;
}}
type="button"
role="radio"
aria-checked={checked}
aria-label={text[value]}
title={text[value]}
tabIndex={checked || (current === undefined && value === 'system') ? 0 : -1}
data-slot="theme-switcher-item"
data-state={checked ? 'checked' : 'unchecked'}
onClick={() => setTheme(value)}
onKeyDown={(event) => handleKeyDown(event, index)}
className={cn(
'inline-flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors outline-none',
'hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50',
'data-[state=checked]:bg-accent data-[state=checked]:text-accent-foreground',
'[&_svg]:size-4',
)}
>
<Icon aria-hidden />
</button>
);
})}
</div>
);
};
export { ThemeToggle, ThemeSelect, ThemeSwitcher };
Dependencies
shadcn registry
buttondropdown-menu
npm
lucide-reactnext-themes
Shipped