COMPONENT
Theme toggle
One square, a sun and a moon, and your reader's choice remembered. The specimen below is real: press it and this page changes, along with every other page in the book.
The switch
Identical to the one in the rail on the left, and it drives the same document. Press either β both stay in step.
<button class="theme-toggle" type="button" data-ds-pending
aria-pressed="false" aria-label="Light theme" x-ds-theme></button>Reference
Read from components/theme-toggle.css when this page was built, so it cannot fall behind the stylesheet.
.theme-toggle--ds-focus-ring--ds-focus-width--ds-ink--ds-ink-soft--ds-motion-fast--ds-pad-toggle--ds-radius-nav--ds-theme-is-light--ds-toggle-glyphNotes
Everything else in this library draws, opens, closes and takes the keyboard with alpine/index.js absent β every directive improves something that already worked. This one cannot. What it does is remember, and a preference that outlives the page is state, which CSS holds none of.
So it does not pretend. The button ships holding data-ds-pending and x-ds-theme takes the attribute off as its first act: a reader with scripting off is never shown a switch that would not switch. The CSS withholds it with visibility, so it keeps its box and arriving shifts nothing in the row it sits in.
A second component built this way would mean the promise had stopped being true. There is one, and this is it.
Each theme declares --ds-theme-is-light β 0 for a dark one, 1 for a light one β and the sun and moon cross-fade on it. So they change because the theme changed, not because a class was toggled. A face driven by the button's own state lies exactly when it matters: a page served light with a control that has not been told yet.
aria-pressed says the same thing to a reader who cannot see it, and it is read back off data-theme rather than set alongside it. That is what lets a page carry two of these β a header and a footer β without them disagreeing.
Alpine boots after the document is parsed, so the stored theme arrives a frame after the first paint unless the document applies it itself. This book runs exactly this, which is why switching to paper and following a link does not flash.
<!-- in <head>, before the first paint -->
<script>
try {
const t = localStorage.getItem('ds-theme');
if (t) document.documentElement.dataset.theme = t;
} catch {}
</script>try is not defensive padding: storage throws on access where it is denied β a private window, third-party cookies blocked β and an uncaught exception in a head script stops the rest of the head.
It is not required. Leave it out and the switch still works, still remembers, and still restores on the next page; the reader sees one frame of the default theme first. The directive reads the document rather than storage on boot, so the two can never argue.
With no value it switches between portfolio-dark and portfolio-light. A consumer with its own pair names them, and the order is the contract β the dark one first, because a pair written the other way round would silently invert every reader's choice.
<!-- a consumer with its own pair names them, dark first -->
<button class="theme-toggle" type="button" data-ds-pending
aria-pressed="false" aria-label="Light theme"
x-ds-theme="acme-night acme-day"></button>It is the obvious attribute for "not relevant yet", and it is the wrong one here. The UA stylesheet spells it display: none !important β measured, not assumed: an inline display: grid on a hidden element still computes to none. So it does not merely withhold the control, it takes the box away with it, and the switch arriving a frame later shoves everything beside it sideways.
<!-- what does NOT work, and it is the obvious first try -->
<button class="theme-toggle" hidden β¦></button>
/* the UA sheet, measured in Chromium: */
[hidden] { display: none !important; }The attribute is on the markup rather than absent from it, too: [data-ds-pending] adds the waiting, where a :not([data-ds-ready]) would make it the base state. A component's base class is the ordinary case and a modifier adds β the same rule that says the name is is-disabled and never is-enabled.
nav-toggle composes one because it is one β it opens a panel. This opens nothing, and .dropdown-button carries an anchor-name. Two elements sharing an anchor name means the last in tree order wins it, so a theme switch sitting after a menu button in a header would quietly steal the menu panel's anchor. It owns its own small box instead.