Skip to content
canvas-watch on GitHub Dark

Floating elements that know where they're at.

Sticky nav bars, docked players, and cards drift over backgrounds that keep changing. canvas-watch works out which zone an element is currently sitting over and puts that answer on the element as a class.

What you do with that class is entirely yours. Re-tint a shadow, flip dark text to light, swap a border, invert the whole panel — the library only ever swaps the class.

See viewport tracking Source on GitHub
shell Copy shell to clipboard
npm install @mzebley/canvas-watch

The card straddles two zones. The one covering most of it wins, and its name lands on the card as a class — which is the entire API. The bar at the top of this page is doing it for real: scroll, and read the class it's wearing.

Why not just IntersectionObserver?

Because it can't answer this question. IntersectionObserver compares a target against its scroll-ancestor or the viewport — never against an arbitrary sibling element.

"Is this floating card mostly over that background zone?" is a 2D overlap question between two unrelated elements. So canvas-watch compares bounding rectangles instead. IntersectionObserver is still in play — as a cheap visibility gate, so the overlap maths only runs for elements actually on screen.

Watch one reference cross the viewport.

Keep scrolling. The beacon starts below you, burns bright while any part of it is in view, then leaves above. The panel stays put so the state change is impossible to miss.

This is the real Svelte API watching #viewport-beacon. Remove the reference to see missing, then restore it and continue the flight.

Live viewport signal

Resolving

Waiting for the first client measurement.

Reference #viewport-beacon

Try it

Scroll inside the frame. The card is watched; the bands behind it are trigger zones. The winning zone is the one covering the largest share of the card — and only if that share clears the threshold, which you can drag.

The card is a sheet of glass whose low-opacity tint follows the winning band. The same class updates tint, ink and border as one measured pairing. None of it is the library's doing: it swapped one class, and the CSS did the rest.

Watched element

The panel samples the winning band's tint and ink.

no class applied

.cw-band-indigo-demozone
.cw-band-butterfield-demozone
.cw-band-merlot-demozone
.cw-band-mint-demozone
.cw-band-ember-demozone
.cw-band-cyan-demozone
Scroll up Scroll down

How much of the card must sit over a band before that band's class applies.

Applied class

— none —

The card is a translucent lens. Its winning over-* class supplies a tinted scrim in the band's hue as well as the ink above it. Every pairing was measured through that composite: body and label ink clear 4.5:1, and the border clears 3:1, on all six.

Scroll the page — not the frame — until the header passes over these bands, and it picks them up too. The bands can't be page triggers directly, though: canvas-watch compares unclipped rectangles, so a band scrolled out of this frame still reports a rect where it would have been, and the header would tint from a band that isn't on screen. The frame itself carries the trigger instead, relabelled as you scroll — the same proxy any zone inside a scroller needs.

Concepts

Watched element

A floating element you tag with watch-bg-canvas (or register through an adapter). It receives an over-* class describing the zone it currently sits over.

Trigger zone

A background element tagged with a *-trigger class. Each maps to an over-* class by convention: strip the -trigger suffix, add the over- prefix.

Trigger classApplied class
canvas-brand-emphasis-triggerover-canvas-brand-emphasis
cw-band-merlot-triggerover-cw-band-merlot

Winner = majority overlap

A watched element gets the class of the zone covering the largest share of its own area. If that share is below the threshold (default 50%), or it overlaps no zone at all, every over-* class is removed. Same-class rectangles count by their physical union, so duplicate pixels are never counted twice.

Nesting wins

A zone inside another zone is the more specific answer, so it beats its container outright — otherwise a container could never be overridden, because its overlap is by definition at least its children's. Depth only reorders zones that already clear the threshold, so a nested zone that barely clips the element can't hijack it, and among zones at the same depth the larger overlap still wins. Coverage stays paired with its depth, so a tiny nested zone cannot promote a same-class ancestor. The heading at the top of this page is a zone of its own inside the hero's: the bar takes dark ink while it is crossing those pale letters, and the hero's light ink everywhere else.

The canvaschange event

Most restyling needs no JavaScript at all — write CSS for the over-* class and you're done. When you need more, each change dispatches a canvaschange CustomEvent with detail: { appliedClass, previousClass } (each string | null): swap an icon set, re-render a chart's palette, announce something. The Svelte adapter surfaces it as onChange.

Install
shell Copy shell to clipboard
npm install @mzebley/canvas-watch

The Svelte adapter ships as a subpath entry point and pulls in an optional peer dependency — install it only if you use it.

ImportWhat you getPeer dependency
@mzebley/canvas-watchFramework-agnostic core (createCanvasWatcher)none
@mzebley/canvas-watch/sveltewatchBgCanvas action and reactive canvasWatch statesvelte >= 5.7
Installing the prerelease from GitHub Packages
Usage

