Skip to main content

Theme Switcher

Mantle owns the theme system (ThemeProvider, useTheme, and the light and dark themes), so it also ships the canonical theme control. ThemeSwitcher is a ghost IconButton that shows the applied theme and flips between light and dark on each click. It reads and writes the theme via useTheme, so it needs a ThemeProvider ancestor. On a page that locks the theme with forceTheme, it shows the locked theme and is disabled, because a click cannot change what the page paints.

import { ThemeSwitcher } from "@ngrok/mantle/theme-switcher"; <ThemeSwitcher />;

Choosing a control

Give each surface one theme control. Two controls for one preference in one view make the user ask which one wins.

SurfaceControl
A site header or a toolbarThemeSwitcher
A signed-out page, such as sign-inThemeSwitcher in the layout footer
An app account menuThemeDropdownMenuItem at the top level of the menu
An app settings pageYour own radio group over useTheme

In a signed-in app, put the control in the account menu, and leave ThemeSwitcher out of the app chrome.

How the toggle works

The stored preference has three values: "system", "light", and "dark". A user who opens the page and finds it too bright (or too dark) has one goal, which is to flip it, so the toggle shows two states and stores all three. It follows the model in Lea Verou's post on dark mode toggles:

  • A click shows the opposite of the applied theme.
  • When that opposite matches the OS setting, the click stores "system" instead of a pin. A second click always gets the user back to the OS default, so a quick flip never turns into a permanent override.
  • A change to the OS setting never clears a stored override. Only a click does.
Stored themeOS settingPage showsA click stores
"system"lightlight"dark"
"dark"lightdark"system"
"system"darkdark"light"
"light"darklight"system"
"dark"darkdark"light"

The theme swap

A click grows the new theme in a circle from the button over the old one, in 400ms on an ease-out curve. The swap runs in a view transition, so every color on the page changes in the same frame. The theme, the icon, and the accessible name change on the click; only the reveal runs after it.

The swap is part of setTheme from useTheme, so your own theme controls, such as a command palette action, get the same reveal.

  • With prefers-reduced-motion: reduce, the swap happens at once.
  • In a browser without view transitions, the swap happens at once.
  • A click during the swap ends it at once, and the click still reaches the element under the pointer. A second click on the button toggles again, so a quick double click returns to the first theme.

Customizing

ThemeSwitcher forwards IconButton's props, so appearance, className, disabled, onClick, and ref work as you'd expect. An onClick that calls event.preventDefault() cancels the theme change. Pass label to set the accessible name, for example to localize it:

import { ThemeSwitcher } from "@ngrok/mantle/theme-switcher"; <ThemeSwitcher appearance="outlined" label="Cambiar tema" />;

Embedding in an existing menu

ThemeDropdownMenuItem is the toggle in menu form: one Theme row that ends in the ThemeSwitcher icon, for an account menu or any other dropdown menu. One row fits a narrow menu, such as the one beside a collapsed sidebar, with no submenu to open. A press works like a ThemeSwitcher click: it flips between light and dark, and the icon swaps between the sun and the moon. The menu stays open, and the new theme grows in a circle from the icon.

import { Button } from "@ngrok/mantle/button";import { DropdownMenu } from "@ngrok/mantle/dropdown-menu";import { ThemeDropdownMenuItem } from "@ngrok/mantle/theme-switcher"; <DropdownMenu.Root>	<DropdownMenu.Trigger asChild>		<Button type="button" appearance="outlined" intent="neutral">			Account		</Button>	</DropdownMenu.Trigger>	<DropdownMenu.Content>		<DropdownMenu.Item>Profile</DropdownMenu.Item>		<ThemeDropdownMenuItem />		<DropdownMenu.Separator />		<DropdownMenu.Item>Log out</DropdownMenu.Item>	</DropdownMenu.Content></DropdownMenu.Root>;

Laying out the row

To lay out the row yourself, pass children and put a ThemeSwitcherIcon where the icon goes. Here the icon comes first, to line up with the icons of other menu rows. The children are the accessible name, and the new theme grows in a circle from the icon.

import { Button } from "@ngrok/mantle/button";import { DropdownMenu } from "@ngrok/mantle/dropdown-menu";import { ThemeDropdownMenuItem, ThemeSwitcherIcon } from "@ngrok/mantle/theme-switcher"; <DropdownMenu.Root>	<DropdownMenu.Trigger asChild>		<Button type="button" appearance="outlined" intent="neutral">			Account		</Button>	</DropdownMenu.Trigger>	<DropdownMenu.Content>		<DropdownMenu.Item>Profile</DropdownMenu.Item>		<ThemeDropdownMenuItem>			<ThemeSwitcherIcon />			Theme		</ThemeDropdownMenuItem>		<DropdownMenu.Separator />		<DropdownMenu.Item>Log out</DropdownMenu.Item>	</DropdownMenu.Content></DropdownMenu.Root>;

