---
title: Theme Toggle
description: A segmented control that reports which option was pressed and knows nothing about themes.
source: https://adamjurek.com/components/theme-toggle
---

## Home


<!-- ::start:showcase demo="theme-toggle" height="220" -->
<!-- ::end:showcase -->

```tsx
<ThemeToggle
	options={[
		{ id: "light", label: "Light", icon: "sun" },
		{ id: "dark", label: "Dark", icon: "moon" },
		{ id: "system", label: "System", icon: "contrast" },
	]}
	value={theme}
	onChange={setTheme}
/>
```

## It knows nothing about themes

It renders choices and reports which was pressed. It does not touch the
document, does not store anything, and has never heard of light or dark - which
is what lets the same control switch a density, a language or a layout.

Persisting is the host's problem on purpose. A cookie, a server function, an
account row and a `localStorage` key are four answers with four different
trade-offs, and a component that picked one would be wrong in three codebases
out of four.

> [!CAUTION] The attribute must already be on `<html>` before this mounts
> A toggle that applies the theme in an effect **guarantees** a flash: the
> server paints one theme, the effect corrects it, and everyone sees both. This
> only changes an attribute that was already correct in the first byte.

## Where this is used

| Where | What |
| --- | --- |
| The nav, right-hand end | switching this site's theme |
| `theme.schemas.ts` | the cookie name, the values, the max-age |
| `theme.functions.ts` | reads the cookie on the server, so the first byte is right |
| `packages/atoms/theming.md` | why a cookie and not a media query |


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/theme-toggle.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/theme-toggle.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | layout · Page structure |
| Files | `theme-toggle.tsx` |
| Dependencies | None |
| Also installs | `icon` |
| Tags | a11y, keyboard, ssr, no-deps |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { useState } from "react";
import { ThemeToggle } from "@sushindustries/ui";

export function Example() {
	const [theme, setTheme] = useState("system");

	return (
		<ThemeToggle
			options={[
				{ id: "light", label: "Light", icon: "sun" },
				{ id: "dark", label: "Dark", icon: "moon" },
				{ id: "system", label: "System", icon: "contrast" },
			]}
			value={theme}
			onChange={setTheme}
		/>
	);
}
```

## What you should see

A small pill with three icon buttons in a recessed track, one of them lit.
Click one and the lit segment moves to match `value` - here, just React
state, so the icons switch but nothing on the page changes yet. Arrow keys
move between them once one is focused, and the selection wraps from the
last option back to the first.

## If nothing happens

If the lit icon changes but the page's actual theme does not, that is
expected from this snippet alone - `ThemeToggle` reports which option was
pressed through `onChange` and stops there. Applying the theme (setting
`data-theme` on `<html>`, persisting it in a cookie) is the host's own
wiring, not something this component does for you.


## Guides


## Why a radiogroup

Three states, and a switch is a lie about two of them.

```tsx
<div
	className="theme-toggle"
	role="radiogroup"
	aria-label={label}
	onKeyDown={onKeyDown}
>
	{options.map((option) => (
		<button
			key={option.id}
			type="button"
			role="radio"
			aria-checked={option.id === value}
			aria-label={option.label}
			tabIndex={option.id === value ? 0 : -1}
			onClick={() => onChange(option.id)}
		>
			<Icon name={option.icon} size={15} />
		</button>
	))}
</div>
```

Arrow keys move between radios for free - the behaviour a group of related
choices should have, and the one a row of buttons has to be given by hand. That
is the whole reason to reach for the role rather than three buttons and a
`data-active`.

**Roving focus** comes with it: a radiogroup is one tab stop, so exactly one
option is focusable and the rest are `tabIndex={-1}`. Without it a three-option
switcher costs three tabs to walk past - three tabs spent on a decoration.

The selection wraps at the ends. A row that stops makes the reader guess
whether they have hit the end or whether the key is broken.

## `aria-checked` is the selector

```css
.theme-toggle-option[aria-checked="true"] { … }
```

The attribute that tells a screen reader which option is chosen is the same one
that draws it, so the two cannot disagree. A separate `data-active` beside it
is a second source of truth for one fact, and the way that fails is the worst
kind: the control looks right and announces the wrong option.

## `role="radio"` on a `<button>`

A real `<input type="radio">` is the semantic form, and it arrives with a
browser-drawn dot, a label association and a focus ring that would all have to
be undone to draw a segmented control. The button carrying the role is the
pattern assistive technology expects here, and the one the ARIA authoring guide
shows.

## Props

| Prop | Type | What it does |
| --- | --- | --- |
| `options` | `ThemeOption[]` | `{ id, label, icon }`. The label is the accessible name and the tooltip |
| `value` | `string` | The chosen id |
| `onChange` | `(id: string) => void` | Called with the id. Storing it is yours |
| `label` | `string` | Names the group. Default `"Theme"` |


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `options` | `readonly ThemeOption[]` | - | The segments, left to right. Arrows walk them and wrap at both ends. |
| `value` | `string` | - | The id of the pressed segment. One matching no option leaves the group with no tab stop. |
| `label?` | `string` | `"Theme"` | Names the group for screen readers. The segments are icons, so nothing else says what it switches. |

<!-- /generated:api -->

## Notes

A `value` that matches no `options[].id` is worse than it sounds: since no
segment is checked, none gets `tabIndex={0}`, and the whole group drops out
of the tab order - keyboard users cannot reach it at all until `value`
settles on a real id. Seed state from one of the actual `options`, never
from an empty string or a sentinel that isn't in the list. `options[].label`
never appears as visible text - it is the accessible name and the tooltip
on each button, so the icon alone carries the visual weight.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="theme-toggle" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

The way this site actually uses it - the trailing end of the nav, wired to
a cookie so the theme survives a reload without a flash.

```tsx
import { NavBar, ThemeToggle } from "@sushindustries/ui";
import { setThemeCookie } from "~/modules/theme/theme.functions";

export function SiteNav({ theme }: { theme: string }) {
	return (
		<NavBar
			brand={<span className="mono">acme</span>}
			entries={[]}
			trailing={
				<ThemeToggle
					options={[
						{ id: "light", label: "Light", icon: "sun" },
						{ id: "dark", label: "Dark", icon: "moon" },
						{ id: "system", label: "System", icon: "contrast" },
					]}
					value={theme}
					onChange={(id) => setThemeCookie(id)}
				/>
			}
		/>
	);
}
```

## What this example is not

`theme` here has to arrive already correct from a server-rendered cookie
read - the `<html>` attribute this toggle affects has to be right on the
very first byte, or the switch from server theme to client theme is a
visible flash that no client-side effect can undo after the fact.
