Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/readme-structure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@variablelab/theme-toggle": patch
---

Restructure the README, with the name linked to the Project page and the development notes moved to the repository's development guide, and name `rcrd <ricardo@variable.land>` in the LICENSE.
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2026 Ricardo Q. Bazan <raquirozbazan@gmail.com>
Copyright (c) 2026 rcrd <ricardo@variable.land>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
76 changes: 31 additions & 45 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,34 +1,33 @@
<p align="center">
<img src="https://raw.githubusercontent.com/withvariablelab/theme-toggle/HEAD/brand/sign-readme.svg" width="64" height="64" alt="">
<img src="https://raw.githubusercontent.com/withvariablelab/theme-toggle/HEAD/brand/sign-readme.svg" width="64" alt="">
</p>

<h1 align="center">theme-toggle</h1>
<h1 align="center"><a href="https://lab.variable.land/theme-toggle">theme-toggle</a></h1>

<p align="center">The theme is set before first paint.</p>

Light/dark/system color-mode toggle for React apps, with a tiny inline script that
sets the theme **before first paint** to avoid the flash of the wrong theme (FOUC).
Light, dark and system color modes for React apps. A blocking script in `<head>` puts the `dark` class on `<html>` from the stored mode, and the `useColorMode` hook reads and changes it.

- Three modes: `light`, `dark`, and `system` (defaults to `system`).
- `system` resolves against the OS `prefers-color-scheme` and tracks it live.
- Persists the user's choice in `localStorage`.
- Applies a `dark` class on `<html>` so you can style with plain CSS.
- Emits a `CustomEvent` on every change so non-React code can react too.
- `system` is the default. It resolves against the OS `prefers-color-scheme` and follows it live.
- The choice is stored in `localStorage`.
- You style the `dark` class with plain CSS.
- Every change dispatches a `CustomEvent`, so code outside React can follow it too.

## Installation
## Install

```sh
pnpm add @variablelab/theme-toggle
```

It needs React 18 or later.

## Usage

The package ships **two pieces** that work together.
theme-toggle ships two pieces that work together.

### 1. Inline script (prevents the theme flash)
### 1. The inline script

Load the prebuilt IIFE **before your app renders** — ideally as the first thing in
`<head>`, blocking, so the correct `dark` class is on `<html>` before the page paints.
Load the prebuilt script as the first thing in `<head>`, blocking, so the `dark` class is on `<html>` before the page paints.

```html
<head>
Expand All @@ -37,11 +36,9 @@ Load the prebuilt IIFE **before your app renders** — ideally as the first thin
</head>
```

On load it reads the stored mode (or `system` by default), toggles the `dark` class
on `<html>` — resolving `system` against the OS preference — persists the mode, and
exposes its runtime contract on `window.__COLOR_MODE__`.
On load it reads the stored mode, or `system` when there is none, and toggles the `dark` class on `<html>`, resolving `system` against the OS preference. Then it stores the mode and exposes its runtime contract on `window.__COLOR_MODE__`.

### 2. React hook (read & control the mode)
### 2. The React hook

```tsx
import { useColorMode } from "@variablelab/theme-toggle";
Expand All @@ -60,13 +57,11 @@ function ThemeSwitch() {
}
```

Calling `setColorMode` persists the choice to `localStorage`, toggles the `dark` class
on `<html>`, and dispatches the change event. While `colorMode` is `system`, the hook
also follows the OS preference live as it changes.
Calling `setColorMode` stores the choice in `localStorage`, toggles the `dark` class on `<html>` and dispatches the change event. While `colorMode` is `system`, the hook also follows the OS preference as it changes.

## Styling

Style the two themes with the `dark` class that gets toggled on `<html>`:
Style the two themes with the `dark` class on `<html>`:

```css
:root {
Expand All @@ -93,42 +88,33 @@ body {
| Storage key | `@variablelab/theme-toggle.colorMode` |
| Change event | `@variablelab/theme-toggle.colorModeChange` |

The change event is a `CustomEvent` dispatched on `document`, with the new value in
`event.detail.colorMode`:
The change event is a `CustomEvent` dispatched on `document`, with the new value in `event.detail.colorMode`:

```ts
document.addEventListener("@variablelab/theme-toggle.colorModeChange", (event) => {
console.log(event.detail.colorMode); // "light" | "dark" | "system"
});
```

The inline script also publishes these names on `window.__COLOR_MODE__`
(`STORAGE_KEY`, `CUSTOM_EVENT`, `COLOR_MODE`) so other scripts can read them without
importing the package.

## Upgrading from `@vlandoss/theme-toggle` 1.x
The inline script also publishes these names on `window.__COLOR_MODE__` (`STORAGE_KEY`, `CUSTOM_EVENT`, `COLOR_MODE`), so other scripts can read them without importing the package.

The package was renamed to `@variablelab/theme-toggle` in 2.0.0:
## Upgrading from 1.x

1. Replace the dependency and imports: `pnpm remove @vlandoss/theme-toggle && pnpm add @variablelab/theme-toggle`,
and update the inline script URL to `https://unpkg.com/@variablelab/theme-toggle/dist/initial.js`.
2. Listeners of the change event must use the new name, `@variablelab/theme-toggle.colorModeChange`
(or read it from `window.__COLOR_MODE__.CUSTOM_EVENT`).
The package was renamed from `@vlandoss/theme-toggle` to `@variablelab/theme-toggle` in 2.0.0:

