Wiki Dark Mode Toggle
Live preview
icon-only
icon-and-label
A single toggle button that switches a documentation wiki between
its light and dark themes. It sets data-theme='dark' on the <html>
element, remembers the visitor's choice in localStorage, and falls back to the
operating-system prefers-color-scheme preference on a first visit.
components/wiki-dark-mode-toggle/recipe.jsonWhen to use Wiki Dark Mode Toggle
Use this toggle in the header or navigation chrome of a
Knowledge-Platform wiki to let a reader choose light or dark
theme and have that choice persist across pages and visits. It is
a theme control, not a general-purpose two-state switch: it does
one thing — flip data-theme on the document root and save the
result. For a control that submits, saves, or opens something, use
a Button instead.
The component originates in the Knowledge Platform surface (see
Knowledge Platform overview)
and is theme-agnostic — it drives whatever light/dark token set the
consuming site has mounted. It is a distinct, standalone recipe;
it is not the same implementation as design.pointsav.com's own
#theme-toggle script, though the two share the same conceptual
model (persisted choice, prefers-color-scheme fallback).
Variants
The recipe ships two variants. Both share one button, one CSS
class (ps-wiki-dark-toggle), and identical behaviour — they
differ only in whether the text label is visible.
| Variant | Renders | Use for |
|---|---|---|
| icon-only | Icon alone; the text label is present but screen-reader-only. | Compact chrome — a dense header or a narrow mobile nav bar where space is tight. |
| icon-and-label | Icon plus a visible text label (Dark / Light). | Roomier navigation where the affordance benefits from a written cue. |
In both variants the label text is always present in the markup —
the icon-only variant hides it visually but keeps it for assistive
technology, so the accessible name never depends on the icon alone.
Anatomy
The button has two internal elements:
- Icon (
.ps-wiki-dark-toggle__icon) — a moon (🌙) in light mode, a sun (☀) in dark mode. It is decorative and markedaria-hidden="true", so it is never announced. - Label (
.ps-wiki-dark-toggle__label) — readsDarkin light mode andLightin dark mode. Visible in theicon-and-labelvariant, screen-reader-only inicon-only.
Both the icon glyph and the label text describe the current
theme's opposite — the destination the button will take you to —
which keeps them consistent with the action-oriented aria-label
described under Accessibility.
Behaviour
Initialisation and flash prevention
On load, an inline init script reads localStorage key ps-theme.
If the stored value is dark — or if nothing is stored and the OS
reports prefers-color-scheme: dark — it sets
data-theme='dark' on <html>. Everything else stays in the
default light theme.
This script must run before first paint. Place it as an inline
script in <head>, ahead of stylesheet-dependent rendering, so the
correct theme is applied before the page is drawn. Deferring it —
loading it as an external module, or placing it at the end of
<body> — reintroduces the light-to-dark flash it exists to
prevent.
Toggling and persistence
Clicking the button reads the current data-theme, flips it
(dark ↔ empty), and writes the new choice to localStorage under
ps-theme. The stored value outlives the session, so a reader who
chose dark once stays in dark on every later visit until they
choose otherwise — the OS preference is consulted only when no
stored choice exists.
Tokens
Every colour, space, radius, and motion value comes from the token substrate — the component hard-codes none of them. Swapping the mounted theme restyles the toggle with no recipe change.
| Token | Role |
|---|---|
semantic.surface.layer-hover | Hover background fill. |
semantic.text.secondary | Default icon and label colour. |
semantic.interactive.focus-ring | :focus-visible outline colour. |
primitive.radius.sm | Corner radius. |
primitive.space.1 | Internal padding. |
primitive.motion.duration.fast | Hover background-colour transition duration. |
The hover transition collapses gracefully for readers who request
reduced motion when the mounted theme wires
primitive.motion.duration.fast to a reduced-motion-aware value.
Accessibility
The toggle targets WCAG 2.2 AA and its ARIA contract is defined by the recipe:
aria-pressedcarries the state astrueorfalse— nevermixed. The button is a genuine two-state toggle, so the binary pressed states are correct; a tri-state value would misrepresent it.aria-labeldescribes the action, not the state. In light mode the label reads "Switch to dark mode"; in dark mode, "Switch to light mode." It updates on every toggle. This tells a screen-reader user what the button will do, which is more useful than restating the theme they are already in.- The icon is
aria-hidden="true". The accessible name comes fromaria-labeland the always-present label text, so the emoji glyph is never read aloud. - Focus is visible.
:focus-visibledraws a 2pxsemantic.interactive.focus-ringoutline at 2px offset, so keyboard focus is unambiguous.
Contrast
The component itself is chrome; the WCAG-critical question is
whether the dark theme it switches to stays legible. Per the
recipe's audit notes, the dark-mode colour pairs pass AA. One
of seven pairs narrowly misses the stricter AAA floor: the weakest,
#4a9eff on #1a1a1a, measures 6.32:1 — an AA pass that falls
just short of the 7.0:1 AAA threshold for normal-size text. Reserve
that exact pairing for large or bold text, or decorative use, if
you need it at that threshold; do not rely on it for small body
copy where AAA is required.
An earlier revision of this recipe claimed all seven pairs passed AAA. That claim was self-contradictory and was corrected during a 2026-07-15 compliance audit — the honest status is AA, with the single near-miss noted above. The doc states only what the audit substantiated.
When not to use
- Not a settings switch. For an on/off preference that saves a value on a form, use a switch or checkbox pattern, not this theme-scoped control.
- Not a Button. This does not submit, save, navigate, or open a dialog. If the control performs work, it is a Button.
- Not for per-component theming. It flips the whole document via
data-themeon<html>. It cannot theme one region in isolation. - Do not defer the init script. Loading it late reintroduces the first-paint flash it exists to prevent.
Important Information
Design System disclosure
This site provides open-source design tokens, documentation, and self-hostable software published by Woodfine Capital Projects Inc. Information here is for general reference only and does not constitute an offer, warranty, or a guarantee of fitness for any particular purpose. Statements regarding planned, intended, or targeted future features are forward-looking and subject to change without notice; they are not undertaken to be updated except as required by law.