Skip to content
Components

Theme toggle

Light, dark, or follow the system — the one drawn form of useGdTheme. It shows where you are rather than where you would go, and it is deliberately only half the job: a pinned theme also needs a stamp in the document head.

Three states, cycled

System → Light → Dark → System. The order belongs to useGdTheme; the button draws it and does not get a vote.

The real control, and the same one at the foot of the nav — press it and this site changes theme.

Three rather than two, because System is a real answer and it is the default. It follows the operating system; only choosing light or dark stamps data-gd-theme and pins it. A two-state switch has no way to say "follow the OS", so it silently overrides that preference on first paint — which is the exact thing someone who set their machine to dark at 11pm is trying to avoid.

The composable shipped first, and every surface that wanted a switch re-typed the same four lines around it: a ghost button, the composable's icon, its label, and a media query to hide that label on a phone. Four lines is exactly the amount of code that drifts.

It shows where you are

The caption is the current theme, never the next one. A control captioned "Dark" that puts you in light mode is the oldest bug in this pattern.

The destination rides in the accessible name instead, which is where it belongs: Theme: System. Switch to light reads as a state plus a consequence, and it is re-read on every press because the name changes with the state.

The narrow bar

The label is the first thing to go. A navbar at 380px is fighting for room with the brand and the account, and the icon carries the meaning on its own.

Take the window under 768px and the first control loses its caption. The second is compact, which is that same state at every width.
  • The breakpoint is not a prop. 767px lives in the component so it is one number for every app — the docs chassis used to hide its label below 900px, and the next app to add a switch would have picked its own number.
  • compact forces the icon-only form at every width, for a bar that is tight whatever the window is doing — the map's, which also carries a council chip.
  • size takes xs, sm (the default) or md: it is a ghost button underneath, so it sizes like one.
  • The caption is hidden, not dropped. What the button announces is its accessible name, which does not change either way — so losing the visible label costs a screen reader nothing.

The half that lives in the head

Stamping a pinned theme before first paint is a document-head concern, and the component deliberately does not pretend otherwise.

// app/app.vue — once, for the whole app.
// GD_THEME_BOOT comes from @gridd/ui, auto-imported with the layer.
useHead({
  script: [{ innerHTML: GD_THEME_BOOT, tagPriority: "critical" }]
})

A toggle mounted in the body is already too late. The choice is read on mount, and on mount is after hydration — which is after paint — so a dark-pinned page renders its light HTML first, every single refresh. GD_THEME_BOOT is the inline script that closes that gap: tiny, run at critical priority so it lands ahead of the stylesheets, and wrapped in a try/catch because localStorage can throw in a private window and a theme is never worth an error.

Only a pin needs it. System needs no script at all — the media query in the stylesheet decides before paint by construction, and the attribute is the only thing that overrides it.

Sites on the docs layout already have it — the layout injects the script, which is why every page here survives a refresh in dark. For what the pin actually switches — the surface ladder, the re-derived status hues — see Dark mode.

Navigate

Esc