Wiring your own menu item

To put the toggle on a DropdownMenu.Item of your own, call useThemeToggle. Pass its onSelect and disabled to the item. A press flips the theme and keeps the menu open, and the new theme grows in a circle from the ThemeSwitcherIcon in the item. Call the hook in a component that renders inside the menu content.

import { Button } from "@ngrok/mantle/button";import { DropdownMenu } from "@ngrok/mantle/dropdown-menu";import { ThemeSwitcherIcon, useThemeToggle } from "@ngrok/mantle/theme-switcher"; export function ThemeItem() {	const toggle = useThemeToggle(); 	return (		<DropdownMenu.Item disabled={toggle.disabled} onSelect={toggle.onSelect}>			<ThemeSwitcherIcon />			Appearance		</DropdownMenu.Item>	);} <DropdownMenu.Root>	<DropdownMenu.Trigger asChild>		<Button type="button" appearance="outlined" intent="neutral">			Account		</Button>	</DropdownMenu.Trigger>	<DropdownMenu.Content>		<DropdownMenu.Item>Profile</DropdownMenu.Item>		<ThemeItem />		<DropdownMenu.Separator />		<DropdownMenu.Item>Log out</DropdownMenu.Item>	</DropdownMenu.Content></DropdownMenu.Root>;

important

ThemeDropdownMenuItem must render inside a DropdownMenu.Content or DropdownMenu.SubContent. It renders a DropdownMenu.Item, which only works within an open dropdown menu. The type system cannot express this constraint, so it is on you to uphold it.

Polymorphism

ThemeSwitcher, ThemeDropdownMenuItem, and ThemeSwitcherIcon don't accept asChild. Each owns what it renders: the switcher renders only its icon, the item renders a DropdownMenu.Item, and the icon renders only the icon swap. To use your own menu item, call useThemeToggle. To use your own control of another kind, call useTheme.

Accessibility

  • When label is omitted, the accessible name says which theme the next click shows: "Switch to dark theme", "Switch to light theme", or "Switch to light theme (system default)" when the click returns to the OS setting. The server can't read the OS setting, so the name is "Toggle theme" until hydration.
  • The circular reveal is movement, so it does not run under prefers-reduced-motion: reduce.
  • The icon mirrors the applied theme (a sun or a moon). It's SSR-safe: a Skeleton placeholder renders until hydration, then the icon appears.
  • ThemeDropdownMenuItem is a role="menuitem". When label is omitted, its accessible name starts with the visible "Theme" and says which theme the next press shows, such as "Theme: Switch to dark theme". When label is set, the label is the name. When children is set, the children are the name. Under forceTheme, the item is disabled.
  • ThemeSwitcherIcon is decorative, so it has aria-hidden.

API Reference

ThemeSwitcher

The light/dark toggle: an IconButton with an SSR-safe themed icon. Requires a ThemeProvider ancestor.

All props from IconButton except icon, intent, asChild, and children (the switcher renders only its icon and aria-label), plus:

PropTypeDefaultDescription
appearance?"filled" | "ghost" | "outlined""ghost"The visual style of the IconButton.
label?stringNames the theme the click showsThe accessible name, rendered as its aria-label. Set it for localization.

ThemeDropdownMenuItem

The light/dark toggle in menu form: a Theme menuitem that ends in the ThemeSwitcher icon. It must render inside a DropdownMenu.Content or DropdownMenu.SubContent, and requires a ThemeProvider ancestor.

Set label or children, not both.

PropTypeDefaultDescription
label?string"Theme"The visible label. When set, it is also the accessible name; when omitted, the name also says which theme the press shows.
children?ReactNodeThe content of the item, in place of the default row. Put a ThemeSwitcherIcon where the icon goes.
className?stringCSS class name applied to the item.
style?CSSPropertiesInline styles applied to the item.
Data AttributeValueDescription
data-slot"theme-dropdown-menu-item"On the item element.
data-slot"theme-dropdown-menu-item-label"On the <span> that holds the label. The item renders it only with no children.

ThemeSwitcherIcon

The ThemeSwitcher icon on its own: a sun or a moon for the applied theme. Requires a ThemeProvider ancestor.

PropTypeDefaultDescription
className?stringCSS class name applied to the icon.
style?CSSPropertiesInline styles applied to the icon.
Data AttributeValueDescription
data-slot"theme-switcher-icon"On the <span> that holds the icon.

useThemeToggle

The toggle of ThemeDropdownMenuItem for a DropdownMenu.Item that you compose. Requires a ThemeProvider ancestor. It takes no arguments and returns:

FieldTypeDescription
disabledbooleantrue under forceTheme. Pass it to the item as disabled.
labelstringThe accessible name of the next press, such as "Switch to dark theme".
onSelect(event: Event) => voidThe item's onSelect. It flips the theme, keeps the menu open, and grows the reveal from the icon.