Theme Toggle
A button that flips the page between light and dark. It renders both the sun
and the moon and lets CSS keyed on data-theme decide which one shows — so the
server never needs to know the visitor's preference, there is no hydration
mismatch, and the icon is right at first paint, in any framework.
Clicking writes data-theme on <html> — the one attribute the whole palette
reads — and persists the choice to localStorage("forte-theme"). Try it: the
demo above flips this entire site, because this site's theme is that
attribute.
Import
import { ThemeToggle, ThemeScript, useTheme } from "@forte-ui/react";No flash on reload
With nothing stored, a page needs no setup at all: absent the attribute,
every token resolves through light-dark() and follows the OS from the very
first paint. The only load that can flash is one replaying a stored choice
that disagrees with the OS — dark chosen on a light machine — and closing that
gap takes one inline script that runs before <body> paints.
In Next.js (or any framework that renders <head>), render ThemeScript in
the root layout:
import { ThemeScript } from "@forte-ui/react";
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<ThemeScript />
</head>
<body>{children}</body>
</html>
);
}suppressHydrationWarning goes on <html> because the script legitimately
makes the client's root element differ from what the server sent.
In a Vite (or any static-index.html) app, a bundled component cannot run
before the bundle, so the same script goes into index.html directly —
create-forte-ui already scaffolds it:
<script>
(function(){try{var t=localStorage.getItem("forte-theme");if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t)}catch(e){}})();
</script>
The string is also exported as themeInitScript for frameworks where head
markup is authored as text.
Examples
Variants
variant decides how much chrome the button carries: ghost is bare until
hovered, soft sits on a panel fill, outline draws a border. There is no
solid — the toggle asserts nothing on or off the way Toggle does, so a loud
fill would only compete with real actions nearby.
Sizes
The same three steps as Button, so the toggle lines up beside one in a header
instead of sitting a pixel proud. The icon follows the button through
--forte-theme-toggle-icon-size, which defaults to half the button size.
Custom icons
icons swaps the artwork per resolved theme. Custom icons drop into the same
wrappers as the built-in glyphs, so the CSS that picks the visible one — and
the cross-fade between them — keep working unchanged, and any svg handed over
is sized to --forte-theme-toggle-icon-size regardless of its own box.
If what you want is not a different icon but a different control — a
three-way picker, a menu item — skip the component and build on
useTheme below.
Controlled
Pass theme and onThemeChange and the button never touches the document: it
reports the opposite mode and shows whatever the prop says — the attribute
lands on the button itself, where it outranks any ancestor. That makes it a
dumb button for an external theme manager, or for theming a subtree:
Note the forte-theme class next to data-theme on the card. The attribute
alone only flips color-scheme; the palette is re-derived on scope markers, so
an island without one keeps the page's already-resolved colours — the
scoped-themes trap.
This is also the next-themes hook-up, spelled out in the Next.js guide:
const { resolvedTheme, setTheme } = useTheme(); // next-themes' hook
<ThemeToggle theme={resolvedTheme} onThemeChange={setTheme} />;next-themes brings its own storage and its own pre-paint script, so skip
ThemeScript and forte-ui's useTheme entirely in that setup — one writer for
data-theme at a time.
useTheme
The hook behind the toggle, for building your own control. theme is the
stated preference — "system" when the page follows the OS — resolvedTheme
is what that currently amounts to on screen, and setTheme writes a new one.
"system" removes the attribute and the stored record, handing the page back
to the OS live: no listener has to chase the preference, because CSS follows it
natively.
The document is the source of truth: the hook reads data-theme off <html>
(watching it with a MutationObserver), so it stays correct even when
something else writes the attribute, and a storage listener carries a choice
made in one tab onto every other. On the server — and during the hydration
render — the snapshot is { theme: "system", resolvedTheme: "light" }; markup
that must be correct at first paint should key off data-theme in CSS, the way
ThemeToggle does, rather than branch on these values.
Accessibility
| Key | Behaviour |
|---|---|
| Tab | Moves focus to the toggle. |
| Space then Enter | Switches the theme, as on any button. |
The root is a native <button>. Its accessible name comes from a
visually-hidden label inside the visible icon's wrapper — the hidden wrapper
is visibility: hidden, which removes it from the accessibility tree — so the
button always exposes exactly one action: "Switch to dark theme" while light
shows, "Switch to light theme" while dark does. labels overrides both
strings, which is the i18n hook.
Motion. The swap is an opacity cross-fade with a rotate-and-shrink gesture
on top, and the gesture is multiplied by --forte-motion-ok — under
prefers-reduced-motion: reduce it collapses to the plain fade, with no media
query in the component. The press squash follows the house pattern: snap in on
--forte-duration-instant, spring out on --forte-ease-spring-snappy.
Theming
Every visual decision is a custom property declared on the button root. Set
them on the toggle itself — through className or an inline style — rather
than on an ancestor: the component declares its own defaults on the root
element, and an element's own declaration beats an inherited value.
| Property | Controls | Default |
|---|---|---|
--forte-theme-toggle-radius | Corner radius. | var(--forte-radius-control) |
--forte-theme-toggle-size | Width and height of the (square) button. size re-points it. | var(--forte-control-h-md) |
--forte-theme-toggle-icon-size | Icon size. Follows the button size unless pinned. | calc(var(--forte-theme-toggle-size) * 0.5) |
--forte-theme-toggle-bg | Fill at rest. var(--forte-color-panel) for variant="soft". | transparent |
--forte-theme-toggle-bg-hover | Fill on hover. | var(--forte-color-panel-hover) |
--forte-theme-toggle-fg | Icon colour at rest. | var(--forte-color-foreground-muted) |
--forte-theme-toggle-fg-hover | Icon colour on hover. | var(--forte-color-foreground) |
--forte-theme-toggle-border-width | Border width, drawn in every variant — outline is the only one that gives it a colour. | 1px |
--forte-theme-toggle-border-color | Border colour. var(--forte-color-border) for variant="outline". | transparent |
--forte-theme-toggle-duration | Colour transitions between states, and the icon cross-fade. | var(--forte-duration-fast) |
--forte-theme-toggle-icon-turn | How far the leaving icon rotates on its way out (the entering one arrives from the other side). Multiplied by --forte-motion-ok, so it collapses under reduced motion and the swap becomes a plain fade. | 90deg |
--forte-theme-toggle-press-scale | How far the button squashes while held. Multiplied by --forte-motion-ok, so a literal value still collapses under reduced motion. | var(--forte-scale-press) |
API reference
ThemeToggle
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
icons | ThemeToggleIcons | Replacement artwork, per resolved theme. Custom icons drop into the same wrappers as the built-in sun and moon — the CSS that decides which one shows, and the cross-fade between them, keep working unchanged. | |
labels | ThemeToggleLabels | Accessible names, per resolved theme — the override point for i18n. Each names the action, not the state: while light shows, the button offers dark. An `aria-label` prop still wins over both, but flattens the two states into one name. | |
onThemeChange | ((theme: ResolvedThemeMode) => void) | Called with the mode the click asks for. In uncontrolled mode it fires after the document has been updated; with `theme` set it is the only effect a click has. | |
ref | Ref<HTMLButtonElement> | Ref to the button element. | |
size | ThemeToggleSize | md | Size of the (square) button. Matches `Button` step for step so the toggle lines up beside one in a header. |
theme | ResolvedThemeMode | Controls the toggle. When set, the button never touches the document — clicking only reports the opposite mode through `onThemeChange`, and the icon follows this prop instead of the page. This is the hook-up for an external theme manager (e.g. next-themes: pass its `resolvedTheme` here and its `setTheme` to `onThemeChange`). Leave unset for the built-in behaviour: the click writes `data-theme` on `<html>` and persists it to `localStorage("forte-theme")`. | |
variant | ThemeToggleVariant | ghost | How much chrome the button carries at rest: `ghost` is bare until hovered, `soft` sits on a panel fill, `outline` draws a border. There is no `solid` — the toggle states nothing on or off the way `Toggle` does, so a loud fill would only compete with real actions nearby. |
Anything a native <button> accepts passes through as well. disabled also
sets data-disabled, which is what the styles key off.
ThemeScript
Renders the pre-paint replay script — see No flash on
reload. Takes any <script> prop; nonce is the one
that matters under a strict CSP.
| Prop | Type | Default | Description |
|---|---|---|---|
nonce | string | CSP nonce, when the app serves a `script-src` policy that requires one. Forwarded to the rendered `<script>` element like any other prop; it is only declared here so it appears in the prop table. |
useTheme
const { theme, resolvedTheme, setTheme } = useTheme();
// theme: "light" | "dark" | "system" the stated preference
// resolvedTheme: "light" | "dark" what is on screen
// setTheme: (theme: ThemeMode) => void write + persist a preferenceTwo lower-level helpers ship beside it for code outside React's render cycle:
setDocumentTheme(theme) (what setTheme and the uncontrolled toggle call)
and resolvedDocumentTheme() (the current mode, read straight off the
document).