# dark-mode-toggle: a web component that starts from prefers-color-scheme and lets users override it

> dark-mode-toggle is a small custom element from GoogleChromeLabs that gives a site a dark mode switch, honouring the system preference by default and letting a visitor override it for that site only. This looks at the three integration styles it supports and the flash of the wrong theme it can still cause.

**GoogleChromeLabs/dark-mode-toggle** — A custom element that allows you to easily put a Dark Mode 🌒 toggle or switch on your site:

- Repository: https://github.com/GoogleChromeLabs/dark-mode-toggle
- Website: https://googlechromelabs.github.io/dark-mode-toggle/demo/index.html
- Stars: 1,227 · Forks: 75
- Language: JavaScript
- License: Apache-2.0
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/googlechromelabs-dark-mode-toggle

## System preference first, site override second

The `<dark-mode-toggle>` element is a custom element that lets you put a dark mode toggle or switch on your site, with the stated design being that you initially adhere to your users' preferences according to `prefers-color-scheme`, but also allow them to optionally permanently override their system setting for just your site.

That second half is the point. The CSS media query alone is not a control. A visitor whose operating system is set to light has no way to see your dark theme without changing a system setting, and a site that offers both has to remember the choice. This element handles the media query, the override, and the persistence, and it does it as a single HTML tag with a few attributes.

The scope is deliberately narrow. The element is a switch, not a theme. It does not write your stylesheets, it does not generate a dark palette, and it does not tell you which colours to use. What it gives you is a control and a signal: an element that knows the current mode and emits an event when it changes, and your CSS that decides what that mode looks like.

The project is published on webcomponents.org, its author is Thomas Steiner, and the readme points to a long related article on web.dev, Hello Darkness, My Old Friend, on dark mode and `prefers-color-scheme`.

The licence is Apache-2.0, with the licence file at the repository root.

## Three integration styles with different performance characteristics

The readme documents three ways to use the element, and the differences between them are not stylistic.

The first is using different stylesheets per colour scheme, conditionally loaded. The element assumes you have organised your CSS into different files that you load conditionally based on the `media` attribute in the stylesheet's `link` element. The readme calls this a great performance pattern, because you do not force people to download CSS they do not need for their current theme preference, while the non-matching stylesheets still get loaded but do not compete for bandwidth in the critical rendering path. You can have more than one file per theme.

```html
<head>
  <link rel="stylesheet" href="common.css" />
  <link
    rel="stylesheet"
    href="light.css"
    media="(prefers-color-scheme: light)"
  />
  <link rel="stylesheet" href="dark.css" media="(prefers-color-scheme: dark)" />
</head>
```

The second is using a CSS class that you toggle. If you would rather not split your CSS into files per colour scheme, you work with a class such as `class="dark"` instead, and the element tells you when to add and remove it.

The third is using internal stylesheets for each colour scheme, defining styles directly in your HTML with `<style>` tags scoped to specific colour schemes. The readme attaches a warning to this one: internal stylesheets do not benefit from the loading optimizations provided by `<link>` elements, which may increase the page's initial load time.

## The flash of the wrong theme, and the loader that fixes it

The first integration style has a known failure mode, and the documentation names it and links the issue. The above method might cause flashing, issue number 77, when the page loads, as the dark mode toggle module is loaded after the page is rendered.

That is not a cosmetic detail. A visitor who chose dark mode and gets a white flash on every navigation will conclude the site is broken, and the flash is more visible in practice than in a design review, because it happens on the first paint of every page.

The fix is a separate loader script that applies the saved theme before the page is rendered. The procedure is to wrap the stylesheet tags with a `<noscript id="dark-mode-toggle-stylesheets">` element and add the loader script:

```html
  <noscript id="dark-mode-toggle-stylesheets">
    <link
      rel="stylesheet"
      href="light.css"
      media="(prefers-color-scheme: light)"
    />
    <link
      rel="stylesheet"
      href="dark.css"
      media="(prefers-color-scheme: dark)"
    />
  </noscript>
  <script src="https://googlechromelabs.github.io/dark-mode-toggle/src/dark-mode-toggle-stylesheets-loader.js"></script>
```

The `noscript` element with a specific id is not a semantic oddity, it is a hook. The loader script finds the stylesheets inside it, and the `noscript` wrapper means the stylesheet links are not parsed as ordinary link elements until the loader has had a chance to look at them.

