Theme Toggle
Light, dark and system themes without a flash on load.
npm install @devix-labs/theme-toggle
Twenty-seven million downloads a week go to theme libraries, and every one is tied to React or Vue. The part that matters most has nothing to do with a framework: a blocking script in <head> that sets the theme before the first paint. Here that is a 564-byte string you paste into any template — which is why the two most-reacted open issues on the React alternative, both about that script breaking inside the renderer, cannot happen. Plus theme-color for the browser chrome, several attributes at once for a page using two design systems, a three-way radio group because a switch cannot say “system”, color-scheme for native controls, cross-tab sync and suppressed transitions. 3.2 kB, zero dependencies.
What you get
The anti-flash script is a string
564 bytes, no renderer involved. The two most-reacted open issues on the React library — 75 reactions between them — are that script breaking when React renders it. A string cannot have that problem.
It colours the browser chrome
theme-color, the meta tag that paints the bar around your page on a phone — the biggest visible surface after the page itself. Asked of the alternative for years and still open.
Several attributes at once
Tailwind's .dark and Bootstrap's data-bs-theme together, which a page using two design systems needs. Also still an open request elsewhere.
No provider, so no provider problems
The theme lives on the document, which every component can already see. Nesting providers and forcing a theme on one route are two more open issues that simply do not arise.
Three states, properly
A checkbox cannot express light, dark, or whatever the machine says — so its label ends up lying. This is a radiogroup of three, which the browser gives arrow keys and announces as 2 of 3.
color-scheme, the forgotten line
Without it a dark page still has white scrollbars, a white caret and white native menus. The browser renders its own parts dark only when told.
No page-wide smear
A stylesheet full of transition: background .2s turns a theme switch into a 200ms smear of every colour at once. Transitions are suppressed for the moment of the change and restored after it.
Storage that cannot take the page down
Reading window.localStorage throws in a sandboxed iframe — not getItem, the property itself. The guard is around the lookup, and two tabs of the same site stay in step.
Theme Toggle — overview
Why it exists
Twenty-seven million downloads a week go to theme libraries. next-themes takes
nineteen million and is React-only; VueUse's useDark takes eight million and is
Vue-only. The framework-free options are a daisyUI helper at ten thousand a week
and two packages last published in 2019 and 2020.
So a Laravel application, an Astro site, a Rails app or anything server-rendered
has no good answer — and the thing they all need most is the part that has
nothing to do with a framework at all: a blocking script in <head> that sets
the theme before the first paint. Without it, every visitor using a dark theme
gets a white flash on every page load.
Which is exactly where the React one struggles. Its two most-reacted open issues are the same complaint twice — the anti-flash script breaking when React renders it — with a third asking to generate the string at build time instead. Seventy-five reactions on a problem that only exists because the script was made a component.
What it does differently
- The snippet is a string, 564 bytes, with no renderer involved. Put it in
HTML, Blade, Astro,
_document, or paste the output straight into your template and never import the package on the server at all. - It sets
theme-color, the meta tag that colours a phone browser's chrome — the biggest visible surface after the page itself, and still an open request on the alternative. - It writes several attributes at once, so a page using Tailwind's
.darkand Bootstrap'sdata-bs-themeworks without a second library. - There is no provider, so the questions that come with one — nesting, and forcing a theme on one route — do not arise.
- A three-way radio group. A checkbox cannot express light, dark, or whatever the machine says, so every two-state toggle ends up with a label that lies. Radios get arrow keys and a "2 of 3" announcement for free.
color-scheme, so scrollbars, carets,<select>menus and date pickers follow too — the most-forgotten line in any dark theme.- Transitions suppressed while switching, so a stylesheet full of
transition: background .2sdoes not smear the whole page. - Storage guarded at the property, because reading
window.localStoragethrows in a sandboxed iframe, and a theme script that throws takes the page down with it. - 3.2 kB, no dependencies.
Not in 1.0
A view transition on switch. document.startViewTransition can wipe the new
theme across the page, and it looks good — but it is Chromium-only today, it
fights the transition suppression that stops the page smearing, and a theme
change wants to feel instant. It belongs behind a flag once the API is settled.
Per-component themes. Theming one subtree differently is a real need, but it
is a CSS scoping question rather than a toggle one — element already lets this
write to something other than <html>, and that is as far as it should go
without inventing a cascade of its own.
How it compares
Questions
Why is the anti-flash script a string instead of a component?
Because as a component it has to be smuggled through dangerouslySetInnerHTML and fights the renderer — the two most-reacted open issues on next-themes, 75 reactions between them, are exactly that, and a third asks for the string to be generated at build time. A string runs before your bundle is fetched, works in Blade, Astro, Hugo or plain HTML, and can be pasted into a template so the package is never imported on the server at all.
Do I really need it? My site looks fine.
It looks fine to you because your choice matches your system. A visitor who has chosen dark on a server-rendered page sees the light theme paint first and correct itself a moment later — a white flash on every single page load. The only fix is a blocking script in <head>, before the stylesheet.
Why radios rather than a switch?
Because there are three choices and system is one of them. A two-state switch has no way to express “whatever the machine says”, so its label ends up describing a state it is not in. Radios are what a set of mutually exclusive options is, and the browser gives them arrow keys and a “2 of 3” announcement for nothing.
Does it work with Tailwind? Bootstrap? Both?
Yes to all three. Tailwind's dark: variant works with the default class attribute and no configuration; Bootstrap wants data-bs-theme; and attribute: ['class', 'data-bs-theme'] writes both, which is still an open request on the alternative.
What about themes other than light and dark?
Pass any names you like — themes: ['light', 'dark', 'sepia'] — and tell it which count as dark for color-scheme. Pass the same list to antiFlashScript() or the snippet will not recognise a stored sepia and will fall back to the system.
More from Devix
All resources →Toast
UI component
Promise-aware toasts with stacking, swipe to dismiss and correct live regions.
Confirm Dialog
UI component
Promise-based dialogs on native <dialog>: confirm, prompt and type-to-confirm.
Command Palette
UI component
A framework-free ⌘K palette with nested pages, async sources and shortcuts.