Theme Toggle
A sun / moon button whose rays retract and a crescent bites in as it switches; a three-way variant adds System.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/theme-toggle.json'use client'
import { useId, useState } from 'react'
import './theme-toggle.css'
export type Theme = 'light' | 'dark' | 'system'
export interface ThemeToggleProps {
value?: Theme
defaultValue?: Theme
onValueChange?: (t: Theme) => void
variant?: 'icon' | 'segmented'
fill?: 'dark' | 'light'
className?: string
}
function SunMoon({ dark }: { dark: boolean }) {
const id = useId()
return (
<svg className="sd-thm__svg" viewBox="0 0 24 24" aria-hidden data-mode={dark ? 'dark' : 'light'}>
<mask id={id}>
<rect x="-4" y="-4" width="32" height="32" fill="#fff" />
<circle className="sd-thm__bite" cx={dark ? 16 : 30} cy={dark ? 7 : -6} r="6.5" fill="#000" />
</mask>
<circle className="sd-thm__core" cx="12" cy="12" r={dark ? 8.5 : 4.5} mask={`url(#${id})`} />
<g className="sd-thm__rays">
{Array.from({ length: 8 }, (_, i) => {
const a = (i * Math.PI) / 4
return <line key={i} x1={12 + Math.cos(a) * 7.5} y1={12 + Math.sin(a) * 7.5} x2={12 + Math.cos(a) * 9.5} y2={12 + Math.sin(a) * 9.5} />
})}
</g>
</svg>
)
}
const ICONS: Record<Theme, string> = { light: 'M8 2v1.5M8 12.5V14M2 8h1.5M12.5 8H14M8 5a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z', system: 'M2.5 3.5h11v7.5h-11zM6 13.5h4', dark: 'M13 9.5A5.5 5.5 0 0 1 6.5 3a5.5 5.5 0 1 0 6.5 6.5Z' }
/** Theme switch: morphing sun/moon or a three-way segmented control. */
export function ThemeToggle({ value, defaultValue = 'dark', onValueChange, variant = 'icon', fill = 'dark', className }: ThemeToggleProps) {
const [inner, setInner] = useState<Theme>(defaultValue)
const t = value ?? inner
const set = (n: Theme) => { setInner(n); onValueChange?.(n) }
const cls = ['sd-thm', className].filter(Boolean).join(' ')
if (variant === 'segmented') {
const order: Theme[] = ['light', 'system', 'dark']
const i = order.indexOf(t)
return (
<div className={cls} data-fill={fill}>
<div className="sd-thm__seg" role="radiogroup" aria-label="Theme">
<span className="sd-thm__pill" aria-hidden style={{ transform: `translateX(${i * 40}px)` }} />
{order.map((o, j) => (
<button key={o} type="button" role="radio" aria-checked={o === t} aria-label={o[0].toUpperCase() + o.slice(1)} tabIndex={o === t ? 0 : -1} className="sd-thm__opt" onClick={() => set(o)}
onKeyDown={(e) => { const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0; if (d) { e.preventDefault(); set(order[(j + d + 3) % 3]) } }}>
<svg viewBox="0 0 16 16" aria-hidden><path d={ICONS[o]} /></svg>
</button>
))}
</div>
</div>
)
}
const dark = t === 'dark'
return (
<div className={cls} data-fill={fill}>
<button type="button" className="sd-thm__btn" aria-label={`Switch to ${dark ? 'light' : 'dark'} theme`} onClick={() => set(dark ? 'light' : 'dark')}><SunMoon dark={dark} /></button>
</div>
)
}.sd-thm { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-thm[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-thm button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-thm :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-thm .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-thm { display: inline-flex; }
.sd-thm__btn { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 14px; box-shadow: inset 0 0 0 1px var(--line); transition: background-color 200ms, transform 140ms; }
.sd-thm__btn:hover { background: var(--soft) !important; }
.sd-thm__btn:active { transform: scale(0.92); }
.sd-thm__svg { width: 22px; height: 22px; overflow: visible; }
.sd-thm__core { fill: currentColor; transition: r 400ms cubic-bezier(0.3, 1.4, 0.4, 1); }
.sd-thm__bite { transition: cx 450ms cubic-bezier(0.3, 1.1, 0.4, 1), cy 450ms cubic-bezier(0.3, 1.1, 0.4, 1); }
.sd-thm__rays { stroke: currentColor; stroke-width: 2; stroke-linecap: round; transform-origin: 12px 12px; transition: transform 450ms cubic-bezier(0.3, 1.4, 0.4, 1), opacity 300ms; }
[data-mode='dark'] .sd-thm__rays { transform: rotate(-90deg) scale(0.4); opacity: 0; }
.sd-thm__seg { position: relative; display: inline-flex; padding: 3px; border-radius: 999px; background: var(--soft); box-shadow: inset 0 0 0 1px var(--line); }
.sd-thm__pill { position: absolute; top: 3px; left: 3px; width: 40px; height: 34px; border-radius: 999px; background: var(--bg); box-shadow: 0 1px 3px rgb(0 0 0 / 0.3), inset 0 0 0 1px var(--line); transition: transform 340ms cubic-bezier(0.3, 1.3, 0.4, 1); }
.sd-thm__opt { position: relative; z-index: 1; display: grid; place-items: center; width: 40px; height: 34px; border-radius: 999px; color: var(--mute); transition: color 200ms; }
.sd-thm__opt[aria-checked='true'] { color: var(--fg); }
.sd-thm__opt svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
@media (prefers-reduced-motion: reduce) { .sd-thm *, .sd-thm *::before, .sd-thm *::after { animation: none !important; transition: none !important; } }Overview
One SVG morphs between states: the sun’s rays scale in and out while a circle mask slides across to cut the crescent. No icon swap, no flash.
The segmented variant offers Light / System / Dark with the same sliding indicator used elsewhere in the kit.
When to use
- Site headers, settings, docs.
When not to use
- Apps that follow the OS only.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/theme-toggle.json$
npx shadcn@latest add https://sundaeui.dev/r/theme-toggle.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/theme-toggle.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/theme-toggle.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy theme-toggle.tsx and theme-toggle.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { ThemeToggle } from '@/components/sundae/theme-toggle'
<ThemeToggle />Props
| Prop | Type | Default |
|---|---|---|
value / defaultValueTheme. | 'light' | 'dark' | 'system' | — |
onValueChangeCalled on change. | (t) => void | — |
variantSingle button or three-way. | 'icon' | 'segmented' | 'icon' |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Icon variant: a button with aria-label describing the action (“Switch to dark theme”).
- Segmented variant: a radiogroup with labelled options.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-thm; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Theme Toggle free?
- Yes. Theme Toggle is free for personal and commercial projects, with full source on this page.
- How do I install Theme Toggle?
- Run npx shadcn@latest add https://sundaeui.dev/r/theme-toggle.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Theme Toggle support keyboard and screen readers?
- Icon variant: a button with aria-label describing the action (“Switch to dark theme”). Segmented variant: a radiogroup with labelled options.
- Does Theme Toggle respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Follow Button
Components · RefinedBookmark Button
Components · RefinedGrid / List View Switcher
Components · RefinedAlarm List
Components · Refined- Pro
Glass Segmented
Components · Refined Toast Stack
Components · Refined