There is a corresponding `<meta name="color-scheme" content="dark light">` in the documented snippet, which is the browser-level hint that tells the user agent the page supports both schemes, so the browser's own form controls and scrollbars match the theme.

The repository carries demo files named for these two cases, `with-flashing.html` and `without-flashing.html`, which is a reasonable way to see the difference without setting up a build.

## The class toggle listens for colorschemechange

The second integration style is the shortest piece of code in the readme, and it shows the event model.

```js
import * as DarkModeToggle from 'https://googlechromelabs.github.io/dark-mode-toggle/src/dark-mode-toggle.mjs';

const toggle = document.querySelector('dark-mode-toggle');
const body = document.body;

// Set or remove the `dark` class the first time.
toggle.mode === 'dark'
  ? body.classList.add('dark')
  : body.classList.remove('dark');

// Listen for toggle changes (which includes `prefers-color-scheme` changes)
toggle.addEventListener('colorschemechange', () => {
  body.classList.toggle('dark', toggle.mode === 'dark');
});
```

Two details are doing the work here. The `toggle.mode` property is the current resolved mode, and the code applies it once on load so the class matches the state the element already knows about. Then the `colorschemechange` event is the subscription, and the comment is explicit that it includes `prefers-color-scheme` changes, not just clicks on the switch.

That second point is the part people miss when they write their own toggle. The mode can change because the user clicked, and it can change because the operating system switched theme while the tab was open. An event that only fires on click leaves the page out of sync with the system the second time the visitor changes their desktop settings.

The demo folder in the repository contains a set of variations including `unstyled.html`, `internal-stylesheets.html` and `dist.html`, which is a useful set of starting points for working out which integration suits your build.

## Appearance is set with CSS custom properties, not attributes

The element's look is controlled through CSS custom properties, and the readme's example lists the full set.

```css
#dark-mode-toggle-1 {
  --dark-mode-toggle-dark-icon: url("https://googlechromelabs.github.io/dark-mode-toggle/demo/moon.png");
  --dark-mode-toggle-light-icon: url("https://googlechromelabs.github.io/dark-mode-toggle/demo/sun.png");
  --dark-mode-toggle-remember-icon-checked: url("https://googlechromelabs.github.io/dark-mode-toggle/demo/checked.svg");
  --dark-mode-toggle-remember-icon-unchecked: url("https://googlechromelabs.github.io/dark-mode-toggle/demo/unchecked.svg");
  --dark-mode-toggle-remember-font: 0.75rem 'Helvetica';
  --dark-mode-toggle-legend-font: bold 0.85rem 'Helvetica';
  --dark-mode-toggle-label-font: 0.85rem 'Helvetica';
  --dark-mode-toggle-color: var(--text-color);
  --dark-mode-toggle-background-color: none;
  --dark-mode-toggle-active-mode-background-color: var(--accent-color);
  --dark-mode-toggle-remember-filter: invert(100%);
}
```

The icons are given as URLs, the fonts as font shorthand, and the colours as references to your own variables such as `var(--text-color)` and `var(--accent-color)`, which is the pattern that makes the control match the site. The `filter: invert(100%)` on the remember icon is the kind of small detail you would not invent yourself.

The element's own attributes are few and mostly textual. In the readme's markup you get `legend="Theme Switcher"`, `appearance="switch"`, the two labels `dark="Dark"` and `light="Light"`, and `remember="Remember this"` for the persistence option. The `remember` attribute is the one that decides whether the visitor's override survives a visit, which is the difference between a temporary switch and the permanently overriding behaviour the readme describes.

## A build that is one terser pass and two linters

The package manifest shows how small this project is, which fits a component that ships as a single module.

The npm package is `dark-mode-toggle`, version 0.18.0, with the description "Web Component that toggles dark mode". The entry points are all the same minified ES module, `./dist/dark-mode-toggle.min.mjs`, exposed through the `main`, `module`, `unpkg` and `exports` fields, with the type declarations served from `./src/dark-mode-toggle.d.ts`. The published files are just `src` and `dist`.

The build script is a single terser invocation, and the details of the invocation are the interesting part. Terser runs with `--toplevel`, with `--mangle-props` and a regex of `^_` so that properties beginning with an underscore are mangled, with `--comments /@license/` so the licence comment survives minification, and with `--ecma=8`. The same treatment is applied to the stylesheets loader script.

