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.
Choosing a control
Give each surface one theme control. Two controls for one preference in one view make the user ask which one wins.
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.
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:
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.
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.
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.
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
labelis 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
Skeletonplaceholder renders until hydration, then the icon appears. ThemeDropdownMenuItemis arole="menuitem". Whenlabelis omitted, its accessible name starts with the visible "Theme" and says which theme the next press shows, such as "Theme: Switch to dark theme". Whenlabelis set, the label is the name. Whenchildrenis set, the children are the name. UnderforceTheme, the item is disabled.ThemeSwitcherIconis decorative, so it hasaria-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:
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.
ThemeSwitcherIcon
The ThemeSwitcher icon on its own: a sun or a moon for the applied theme. Requires a ThemeProvider ancestor.
useThemeToggle
The toggle of ThemeDropdownMenuItem for a DropdownMenu.Item that you compose. Requires a ThemeProvider ancestor. It takes no arguments and returns: