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

@brustack/theme-transitions-core

v3.2.0

Published

Framework-agnostic core for animated theme transitions using the View Transitions API

Readme

@brustack/theme-transitions-core

made by brustack npm version license

Framework-agnostic core for animated theme transitions using the View Transitions API.

  • ✅ Multiple effects to choose from
  • ✅ Zero flash of the wrong theme on load
  • ✅ Syncs automatically with OS prefers-color-scheme
  • ✅ Custom themes beyond light/dark
  • ✅ Vite plugin included, any other bundler supported via two exported functions
  • ✅ Framework-agnostic, thin adapters for Vue, React, Nuxt, and Next.js

Install

npm install @brustack/theme-transitions-core
# or
pnpm add @brustack/theme-transitions-core
# or
yarn add @brustack/theme-transitions-core

Usage

// main.ts (wherever your app initializes)
import "@brustack/theme-transitions-core/style.css";
import { getController } from "@brustack/theme-transitions-core";

const controller = getController();
const button = document.querySelector<HTMLButtonElement>("#theme-toggle")!;

button.addEventListener("click", () => {
  controller.toggleTheme();
});

controller.subscribe(() => {
  const { theme, isAnimating } = controller.getState();
  button.textContent = theme;
  button.disabled = isAnimating;
});

This wires up the interactive toggle. It doesn't yet prevent a flash of the wrong theme on load, that's what the Vite plugin (or Other bundlers) section below sets up.

Styling

The controller applies the current theme's name (dark, light, or a custom name, see Custom themes below) as a class on <html>. Style your palette off that class with any approach.

CSS variables

:root {
  --bg: #ffffff;
  --text: #111111;
}

html.dark {
  --bg: #0b0b10;
  --text: #f4f2ed;
}

html.sepia {
  --bg: #f4ecd8;
  --text: #4b3621;
}

body {
  background: var(--bg);
  color: var(--text);
}

Tailwind

Set darkMode: 'class' in your Tailwind config (see Install above), then map your color tokens to the CSS variables above:

// tailwind.config.js
module.exports = {
  darkMode: "class",
  theme: {
    extend: {
      colors: {
        bg: "var(--bg)",
        text: "var(--text)",
      },
    },
  },
};

Configuration (optional)

| Variant | duration | easing | direction | | ---------------- | :--------: | :----------: | :---------: | | spread | '1s' | ❌ | ❌ | | fade (default) | '400ms' | 'ease' | ❌ | | wipe | '1s' | 'ease-out' | 'left' | | none | ❌ | ❌ | ❌ |

getController({ variant: "spread", duration: "1s" });

The first call in a process sets the shared default; createController(options) creates an independent instance instead. toggleTheme/setTheme accept a TransitionOptions object (same shape, plus origin, required for spread, derive it with originFromEvent(event) or originFromElement(el)) to override just that one call.

Wipe directions

| Value | Enters from | | --------------------------------------------------------------------- | ------------------------------- | | 'left' / 'right' / 'up' / 'down' | that edge | | 'center-x' / 'center-y' | center, growing along that axis | | 'diagonal-tl' / 'diagonal-tr' / 'diagonal-bl' / 'diagonal-br' | that corner |

Custom themes

Register extra theme names beyond light/dark/system via themes:

getController({ themes: ["sepia", "sunset"] });

setTheme('sepia') then applies a sepia class the same way light/dark do (see Styling above). controller.getState().themes always includes ['light', 'dark', 'system', ...your custom names], useful for building a theme switcher. toggleTheme() is unaffected, it always flips between light and dark.

API

| | | | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | getController(options?) | Returns the shared controller singleton | | createController(options?) | Returns an independent, non-singleton controller | | resetController() | Clears the shared singleton so the next getController() call creates a fresh one. Mainly useful between tests. | | controller.toggleTheme(options?) | Switch between light and dark | | controller.setTheme(mode, options?) | Set light, dark, system, or a custom theme name | | controller.getState() | Returns { theme, mode, isAnimating, themes } | | controller.subscribe(listener) | Runs listener on every state change, returns an unsubscribe function | | originFromEvent(event) | Click position for spread | | originFromElement(el) | Element center for spread |

Advanced

Lower-level exports the six official adapters are themselves built on. Reach for these if you're building a custom framework integration or wrapping the controller yourself.

| | | | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | resolveOptions(eventOrOpts) | Normalizes a MouseEvent or TransitionOptions into TransitionOptions, deriving origin from the event. What every adapter's toggleTheme/setTheme calls under the hood. | | resolveThemeEffects(options?) | Merges variant overrides into a full per-effect option set ({ spread, fade, wipe, none }). | | defaultThemeEffects | The built-in default option set for every effect. | | DEFAULT_VARIANT | The default transition variant ('fade'). | | buildThemeTransitionCss(effects?) | Generates the ::view-transition-* CSS for a given effect set. What the Vite plugin and Nuxt module inject. | | buildConfigInitScript(options) | Generates the script that sets window.__themeConfig, so every getController() call in the app picks up the same default effect options without repeating them. Prepend it to buildColorModeInitScript()'s output for non-Vite bundlers, see Other bundlers below. | | applyThemeClass(value, previous?) | Swaps the theme class on <html>. | | getSystemTheme() | Reads the OS prefers-color-scheme. | | resolveTheme(preference) | Resolves 'system' to 'light'/'dark', passes any other value through unchanged. | | readStoredPreference() / writeStoredPreference(preference) | Read/write the persisted theme preference. | | isValidCssDuration(duration) / parseCssDuration(duration) | Validate/parse a CSS duration string like '400ms'. |

Vite plugin

Register the anti-flash init script in vite.config.ts:

import { defineConfig } from "vite";
import { themeTransitions } from "@brustack/theme-transitions-core/vite";

export default defineConfig({
  plugins: [themeTransitions()],
});

Optionally, pass default effect options so every getController() call in the app picks them up without repeating them:

plugins: [themeTransitions({ variant: 'spread', duration: '1s' })],

Other bundlers

Not using Vite? The plugin above is a thin wrapper around two functions this package already exports, so you can get the same anti-flash behavior with any bundler by calling them directly.

With webpack and html-webpack-plugin:

const {
  buildColorModeInitScript,
} = require("@brustack/theme-transitions-core");

new HtmlWebpackPlugin({
  templateParameters: { themeInitScript: buildColorModeInitScript() },
});
<!-- in the HTML template, inside <head> -->
<script>
  <%= htmlWebpackPlugin.options.templateParameters.themeInitScript %>
</script>

The script must run in <head>, before the page paints, regardless of where your bundle's own <script> tags are injected. To also set app-wide default effect options (the same thing the Vite plugin's argument does), prepend buildConfigInitScript(options) (which sets window.__themeConfig) to the same string.

For zero-build/CDN consumers who can't call buildColorModeInitScript() themselves, this package also ships dist/theme-init.js, a prebuilt, static copy of the same anti-flash init script. Load it directly with a <script src="...theme-init.js"></script> tag in <head>.

Webpack also needs a CSS rule that reaches into node_modules for this package's stylesheet. If your existing .css rule excludes node_modules (common when scoping CSS Modules to your own source), add this package's path to that rule's include:

{
  test: /\.css$/,
  include: [path.resolve(__dirname, 'node_modules/@brustack/theme-transitions-core')],
  use: ['style-loader', 'css-loader'],
}

Notes

  • Server-side code (SSR) must use createController() for a request-scoped instance. getController()'s shared singleton is safe only for client-side usage, where one browser tab is already its own isolated process. A Node server handles many requests in the same process, so sharing the singleton there risks one user's theme leaking into another's response.

Known issues

  • Chrome 150 has a regression where the spread effect's clip-path animation can render from the wrong position after the browser window moves between displays with different DPI/scaling. This is a Chrome bug, not something this package can work around. It's already fixed upstream and verified in Chrome Canary; the fix should reach the Stable channel in a future release. See Chromium issue #535696703.