Theme toggle
Light, dark, or follow the system — the one drawn form of useGdTheme, for a navbar, a docs rail or anywhere a person changes the page's theme.
Three states, cycled
Code
<GdThemeToggle />The narrow bar
Code
<GdThemeToggle />
<GdThemeToggle compact /> <!-- icon only, at any width -->The half that lives in the head
// 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" }]
}) Sites on the docs layout already have it — the layout injects the script, which is why every page here survives a refresh in dark. Only a pin needs it: System needs no script at all.
API
Props
compactbooleanfalseIcon only, at every width — for a bar with no room to spare. It is `GdButton`'s icon mode, not a text button with its words hidden.
size"xs" | "sm" | "md""sm"It is a ghost button underneath, so it sizes like one. The button's `lg` is deliberately not sayable here: nothing in a bar is that big.
No slots and no events: the state lives in useGdTheme, and a caller who wants to read or set the theme calls the composable rather than listening to the button. The 767px breakpoint is not a prop.
Usage Guidelines
- Put one in the navbar of any app that offers a theme at all, and only one — the state is shared, so two controls on a page stay in agreement.
- Ship the head script with it. An app that offers this control owes
GD_THEME_BOOT; without it a dark-pinned page flashes light on every refresh. - Reach for
compactwhen the bar is tight at every width — the map's, which also carries a council chip. Otherwise let the 767px collapse do it. - Use
sizeto match the bar, not to make the control louder.smis the default and is right almost everywhere. - Call
useGdThemedirectly when you need the resolved theme in JavaScript — a MapLibre paint value, a three.js material. CSS never needs it. - Do not caption it with the destination. The button shows where you are; the accessible name carries where you would go.
Behavior & Anatomy
Three states, not two
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
A navbar at 380px is fighting for room with the brand and the account, and the icon carries the meaning on its own.
- The breakpoint is not a prop. 767px lives in the component so it is one number for every app. Expose it and each app picks its own, and the switch collapses at a different width in every one.
compactforces 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.sizetakesxs,sm(the default) ormd: it is a ghost button underneath, so it sizes like one.- Compact is
GdButton's icon mode, not a text button with its words hidden. That distinction is geometry: icon mode is a real square that drops the.gd-btn__labelwrapper, while hiding only the caption left that wrapper in the flex row as a zero-width item still collecting the row's gap — a 47×29 button with its glyph 8px left of centre. The narrow-viewport collapse reproduces the same square, so both routes land on one geometry. - Losing the caption costs a screen reader nothing. What the button announces is its accessible name, and that does not change either way.
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.
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.
For what the pin actually switches — the surface ladder, the re-derived status hues — see Dark mode.