Kanzo UI
Hooks

useThemeTick

Re-render when the theme changes, so a surface painted from resolved tokens repaints instead of freezing the theme it mounted with.

tick 0

Eight bars on a <canvas>, painted with resolveTokenColor(host, "--chart-N"). Flip the appearance toggle: nothing about that changes a prop, and without this hook the canvas would keep the theme it was born with.

Usage

import { resolveTokenColor, useThemeTick } from "@kanzo-tech/ui";

Both are on the root barrel and neither needs an optional peer: a token resolver is not an engine.

const tick = useThemeTick();

useEffect(() => {
  const ctx = canvas.current?.getContext("2d");
  if (!ctx || !host.current) return;
  ctx.fillStyle = resolveTokenColor(host.current, "--chart-1");
  ctx.fillRect(0, 0, 40, 40);
}, [tick]);

The returned number is meaningless on its own — it is a dependency, and the only correct use of it is in a dependency list.

Why a literal goes stale

CSS follows the theme for free: a style={{ color: "var(--chart-1)" }} re-resolves the moment the cascade changes, with no React involvement at all. Take that route whenever your sink is CSS.

A canvas is not CSS. fillStyle cannot take var(--chart-1), and neither can Observable Plot, whose isColor reads an unresolved token as a column name and kills the query. So both have to be handed a literal — and a literal is a snapshot of whatever theme was live when it was read.

Things that move the theme, none of which re-render anything on their own:

  • KanzoThemeProvider writes its axes as data-* attributes on <html>data-theme included
  • next-themes toggles .dark on the same element
  • a stylesheet lands late, which moves every token with the root untouched

The first two are attribute writes on document.documentElement, and a theme change is now one of them: it is data-theme, not a swapped document. That is a simplification, and this hook kept the <head> observer anyway — a host that loads the catalogue asynchronously, or code-splits a theme file, changes what every token resolves to without touching an attribute, and an observer on the root cannot see that. The third case is rarer than the swap it replaced; it is not gone.

Do not hand-roll the observer. The local copy this replaced filtered attributes down to class, style and data-theme — so every theme axis added after it was written stopped repainting the surface. A filter is a list of the axes that existed the day someone typed it; observing the element is not.

Who already does this for you

ChartRoot does — via TokenizedPlot, which re-resolves every mark colour and rebuilds the plot on each tick. You need this hook for surfaces the library does not paint: a ChartRaw mark you resolve yourself, a WebGL graph, a map, any imperative widget.

Note the pairing with useChartContext: color(key) from the chart context is already resolved, and therefore also a snapshot. Where it lands in CSS, pass the token through instead; where it lands on a canvas, pair it with this.

API Reference

function useThemeTick(): number;

Takes no arguments.

Returns

number — a counter incremented on every attribute mutation of document.documentElement, on every <head> mutation, and whenever the provider's theme preference moves. Starts at 0, including on the server and on the first client render, so it never causes a hydration mismatch. Feed it to an effect's dependency list; do not render it.

A <head> mutation is also how a router inserting route CSS looks, so the counter ticks a little more often than colour strictly changes. That is the trade, and it is the correct way round: a false tick costs one re-resolve, a missed one leaves a canvas painting a brand nobody selected.

The observers are installed once on mount and disconnected on unmount. Where MutationObserver is undefined the hook stays at 0 rather than throwing, and the provider half is optional, so a surface still works without one.

ExportWhat it is
resolveTokenColor(host, token)reads a token off the live cascade and normalises it to a Plot-safe rgb(...)
useChartCapacitythe same subscription, applied to --chart-capacity

On this page