Saved preferences carry over automatically. On init, if the old `@vlandoss/theme-toggle.colorMode`
key exists, its value is copied to the new storage key, unless the new key already holds a value
(that value wins). The old key is then removed either way.
1. Replace the dependency and imports with `pnpm remove @vlandoss/theme-toggle && pnpm add @variablelab/theme-toggle`, and change the inline script URL to `https://unpkg.com/@variablelab/theme-toggle/dist/initial.js`.
2. Listeners of the change event must use the new name, `@variablelab/theme-toggle.colorModeChange`, or read it from `window.__COLOR_MODE__.CUSTOM_EVENT`.

## Development
Saved preferences carry over. On init, if the old `@vlandoss/theme-toggle.colorMode` key exists, its value is copied to the new key, unless the new key already holds a value: then that value wins. The old key is removed either way.

```sh
pnpm install
pnpm test # functional tests run in a real browser via Vitest Browser Mode + Playwright
```
## Contributing

The suite in `test/functional` covers both entry points: the `useColorMode` hook
(`src/index.ts`) and the inline script (`src/initial.ts`).
The [contributing guide](https://github.com/withvariablelab/theme-toggle/blob/main/docs/contributing.md) covers issues, pull requests and releases, and the [development guide](https://github.com/withvariablelab/theme-toggle/blob/main/docs/development.md) covers setting up the repo and running its checks.

## License

MIT
The code is [MIT](https://github.com/withvariablelab/theme-toggle/blob/main/LICENSE). The Sign in `brand/` is not: its use is set by [BRAND-USE.md](https://github.com/withvariablelab/brand/blob/main/BRAND-USE.md).

<p align="center">
<a href="https://lab.variable.land"><img src="https://cdn.jsdelivr.net/gh/withvariablelab/brand@v0.3.1/.github/readme/logo.svg" width="24" alt="" align="absmiddle">&nbsp;Variable Land</a>
</p>
25 changes: 25 additions & 0 deletions docs/contributing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Contributing

theme-toggle takes bug reports, fixes and small features. To set up the repo and run its checks, see the [development guide](development.md).

## Issues

- A bug report says what you expected, what happened, and which browser, React version and theme-toggle version you use.
- A new feature, or a change to the hook or the inline script, starts as an issue before any pull request.

## Pull requests

1. Branch from `main` with a prefix: `feat/`, `fix/`, `docs/`, `chore/` or `test/`.
2. Write commits as [Conventional Commits](https://www.conventionalcommits.org), such as `docs: fix the inline script URL`. The pull request title takes the same form, because it becomes the squash commit.
3. Run `rr check` and `pnpm test` before you push.
4. A pull request from a `feat/` or `fix/` branch, or with the `preview` label, publishes a preview to npm under the `pr-<number>` tag: `pnpm add @variablelab/theme-toggle@pr-123`.

CI has to pass, and a maintainer squash-merges.

## Changesets

A change to what ships to npm, the README and the LICENSE included, needs a changeset. Run `pnpm changeset`, pick the bump (`patch` for a fix, `minor` for a feature, `major` for a breaking change), and write one sentence for the changelog. Changes to tests, CI or `docs/` need none. Nobody edits versions or `CHANGELOG.md` by hand.

## Releases

When changesets land on `main`, `vland-bot` opens or updates a "chore: update versions" pull request. A maintainer merges it, and that publishes the new version to npm.
33 changes: 33 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Development

How to work on theme-toggle in a clone. The [contributing guide](contributing.md) covers how a change gets in.

## Setup

[mise](https://mise.jdx.dev) pins Node 24 and pnpm in `mise.toml`, with checksums in `mise.lock`, and puts `node_modules/.bin` on the `PATH`, so the tools run by name:

```sh
mise install
pnpm install
playwright install chromium # the browser the tests run in
lefthook install # the git hooks in lefthook.yml
```

pnpm doesn't run lefthook's own install script, so the hooks need `lefthook install` once. After changing a version in `mise.toml`, run `mise lock` and commit both files.

## Tasks

```sh
pnpm test # the functional tests, in a real browser
rr check # Biome's format and lint, then the type check
rr jsc --fix # apply Biome's fixes
rr pack # build dist/ with tsdown, as prepublishOnly does
```

The suite in `test/functional` runs in Vitest Browser Mode with Playwright. It covers both entry points: the `useColorMode` hook (`src/index.ts`) and the inline script (`src/initial.ts`).

## CI

- **On each pull request to `main`:** `pnpm rr check`, `pnpm test` and the build with `pnpm rr pack` (CI), and zizmor on the workflows (Workflow audit).
- **Git hooks** ([lefthook](https://lefthook.dev)): on commit, Biome's fixes on the staged files and the type check; on push, the tests.
- **Every Monday:** `pnpm audit` on the lockfile, which keeps an issue labeled `dependency-audit` open while it finds known vulnerabilities (Dependency audit).
Loading