The shape is the same everywhere: mark the background zones, register the floating element, and write CSS for the over-* classes.

Page.svelte Copy Page.svelte to clipboard
<script>
	import { watchBgCanvas } from '@mzebley/canvas-watch/svelte';
</script>

<!-- A background zone -->
<section class="canvas-brand-emphasis-trigger">…</section>

<!-- A floating element that reacts to it -->
<div class="card" use:watchBgCanvas>…</div>
styles.css Copy styles.css to clipboard
.card {
	background: rgb(255 255 255);
	color: rgb(15 15 20);
	--shadow-color: rgb(0 0 0);
	box-shadow: 0 10px 30px -8px color-mix(in srgb, var(--shadow-color) 40%, transparent);
	transition: background 400ms ease, color 400ms ease, box-shadow 400ms ease;
}

/* canvas-brand-emphasis-trigger → over-canvas-brand-emphasis */
.card.over-canvas-brand-emphasis {
	--shadow-color: rgb(79 70 229);
}

/* Nothing says it has to be a shadow. Over a pale zone, invert the
   whole card so it still reads. Check the contrast of what you flip. */
.card.over-canvas-paper {
	background: rgb(31 27 92);
	color: rgb(226 224 250);
	--shadow-color: rgb(31 27 92);
}
Svelte Angular Vanilla JS

Svelte

The action shares one watcher across the whole app and cleans up on destroy. class="watch-bg-canvas" is optional when you use the action — it registers the node directly — but keeping it makes the intent obvious in markup.

Card.svelte Copy Card.svelte to clipboard
<script>
	import { watchBgCanvas } from '@mzebley/canvas-watch/svelte';

	let tint = $state(null);
</script>

<div
	class="card"
	use:watchBgCanvas={{ onChange: (detail) => (tint = detail.appliedClass) }}
>
	Currently over: {tint ?? 'nothing'}
</div>

Viewport state

canvasWatch('#hero') exposes reactive aboveViewport, inViewport, belowViewport and missing properties. The full state starts as unknown; a missing ID becomes missing only after client resolution. Read these properties in a template or tracked effect; imperative callers should use observeViewport.

Header.svelte Copy Header.svelte to clipboard
<script>
	import { canvasWatch } from '@mzebley/canvas-watch/svelte';
	const hero = canvasWatch('#hero');
</script>

<header class:past-hero={hero.aboveViewport}>
	{hero.missing ? 'Hero not found' : hero.state}
</header>
<section id="hero">…</section>

Elements that outlive a page — a layout-level nav or player — are registered once and won't automatically see a new page's trigger zones. Re-scan after navigation:

+layout.svelte Copy +layout.svelte to clipboard
<script>
	import { afterNavigate } from '$app/navigation';
	import { tick } from 'svelte';
	import { refreshCanvasWatch } from '@mzebley/canvas-watch/svelte';

	afterNavigate(async () => {
		await tick(); // let the new page's DOM render first
		refreshCanvasWatch();
	});
</script>
API

createCanvasWatcher(options?)

Returns a live watcher in the browser, or a no-op during SSR — so you never need to guard typeof window. Invalid thresholds, margins, selectors and class mappings fail before any browser resources are allocated.

OptionDefaultDescription
watchSelector.watch-bg-canvasSelector for elements to watch.
triggerSelector[class*="-trigger"]Coarse selector for trigger elements; refined in JS by triggerSuffix.
triggerSuffix-triggerSuffix that marks a class as a trigger.
appliedPrefixover-Prefix for the applied class.
classMap{}Explicit triggerClass → appliedClass overrides; these win over the convention.
threshold0.5Fraction of the watched element's own area that must overlap to count.
triggerRootMargin200Margin in px around the viewport for keeping a trigger zone "active".
main.ts Copy main.ts to clipboard
createCanvasWatcher({
	classMap: { 'hero-trigger': 'on-hero' }, // hero-trigger → on-hero
	threshold: 0.6,
});

CanvasWatcher

MethodDescription
refresh()Re-scan watch, trigger and declarative viewport elements.
watch(el)Register one overlap owner. Returns an idempotent unwatch() function.
observeViewport(reference, listener)Subscribe one owner to vertical viewport state. Returns an idempotent unsubscribe function.
schedule()Force a recompute on the next frame.
destroy()Terminally cancel work, disconnect observers and remove owned classes.

Every registration owns an independent disposer, even when an element or callback is repeated. destroy() is terminal. Consumer listener errors are reported without blocking healthy listeners or cleanup.

