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.
npm install @mzebley/canvas-watchThe 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.
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-demozoneHow 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.
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 class | Applied class |
|---|---|
canvas-brand-emphasis-trigger | over-canvas-brand-emphasis |
cw-band-merlot-trigger | over-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.
npm install @mzebley/canvas-watchThe Svelte adapter ships as a subpath entry point and pulls in an optional peer dependency — install it only if you use it.
| Import | What you get | Peer dependency |
|---|---|---|
@mzebley/canvas-watch | Framework-agnostic core (createCanvasWatcher) | none |
@mzebley/canvas-watch/svelte | watchBgCanvas action and reactive canvasWatch state | svelte >= 5.7 |
While the package is distributed privately through GitHub Packages, point the @mzebley scope at that registry. Locally, authenticate once with a classic
personal access token carrying only read:packages:
# One-time login with a classic token that has read:packages
npm login --scope=@mzebley --auth-type=legacy --registry=https://npm.pkg.github.com
npm install @mzebley/canvas-watchFor CI or hosted builds, keep the token in a secret and reference it from .npmrc rather than committing the value:
registry=https://registry.npmjs.org
@mzebley:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_PACKAGES_TOKEN}The shape is the same everywhere: mark the background zones, register the floating
element, and write CSS for the over-* classes.
<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>.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
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.
<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.
<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:
<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>Angular
There is no Angular entry point to install — the core is enough. A standalone directive
wraps it in about thirty lines, and because it registers on getSharedWatcher(), it joins the same single rAF loop as every other
watched element on the page. Own this file in your app:
import {
Directive,
ElementRef,
EventEmitter,
inject,
NgZone,
Output,
type OnDestroy,
type OnInit,
} from '@angular/core';
import {
getSharedWatcher,
scheduleRefresh,
type CanvasChangeDetail,
} from '@mzebley/canvas-watch';
@Directive({
selector: '[canvasWatch]',
standalone: true,
})
export class CanvasWatchDirective implements OnInit, OnDestroy {
@Output() canvasChange = new EventEmitter<CanvasChangeDetail>();
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly zone = inject(NgZone);
private unwatch?: () => void;
private readonly listener = (event: Event): void => {
const detail = (event as CustomEvent<CanvasChangeDetail>).detail;
// Re-enter the zone only when a change actually fires (rare), so change
// detection runs for consumers without paying for it on every scroll.
this.zone.run(() => this.canvasChange.emit(detail));
};
ngOnInit(): void {
const node = this.host.nativeElement;
// Register outside the zone: the shared watcher is created lazily on first
// use, and its scroll listener + rAF loop must not be zone-patched —
// otherwise every scroll event app-wide triggers change detection.
this.zone.runOutsideAngular(() => {
this.unwatch = getSharedWatcher().watch(node);
// Pick up trigger zones already in the DOM (coalesced across mounts).
scheduleRefresh();
node.addEventListener('canvaschange', this.listener);
});
}
ngOnDestroy(): void {
this.host.nativeElement.removeEventListener('canvaschange', this.listener);
this.unwatch?.();
}
}The runOutsideAngular wrapper is the part worth keeping. The watcher's
scroll listener and rAF loop would otherwise be zone-patched, and every scroll frame
app-wide would trigger change detection. Re-entering the zone only when a class actually
changes keeps that cost off the scroll path.
import { Component } from '@angular/core';
import type { CanvasChangeDetail } from '@mzebley/canvas-watch';
import { CanvasWatchDirective } from './canvas-watch.directive';
@Component({
standalone: true,
imports: [CanvasWatchDirective],
template: `
<section class="canvas-brand-emphasis-trigger">…</section>
<div class="card" canvasWatch (canvasChange)="onTint($event)">…</div>
`,
})
export class DemoComponent {
onTint(detail: CanvasChangeDetail) {
// detail.appliedClass / detail.previousClass
}
}Elements that outlive a route — a layout-level nav or player — are registered once and won't automatically see the next page's trigger zones. Re-scan after navigation:
import { inject } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { filter } from 'rxjs/operators';
import { refreshCanvasWatch } from '@mzebley/canvas-watch';
export class AppComponent {
constructor() {
inject(Router)
.events.pipe(filter((e) => e instanceof NavigationEnd))
.subscribe(() => refreshCanvasWatch());
}
}Vanilla, or any framework
The core has no framework dependencies. Tag the elements in markup, then create a watcher and refresh it.
<section class="canvas-brand-emphasis-trigger">…</section>
<div class="card watch-bg-canvas">…</div>import { createCanvasWatcher } from '@mzebley/canvas-watch';
const watcher = createCanvasWatcher();
// Scan the DOM for .watch-bg-canvas elements and *-trigger zones.
watcher.refresh();
// …after adding or removing watched/trigger elements:
watcher.refresh();
// …on teardown:
watcher.destroy();Viewport state
Subscribe in JavaScript, or let HTML own one default state class. ID resolution,
late insertion, removal and relevant attribute changes share one coalesced MutationObserver per watcher. Unrelated classes are untouched.
const stop = watcher.observeViewport('#hero', (detail) => {
console.log(detail.state); // missing | above | within | below
});
// Later
stop();<header class="site-header" data-canvas-watch-viewport="#hero"></header>
<section id="hero"></section>Listen for changes directly on the element:
el.addEventListener('canvaschange', (e) => {
console.log(e.detail.appliedClass, e.detail.previousClass);
});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.
| Option | Default | Description |
|---|---|---|
watchSelector | .watch-bg-canvas | Selector for elements to watch. |
triggerSelector | [class*="-trigger"] | Coarse selector for trigger elements; refined in JS by triggerSuffix. |
triggerSuffix | -trigger | Suffix that marks a class as a trigger. |
appliedPrefix | over- | Prefix for the applied class. |
classMap | {} | Explicit triggerClass → appliedClass overrides; these win over the convention. |
threshold | 0.5 | Fraction of the watched element's own area that must overlap to count. |
triggerRootMargin | 200 | Margin in px around the viewport for keeping a trigger zone "active". |
createCanvasWatcher({
classMap: { 'hero-trigger': 'on-hero' }, // hero-trigger → on-hero
threshold: 0.6,
});CanvasWatcher
| Method | Description |
|---|---|
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.
- 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.
- One rAF loop. Scroll, resize, DOM mutation, ResizeObserver and observer callbacks all funnel into one coalesced requestAnimationFrame. Overlap and viewport registrations share that loop.
- 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.
- 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.
- Idle pages cost nothing. With no visible overlap work and no viewport registrations, scroll frames short-circuit before any work is scheduled.
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.
/* ✗ 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);
}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.