Published on

My auto dark mode lasted 60 seconds, then saved itself as dark

My auto dark mode lasted 60 seconds, then saved itself as dark
Authors
On this page

In December 2024 I added a theme button to this blog with three modes: auto, light and dark. Auto picks light from 6 AM to 6 PM and dark the rest of the day.

It was broken for almost two years. A new visitor got the light theme for about a minute, whatever the hour. After that the site remembered a fixed theme, and auto never came back unless they clicked through the cycle again.

The original code

The site uses next-themes 0.3.0. My switch stored its mode in localStorage and started a timer when the mode was auto:

useEffect(() => {
  setMounted(true)
  const savedTheme = localStorage.getItem('theme') || 'auto'
  setTheme(savedTheme)

  if (savedTheme === 'auto') {
    const interval = setInterval(() => {
      setTheme(isDaytime() ? 'light' : 'dark')
    }, 60000)
    return () => clearInterval(interval)
  }
}, [setTheme])

The problem is the key name. next-themes stores the active theme under theme too, and it writes that key on every setTheme call. So my code and the library were writing to the same slot with different meanings. I stored a mode (auto). The library stored a resolved theme (light or dark).

What a visitor saw

I reproduced it on a production build with Playwright, with the clock set to 22:00:

  1. First load: setTheme('auto') runs. auto is not a theme next-themes knows, so the <html> element gets the class auto, which my Tailwind config ignores. The visitor sees the light theme at 10 PM.
  2. 60 seconds later: the interval fires and calls setTheme('dark'). The page turns dark, and next-themes saves dark under theme.
  3. Next visit: my code reads theme, gets dark, and never starts the interval. The visitor is now on a fixed dark theme they never picked.
Four steps of the bug: first load at 22:00 shows light, the timer saves dark after 60 seconds, the next visit reads dark and skips the timer, auto mode is gone

The toggle had the same confusion. It read theme from the hook to find the next mode, so after the timer fired, a click from "auto" actually started from dark.

The fix: give the mode its own key

The user's choice and the rendered theme are two different values, so they get two different keys. next-themes keeps theme. My switch stores the mode under theme-mode:

const MODE_KEY = 'theme-mode'
const NEXT_MODE = { auto: 'light', light: 'dark', dark: 'auto' }

const timeOfDayTheme = () => {
  const hours = new Date().getHours()
  return hours >= 6 && hours < 18 ? 'light' : 'dark'
}

const resolveMode = (mode) => (mode === 'auto' ? timeOfDayTheme() : mode)

The mode lives in React state. It starts as auto and is read from theme-mode after mount. One effect turns it into a real theme and only runs the timer in auto:

useEffect(() => {
  if (!mounted) return // wait for the saved mode
  setTheme(resolveMode(mode))
  if (mode !== 'auto') return
  const interval = setInterval(() => setTheme(resolveMode('auto')), 60000)
  return () => clearInterval(interval)
}, [mounted, mode, setTheme])

Two things changed. setTheme only ever receives light or dark, so there is no minute of a made-up class. And the timer can write whatever it likes to theme, because nothing reads that key to decide the mode anymore. The icon and the tooltip read mode too, so the button shows what the user picked, not what the clock resolved.

The mounted guard came from review. Without it, the effect first ran with the default auto, so someone who picked dark saw a light frame at 10 AM before their saved mode loaded.

Testing it with a fake clock

A bug that needs a specific hour and a 60-second wait does not get caught by clicking around. Playwright can install a fake clock in the page, so the test sets the time to 17:59:30 or 22:59:30 and fast-forwards:

await page.clock.install({ time: new Date(2026, 9, 14, hour, 59, 30) })
await page.goto(url)
expect('fresh visit dark', await dark(), hour < 6 || hour >= 18)

await page.clock.runFor(61_000) // cross the hour, fire the interval
await page.reload()
expect('auto survives reload', (await title()).startsWith('Automatic'), true)

expect is a small helper that prints PASS or FAIL. The full script checks seven things at each of the two hours: the first visit, the hour change, auto surviving a reload, two clicks, dark surviving a reload, and the timer leaving a manual dark alone. On the old build, 9 of the 14 checks failed. On the fixed build, all 14 pass.

Those checks all ran near 18:00 or 23:00, when auto also resolves to dark, so they could not catch the light frame. A second script loads a saved dark mode at 10:00 and a saved light mode at 22:00, records every class change on <html> with a MutationObserver, and fails if the other theme ever appears. It failed both cases before the guard and passes both after.

What I would tell past me

  • Check what your library stores. next-themes documents its storageKey option, which defaults to theme. My code used the same name.
  • Keep the choice apart from its result. "Auto" is a choice. "Dark at 22:00" is a result. Storing both under one name is how the result overwrote the choice.
  • Put the clock in the test. A fake clock made a 60-second bug take under a second to reproduce.

The switch in the header of this site is the fixed version. If you have a theme toggle with an auto mode, open devtools, wait a minute, and look at localStorage.


Resources