The core also exports the pure helpers overlapArea(a, b), resolveAppliedClass(triggerClass, opts) and pickWinningClass(totals, area, threshold) — the winner rule itself, depth and all — plus classifyViewportRect(rect, viewportTop, viewportBottom) and the shared-singleton helpers getSharedWatcher() and refreshCanvasWatch() that the Svelte adapter is built on.

How it works
  1. Visibility gate. Each watched element is tracked by an IntersectionObserver, so only on-screen elements are ever measured. A second observer tracks trigger zones with a root margin, so only zones near the viewport are considered.
  2. One rAF loop. Scroll, resize, DOM mutation, ResizeObserver and observer callbacks all funnel into one coalesced requestAnimationFrame. Overlap and viewport registrations share that loop.
  3. Read, commit, then notify. Per frame, every unique element rect is read once. Classes, ownership and observer targets are committed before consumer callbacks, so reentrant teardown cannot resurrect stale work.
  4. Minimal DOM churn. A class is added or removed only when the winning zone actually changes, so scrolling within one zone touches the DOM zero times.
  5. Idle pages cost nothing. With no visible overlap work and no viewport registrations, scroll frames short-circuit before any work is scheduled.
Gotchas

Overriding a composed token

If the property you're changing is a variable that itself references another one — a shadow composite, a gradient, a border shorthand — declared on :root, then overriding the inner variable from an over-* class does nothing. CSS bakes the nested var() once, where the composite is declared, and the result inherits down. Re-declare the composite on the watched element so it re-bakes against that element's value.

position: sticky and overflow

A sticky watched element won't stick if an ancestor has overflow: hidden, auto, or scroll. Put clipping on a sibling layer, not an ancestor of the sticky element.

Stacking is not considered

Overlap ignores z-index. At one nesting depth the larger physical coverage wins, which may not be the element actually painted on top.

Neither is clipping

getBoundingClientRect reports where an element would be, not what's visible. A trigger zone inside a scrolling container still reports a full-size rect once it has scrolled out of view, so a watched element can pick up a zone that is no longer on screen. When your zones live in a scroller, put the trigger class on the scroller itself — its rect is the visible box — and relabel it as its content scrolls. The playground on this page does exactly that.

One element gets one answer

Majority overlap resolves per watched element, so a wide element spanning two zones takes a single class — and half of it ends up styled for the wrong background. A bar across the full content width hits this constantly. The fix is not in the library: watch the regions instead of the container. Wrap each cluster of content in its own watched element and each resolves its own zone, in the same frame, from the same watcher. The header on this page does exactly that — its left and right ends carry different classes whenever they sit over different zones. Nesting is safe because a watched element only ever resolves against elements carrying a trigger class, so the container it sits inside is invisible to it.

Dynamic triggers need refresh()

Trigger zones are indexed on refresh() — at adapter mount, or via refreshCanvasWatch() — not continuously observed for class changes.

Unobservable motion needs schedule()

Scroll, resize, relevant DOM mutations, target resizing and viewport-boundary crossings invalidate geometry. Call schedule() after motion that changes geometry without producing one of those signals.

The composed-token trap Copy The composed-token trap to clipboard
/* ✗ Does nothing. The nested var() is baked once, on :root. */
:root {
	--shadow-color: 0deg 0% 0%;
	--shadow-elevation: 0 10px 30px -8px hsl(var(--shadow-color) / 0.4);
}
.card { box-shadow: var(--shadow-elevation); }
.card.over-canvas-brand-emphasis { --shadow-color: 243deg 55% 36%; }

/* ✓ Re-declare the composite on the watched element so it re-bakes. */
.card {
	--shadow-color: 0deg 0% 0%;
	--shadow-elevation: 0 10px 30px -8px hsl(var(--shadow-color) / 0.4);
	box-shadow: var(--shadow-elevation);
}
Accessibility

canvas-watch is purely presentational. It adds and removes one class. It adds no ARIA, announces nothing to assistive technology, and never alters content, focus order, or layout.

Contrast is your responsibility, and it matters more the more you restyle. A shadow that misses 3:1 is cosmetic; text that lands at 2:1 because the panel behind it changed is a WCAG 1.4.3 failure. If your over-* rules touch text, border, or icon colour, check every zone — the library only tells you which one you're over, it has no idea whether the result is legible.

Reduced motion is the consumer's call. Put any transition on the properties you change behind @media (prefers-reduced-motion: reduce).

Under the hood it needs IntersectionObserver, ResizeObserver, MutationObserver, requestAnimationFrame and CustomEvent — all available in every current evergreen browser. It is SSR-safe: createCanvasWatcher returns a no-op when window and document are absent, and the Svelte adapter only runs on the client.

One class. That's the API.

Mark your zones, register your floating element, and write the CSS you were going to write anyway.

Read the source Install it

canvas-watch — ISC licensed, by Mark Zebley

GitHub npm dynamowaves