DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Three theme toggles in one app, three different answers to the same two lines, and none of them on the 72 pages a visitor sees

next-themes cannot tell you the theme during server rendering, because the answer lives in the visitor's localStorage and in their operating system, and the server has neither. So everybody writes this:

const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
Enter fullscreen mode Exit fullscreen mode

and then has to decide what to put on the screen until that effect runs. Our app answers that question three times, in three files, three different ways. I only noticed because I was grepping for something else.

The three

components/ThemeToggle.tsx, used in the dashboard header:

if (!mounted) {
  return <Button variant="ghost" size="icon" aria-label="Toggle theme" disabled />
}
Enter fullscreen mode Exit fullscreen mode

A real button, correct size, disabled, with the aria-label already on it. The icon size in our button variants is h-10 w-10, so it occupies exactly the 40 pixels the live control will occupy. No shift.

components/PlayerLobby.tsx, an inline copy for the player's waiting screen:

if (!mounted) {
  // Render an empty placeholder the same size to avoid layout shift
  return <span className="w-8 h-8 inline-block" />
}
Enter fullscreen mode Exit fullscreen mode

An empty span, 32 pixels, matching the 32 pixel button that replaces it. No shift either, and the comment says what it is for, which is more than most placeholders get.

components/PlayerGame.tsx, the header during the actual quiz:

{mounted && (
  <button onClick={() => setTheme(isDark ? 'light' : 'dark')} className="… w-8 h-8 …">
Enter fullscreen mode Exit fullscreen mode

Nothing. The button is 32 pixels wide and the space it will occupy is not reserved, so on the screen a player is looking at for the entire quiz, the header's right-hand group shifts by 32 pixels at hydration.

Same hook, same two lines above it, three answers: the right size with a disabled control, the right size with nothing in it, and no size at all.

Which is actually best

I went in assuming the disabled button was the good one and the missing placeholder was the bug. Half right.

The missing placeholder is the bug, and it is the smallest of the three problems, because it happens on a page loaded once at the start of a quiz night and the shift is one icon's width on one row.

The disabled button is worse than it looks. A disabled button is removed from the tab order, so somebody keyboard-navigating in the first frame tabs straight past it into the profile menu, and then the button appears behind where their focus now is. It also announces as unavailable to a screen reader for as long as it is there, which is a different statement from "this is loading". The empty span is the honest one: it reserves the space and claims nothing.

If I were writing the rule down: reserve the box, put nothing interactive in it, and let the real control arrive. The player lobby got there first and the other two copied the idea without copying the implementation.

The other asymmetry

The dashboard toggle is a dropdown with three options:

<DropdownMenuItem onClick={() => setTheme("light")}>Light</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>System</DropdownMenuItem>
Enter fullscreen mode Exit fullscreen mode

Both player copies are a single button that flips between two values:

onClick={() => setTheme(isDark ? 'light' : 'dark')}
Enter fullscreen mode Exit fullscreen mode

For a one-off visitor on their own phone in a pub, a one-tap flip is obviously the right control and a dropdown would be silly. But it is a one-way door: setTheme('light') writes an explicit preference, and there is no path in the player UI back to system. Tap it once in a dark pub and your phone's own light and dark schedule stops being consulted on that origin until you find the dashboard, which most players will never see because most players do not have an account.

The part that surprised me

The provider is configured like this:

<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
Enter fullscreen mode Exit fullscreen mode

enableSystem is on. The dashboard offers System as a choice and it works. But next-themes documents defaultTheme as 'system' precisely so that enableSystem has something to do on a first visit, and we have overridden it to dark. So nobody is ever on system unless they go and pick it, and the only place you can pick it is behind the login.

That is probably the intended look. It is worth being clear that it is a choice and not a consequence of enableSystem, because the two lines sit next to each other and read like they agree.

And none of this is on the public site

Here is the count that makes the whole thing slightly funny. We have 72 indexable pages. The number of them that ship any theme control at all is zero.

$ curl -s https://pub-trivia.app/features | grep -oiE 'aria-label="[^"]*theme[^"]*"'
$
Enter fullscreen mode Exit fullscreen mode

ThemeToggle is imported by exactly one file, DashboardHeader, which is behind the auth gate. The two player copies live on /play, which needs a real venue and table. The marketing site, the guides, the free tools, the comparison pages: all of them render inside a provider configured for three themes, with no way to reach any of the other two.

Which is fine. It is a dark site, it was designed dark, and the link preview cards are drawn in the same palette. But three implementations of a control that no visitor can reach, with three different pre-hydration behaviours between them, is a good illustration of how a component gets copied twice before anybody asks whether the first copy was right.

Go and look at the thing nobody can switch: the features hub, the tools and the big screen display page, which is the one page on the site where the dark palette is doing an actual job, because the screen it describes is pointed at a room.

Top comments (0)