The lint setup is two tools, with eslint over the JavaScript in `src` and `demo`, and stylelint over the stylesheets, and the CSS lint step has an unusual detail: it copies a template file to an HTML file, lints that, then copies it back and removes the temporary file. The formatting step is prettier across the whole project, and the `prepare` script runs lint, fix and build in sequence, so a publish always goes through the linters.

For local work, the `start` script runs `npx http-server` and prints the demo URL on port 8080, which is the fastest way to see the component without a build step.

## Where the dark mode toggle is the wrong tool

The limits are clear, and they are the same limits as any component that solves one problem well.

The first is that it does not design a dark theme. It switches, and you supply both stylesheets. For a site that has never been designed for dark mode, this component will not make it look good, and the work of writing a second, coherent set of styles is the bulk of the effort. A team whose real problem is that the site has no dark theme at all should treat this as a small piece of a larger design task, and the readme links the web.dev article on the topic for that reason.

The second is the flash. The stylesheet approach, which is the one with the best performance characteristics, needs the loader script wired in before render, and the internal stylesheet approach avoids the flash differently by giving up the loading optimisation. Neither is free, and the readme is upfront that the default path flashes.

The third is scope of customisation. The element gives you a switch or a toggle, a label, a legend and a remember option, styled through custom properties. It is not a component library, and it does not ship an opinionated set of controls. A design system team that wants their own switch component, or that wants the control to look like their own product, will style it from scratch anyway, and at that point the value of the element is the preference handling rather than the appearance.

The fourth is release maturity. The version is 0.18.0, the latest tag v0.18.0 is dated 2025-12-12, v0.17.0 was 2025-05-07 and v0.16.1 was 2024-10-07, and the last push to the repository was on 2026-08-05. The project is not archived, but a pre-1.0 component with irregular tags is something to pin rather than track, especially since the readme's own issue tracker is the place where the flash issue was discussed, which tells you where to look when behaviour surprises you.

## Conclusion

dark-mode-toggle fits a team shipping a static or lightly templated site that needs a dark theme switch without writing the toggle, the preference storage and the system preference handling by hand, and it fits a team that wants to keep separate stylesheets per colour scheme. It does not fit a design system project that wants a single themed stylesheet, and it is the wrong choice if you cannot accept a flash of the wrong theme on first load, since the fix is a separate loader script that has to be wired in before render. Before you adopt it, read the three usage sections in the readme, because the stylesheet, class and internal style approaches have different performance characteristics, and check the release you pin, since the latest tag is v0.18.0 from 2025-12-12.

## FAQ

### How do I install the dark-mode-toggle web component?

Install it from npm with `npm install --save dark-mode-toggle`, or load it as a module from a CDN with `<script type="module" src="https://unpkg.com/dark-mode-toggle"></script>`. The element is also published on webcomponents.org.

### How do I stop the dark mode toggle from flashing on page load?

The flashing comes from the module loading after the page is rendered, and the fix is a loader script. Wrap the stylesheet links in a `<noscript id="dark-mode-toggle-stylesheets">` element and add `dark-mode-toggle-stylesheets-loader.js` so the saved theme is applied before the page renders.

### How do I react to the dark mode toggle changing?

Listen for the `colorschemechange` event on the element and read `toggle.mode`. The documentation notes the event includes `prefers-color-scheme` changes, so it also fires when the operating system theme changes, not only when the user clicks the switch.

### Which approach should I use for the stylesheets?

Separate files loaded with the `media` attribute give the best loading behaviour, since users do not download CSS for the theme they are not using, and the repository has a demo of each approach. Internal `<style>` tags scoped by colour scheme work but the readme warns they lose the loading optimisation and may slow initial load.

### What licence is the dark-mode-toggle element released under?

Apache-2.0, with the licence file at the repository root. The npm package is version 0.18.0, and the project is maintained in the GoogleChromeLabs GitHub organisation.

## Sources

- [GoogleChromeLabs/dark-mode-toggle on GitHub](https://github.com/GoogleChromeLabs/dark-mode-toggle)
- [License: Apache-2.0](https://github.com/GoogleChromeLabs/dark-mode-toggle/blob/main/LICENSE)
- [Project website](https://googlechromelabs.github.io/dark-mode-toggle/demo/index.html)
- [README](https://github.com/GoogleChromeLabs/dark-mode-toggle/blob/main/README.md)
- [Releases](https://github.com/GoogleChromeLabs/dark-mode-toggle/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/googlechromelabs-dark-mode-toggle
