npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@codefusion-cc/theme

v0.1.1

Published

Light, dark or the system's theme, kept per browser and shown before first paint: the page never flashes the wrong one, follows the system while open, every open tab follows a change, and a switch can grow the new theme as a circle from its button

Downloads

1,113

Readme

@codefusion-cc/theme

Light, dark or the system's theme, kept per browser and shown before first paint. The page never flashes the wrong theme, follows the system while it is open, and every open tab follows a change made in another. Other looks a person picks (a palette, an accent) can be kept and shown next to the mode.

npm install @codefusion-cc/theme

The config

One plain object, shared by the page and the Vite config, so the pre-paint script and the page can never disagree:

// src/themeConfig.ts
import { defineTheme } from '@codefusion-cc/theme'

export const themeConfig = defineTheme({
  storageKey: 'myapp-theme',
  // Tailwind's dark class…
  html: { class: 'dark' },
  // …or daisyUI's data-theme, a value for each:
  // html: { attribute: 'data-theme', light: 'myapp-light', dark: 'myapp-dark' },
  themeColor: '--color-base-100',
})
  • storageKey: where the mode is kept in localStorage. A stored light or dark counts; anything else (nothing, the system mode, a value from an older version, junk) is the system mode. The system mode and every default are kept as nothing, so a later default reaches everyone who never picked.

  • html: how <html> shows the theme. color-scheme is always set too, so the browser's own controls and scrollbars match.

  • themeColor: a CSS custom property holding the page's background. Each <meta name="theme-color"> takes its value once the page has loaded and after every change, so an installed app's title bar and a phone's browser bar match the theme picked. Its media attribute is dropped: a meta for the system's other mode would win over the pick.

  • choices: other looks, each under its own key, with the values it can be and a default. A choice can show as its own data attribute (attribute: 'data-accent', absent while null), and html's values can name one as {name}:

    html: { attribute: 'data-theme', light: '{palette}', dark: '{palette}-dark' },
    choices: {
      palette: { storageKey: 'myapp-palette', values: ['graphite', 'ember'], default: 'graphite' },
      accent: { storageKey: 'myapp-accent', values: ['lime', 'sky'], default: null, attribute: 'data-accent' },
    },

defineTheme throws a TypeError for a mistake in the config (no key, two things under one key, a default that is not one of the values, a {name} that is no choice).

Before first paint

prePaintTheme from ./vite puts a small classic script first in each page's <head>. It reads the stored theme and shows it on <html> before anything paints, and it never throws, whatever the storage does:

// vite.config.ts
import { prePaintTheme } from '@codefusion-cc/theme/vite'
import { themeConfig } from './src/themeConfig.ts'

export default defineConfig({ plugins: [react(), prePaintTheme(themeConfig)] })

It is served as /theme.js (fileName changes it), which a CSP with script-src 'self' allows. inline: true writes it into the page instead (a CSP then needs its hash). Without Vite, themeScript(config) from . is its source.

In the page

Once, at startup, in a module the rest of the app imports:

// src/theme.ts
import { createTheme } from '@codefusion-cc/theme/browser'
import { themeConfig } from './themeConfig.ts'

export const theme = createTheme(themeConfig)

It shows the same stored theme as the script, and from then on:

  • follows the system while the mode is system, as it changes (also on Safari before 14);
  • follows a pick made in another tab of the app;
  • keeps a pick in localStorage, or for this page only when storage is blocked, missing or full;
  • switches every colour at once: transitions are off for the frame the theme changes in (through the CSSOM, which a CSP's style-src doesn't block), so buttons don't fade through the old theme's colours;
  • sets the theme-color meta.

theme.get() is what the page shows: { mode, resolved, systemDark, choices }. Picks: theme.setMode('dark'), theme.toggle() (a single light/dark button: it picks the other theme, or system when that is what the system shows, so one click doesn't hold the page to one mode for good), theme.setChoice('accent', 'sky') (null for the default). An unknown mode or choice value throws a TypeError: a programming error.

In React

import { useTheme } from '@codefusion-cc/theme/react'
import { theme } from './theme.ts'

function ThemeButton() {
  const { resolved } = useTheme(theme)
  return <button onClick={theme.toggle}>{resolved === 'dark' ? 'Light' : 'Dark'}</button>
}

Every component renders again on each change, from any source. No provider is needed.

The animated switch

revealThemeChange (./browser) runs a change inside a View Transition: the new theme grows as a circle from the button over the old one. Browsers without View Transitions, visitors who asked for reduced motion, and a browser that refuses to start a transition get the change at once.

Once, in the app's CSS (or import '@codefusion-cc/theme/reveal.css' in a module):

@import '@codefusion-cc/theme/reveal.css';
import { revealThemeChange } from '@codefusion-cc/theme/browser'
import { flushSync } from 'react-dom'
import { theme } from './theme.ts'

<button
  onClick={(event) =>
    revealThemeChange(() => flushSync(() => theme.toggle()), {
      // A keyboard click has no pointer position: start from the button then.
      origin: event.detail ? { x: event.clientX, y: event.clientY } : event.currentTarget,
    })
  }
/>
  • The change must be on the page when the callback returns: the browser snapshots the new page right after it. theme.toggle() and theme.setMode() paint <html> at once; flushSync also renders the components that read useTheme (the button's icon and label) into that snapshot.
  • origin is a point or an element (the circle starts from its centre); without one, the middle of the viewport. duration (650 ms) and easing are options.
  • The promise resolves when the new theme is on the page for good and rejects only with an error the callback threw.
  • Several clicks in a row are fine: the browser ends the running transition and starts the next one.

While the circle grows, the page is one snapshot: elements the app names for its own view transitions (view-transition-name in a stylesheet or inline, React's <ViewTransition>) are flattened into it, so a fixed header or a shared photo does not fade or jump on its own. The reveal finds them by their computed style and takes their names off inline (a few milliseconds for thousands of elements), then puts each back as it was. A rule matching every element would cost far more: WebKit restyles the whole page when such a rule starts and again when it stops applying, each time as long as the theme change itself. The class cf-theme-reveal (REVEAL_CLASS) is on <html> for the duration; reveal.css keys its rules (on the transition's pseudo-elements only) on it. Names are taken off twice, before the old snapshot and right after the callback; a name that appears later (in an effect, a frame later) is not flattened.

In an app's tests

@codefusion-cc/theme/testing is a browser for tests in Node, without a DOM: tabs of one site sharing its storage, a system that switches between light and dark, and storage that can be blocked, missing or full. Each tab's <html> keeps what was shown on it:

import { createTheme } from '@codefusion-cc/theme/browser'
import { testBrowser } from '@codefusion-cc/theme/testing'

const browser = testBrowser({ systemDark: true, stored: { 'myapp-theme': 'light' } })
const [first, second] = [browser.openTab(), browser.openTab()]
const mine = createTheme(themeConfig, first.env)
const theirs = createTheme(themeConfig, second.env)
mine.setMode('system')
expect(second.html.classes).toEqual(['dark'])

tab.runScript(themeScript(config)) runs the pre-paint script in a tab, tab.frame() runs its animation frames, and browser.writeElsewhere(key, value) is a change made by another program.