Skip to content

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.

theme-toggle/basic.tsx

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.

theme-toggle/variants.tsx

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.

theme-toggle/sizes.tsx

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.

theme-toggle/custom-icons.tsx

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:

theme-toggle/controlled.tsx
Scoped to this cardcurrently dark

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.

theme-toggle/use-theme.tsx
theme: system · resolved: light

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

Keyboard interactions
KeyBehaviour
TabMoves focus to the toggle.
Space then EnterSwitches 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.

Theming tokens for ThemeToggle
PropertyControlsDefault
--forte-theme-toggle-radiusCorner radius.var(--forte-radius-control)
--forte-theme-toggle-sizeWidth and height of the (square) button. size re-points it.var(--forte-control-h-md)
--forte-theme-toggle-icon-sizeIcon size. Follows the button size unless pinned.calc(var(--forte-theme-toggle-size) * 0.5)
--forte-theme-toggle-bgFill at rest. var(--forte-color-panel) for variant="soft".transparent
--forte-theme-toggle-bg-hoverFill on hover.var(--forte-color-panel-hover)
--forte-theme-toggle-fgIcon colour at rest.var(--forte-color-foreground-muted)
--forte-theme-toggle-fg-hoverIcon colour on hover.var(--forte-color-foreground)
--forte-theme-toggle-border-widthBorder width, drawn in every variant — outline is the only one that gives it a colour.1px
--forte-theme-toggle-border-colorBorder colour. var(--forte-color-border) for variant="outline".transparent
--forte-theme-toggle-durationColour transitions between states, and the icon cross-fade.var(--forte-duration-fast)
--forte-theme-toggle-icon-turnHow 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-scaleHow 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

Props for ThemeToggle
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
iconsThemeToggleIconsReplacement 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.
labelsThemeToggleLabelsAccessible 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.
refRef<HTMLButtonElement>Ref to the button element.
sizeThemeToggleSizemdSize of the (square) button. Matches `Button` step for step so the toggle lines up beside one in a header.
themeResolvedThemeModeControls 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")`.
variantThemeToggleVariantghostHow 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.

Props for ThemeScript
PropTypeDefaultDescription
noncestringCSP 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 preference

Two 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).