What am I over?
Compare a floating element with background zones. Get one class for the winning surface.
Where did it go?
Track whether a reference is above, within, below, or missing from the viewport.
Get it running
Installation
canvas-watch compares element geometry and gives your interface a class or a state to work with. Your CSS and application decide what happens next.
npm install @mzebley/canvas-watchAny framework
@mzebley/canvas-watchThe core has no framework dependencies.
Svelte 5.7+
@mzebley/canvas-watch/svelteAn action for overlap. Reactive getters for viewport state.
Both entries are safe to import during SSR. Measurements begin in the browser.
The small version
A zone, an element, a class.
Give a background a -trigger class. Register the element that should respond to it. When enough of that element overlaps the zone, it receives the matching over- class.
ink-triggerover-inkon the watched element<section class="ink-trigger">A dark background</section>
<nav class="watch-bg-canvas">A floating navigation</nav>import { createCanvasWatcher } from '@mzebley/canvas-watch';
const watcher = createCanvasWatcher();
watcher.refresh();
// When this part of your app unmounts:
// watcher.destroy();.ink-trigger {
min-block-size: 100vh;
background: #33205e;
color: #f4f0fa;
}
.watch-bg-canvas {
position: fixed;
inset: 1rem 1rem auto;
padding: 1rem;
color: #4b2a87;
}
.watch-bg-canvas.over-ink { color: #f4f0fa; }
/* You own the style. canvas-watch supplies the class. */<script>
import { watchBgCanvas } from '@mzebley/canvas-watch/svelte';
</script>
<section class="ink-trigger">A dark background</section>
<nav use:watchBgCanvas>A floating navigation</nav>
<style>
section { min-block-size: 100vh; background: #33205e; color: #f4f0fa; }
nav { position: fixed; inset: 1rem 1rem auto; padding: 1rem; color: #4b2a87; }
nav:global(.over-ink) { color: #f4f0fa; }
</style>The action registers the element directly, shares a watcher across the app, and cleans up when the element is destroyed. A watch-bg-canvas class is optional here.
React, Vue, Angular, and other frameworks can use the core in their mount and teardown lifecycles. The package ships a Svelte adapter; other adapters are yours to compose.
Try the actual behavior
Practical examples
These examples run canvas-watch itself. Adjust the geometry, change the threshold, and inspect the class and event it produces.
Let a floating player pick up its surroundings.
Swap a shadow color without rebuilding the player. The class changes only when a different zone wins.
No qualifying zone{
"appliedClass": null,
"previousClass": null
}.player {
--shadow-color: #4b2a87;
/* Compose this on the element, where its color changes. */
box-shadow: 0 12px 32px -8px
color-mix(in srgb, var(--shadow-color) 40%, transparent);
}
.player.over-rose { --shadow-color: #a72772; }
.player.over-ink { --shadow-color: #c5b2e6; }
@media (prefers-reduced-motion: no-preference) {
.player { transition: box-shadow 180ms ease; }
}Give the more specific background a say.
A dark inset sits inside a light section. Once the inset meets the overlap threshold, it wins over its parent. Raise the threshold and move to an edge to see it fall back.
No qualifying zone{
"appliedClass": null,
"previousClass": null
}<section class="paper-trigger">
A background covering the entire example.
<aside class="ink-trigger">A more specific background.</aside>
</section>
<div class="watch-bg-canvas">The floating element</div>
<!-- The nested ink zone wins once it meets the threshold. -->Treat adjacent surfaces as one.
The two rose zones carry the same class. Together, they can meet a threshold that neither meets alone. Duplicating the left zone adds no new physical coverage.
No qualifying zone{
"appliedClass": null,
"previousClass": null
}<section class="rose-trigger">Left half</section>
<section class="rose-trigger">Right half</section>
<div class="watch-bg-canvas">Across both zones</div>
<!-- Adjacent zones with the same applied class combine.
Overlapping pixels at the same depth count once. -->Keep the class names your app already uses.
Map trigger classes to on-paper, on-rose, and on-ink. Custom selectors keep a watcher focused on the part of the page it owns.
No qualifying zone{
"appliedClass": null,
"previousClass": null
}const watcher = createCanvasWatcher({
watchSelector: '.floating-control',
triggerSelector: '.page-zone',
classMap: {
'paper-trigger': 'on-paper',
'rose-trigger': 'on-rose',
'ink-trigger': 'on-ink',
},
threshold: 0.5,
});
watcher.refresh();React when the answer changes.
Each example exposes its latest canvaschange event. Use appliedClass and previousClass for application logic; both can be null. Svelte's action exposes the same detail through onChange.
floatingElement.addEventListener('canvaschange', (event) => {
const { appliedClass, previousClass } = event.detail;
console.log(previousClass, '→', appliedClass);
});
// Svelte: pass the same detail to an onChange callback.
// use:watchBgCanvas={{ onChange: (detail) => { … } }}First, discard zones below the threshold. Then choose the deepest eligible zone. At the same depth, the greatest overlap wins. If no zone qualifies, the owned class is removed.
A second kind of watching
Keep track of what left the viewport.
A navigation can gain a border after the hero leaves. A dock can change after its original controls scroll away. Reference a native ID or an Element and read its vertical state.
<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">Your introduction</section>aboveViewport, inViewport, belowViewport, and missing are reactive getters. Before the first client measurement, state is unknown and all four are false. Read them from a template or tracked effect.
const stop = watcher.observeViewport('#hero', (detail) => {
// state: 'missing' | 'above' | 'within' | 'below'
console.log(detail.state, detail.previousState);
});
// A direct Element reference works too.
// Each subscription gets an independent disposer.
stop();Or let HTML own the styling.
With data-canvas-watch-viewport, an element receives exactly one cw-reference-above, cw-reference-within, cw-reference-below, or cw-reference-missing class. Discovery stays active after refresh(), so later insertion, removal, and reference attribute changes are picked up automatically.
<header data-canvas-watch-viewport="#hero">Navigation</header>
<section id="hero">Your introduction</section>
<style>
header.cw-reference-above { box-shadow: 0 4px 12px #0002; }
header.cw-reference-missing { border-bottom: 2px dashed; }
</style>
<!-- Call watcher.refresh() once to enable discovery. -->The complete surface
API reference
createCanvasWatcher(options?)
Create an independent watcher. In an SSR environment it returns a no-op. Options are validated before browser resources are allocated: thresholds must be finite and between 0 and 1, margins finite, selectors valid, and class mappings single tokens.
| Option / default | What it controls |
|---|---|
watchSelector.watch-bg-canvas | Find the elements that receive overlap classes. |
triggerSelector[class*="-trigger"] | Find candidate background zones. |
triggerSuffix-trigger | Identify the zone classes on each candidate. |
appliedPrefixover- | Prefix the class applied to a watched element. |
classMapundefined | Map trigger classes to your own applied classes. |
threshold0.5 | Require this share of the watched element's area, from 0 to 1. |
triggerRootMargin200 | Keep zones active within this many pixels of the viewport. |
Watcher methods
refresh()- Re-scan overlap targets, trigger zones, and declarative viewport elements.
watch(element)- Register an element directly. Returns an independent unwatch function.
observeViewport(reference, listener)- Watch a native #id or Element. Returns an independent unsubscribe function.
schedule()- Request one measurement on the next animation frame.
destroy()- Disconnect observers, cancel pending work, and remove owned classes. Terminal and idempotent.
Svelte adapter
watchBgCanvas- An action that registers overlap on the shared watcher. Accepts an optional
onChange(detail)callback. canvasWatch(reference)- Reactive viewport state with five getters:
state,aboveViewport,inViewport,belowViewport, andmissing. For imperative subscriptions, useobserveViewport. refreshCanvasWatch()- Re-scan the shared watcher after mounting new trigger zones or navigating.
Shared watcher and pure helpers
getSharedWatcher()- Join the singleton used by the Svelte adapter.
refreshCanvasWatch()- Queue a coalesced re-scan of the shared watcher.
scheduleRefresh()- An alias of refreshCanvasWatch; the same coalesced re-scan.
overlapArea(a, b)- Calculate the intersection area of two rectangles.
resolveAppliedClass(triggerClass, options)- Resolve a class mapping or the suffix/prefix convention.
pickWinningClass(totals, area, threshold)- Choose among depth-qualified class claims.
classifyViewportRect(rect, top, bottom)- Classify vertical position against two viewport bounds.
import type {
CanvasWatcher, CanvasWatchOptions, CanvasChangeDetail,
ViewportState, ViewportReference, ViewportChangeDetail,
ViewportListener, ClassResolveOptions, DOMRectLike, ZoneOverlap,
} from '@mzebley/canvas-watch';
// Additional Svelte types from '@mzebley/canvas-watch/svelte':
// CanvasViewport, WatchBgCanvasParams
type State = 'unknown' | 'missing' | 'above' | 'within' | 'below';
// ViewportReference accepts a native '#id' or a direct Element.The core exports types for watchers, options, rectangles, overlap claims, viewport references and listeners, and change details. The Svelte entry also exports CanvasViewport and WatchBgCanvasParams.
Mount, update, clean up
Built to live inside an app.
Register one element or discover a group. Each registration owns an independent, idempotent disposer. Destroying a watcher disconnects its observers and makes future calls inert.
const watcher = createCanvasWatcher();
const unwatch = watcher.watch(floatingElement);
const unsubscribe = watcher.observeViewport('#hero', onViewportChange);
watcher.refresh(); // Re-index after adding or changing trigger zones.
watcher.schedule(); // Re-measure after moving an element with a transform.
unwatch();
unsubscribe();
watcher.destroy(); // Disconnect observers and remove owned classes.Refresh after a route changes.
A layout-level navigation outlives its pages. Let the new DOM render, then re-index its trigger zones. In SvelteKit:
<script>
import { afterNavigate } from '$app/navigation';
import { tick } from 'svelte';
import { refreshCanvasWatch } from '@mzebley/canvas-watch/svelte';
afterNavigate(async () => {
await tick();
refreshCanvasWatch();
});
</script>One measurement pass per frame.
- Gate by visibility. IntersectionObserver keeps overlap work focused on visible elements and nearby zones.
- Coalesce the signals. Scroll, resize, relevant mutations, and observer callbacks feed one frame scheduler per watcher.
- Read, commit, notify. Each unique rectangle is read once per pass. Classes are committed before consumer callbacks run.
- Leave stable classes alone. Scrolling within the same winning zone causes no class churn. An idle page with no active work skips measuring.
The Svelte adapter shares one watcher across its elements. ESM and CommonJS consumers in the same JavaScript realm join the same singleton through getSharedWatcher(). Separate watchers, as used by these configurable examples, keep their own schedulers.
Know the boundaries
Geometry is a useful signal.
canvas-watch changes classes and reports state. It doesn't inspect pixels, calculate contrast, add ARIA, announce changes, or move focus. Your styles and behavior still need to work for the people using them.
- Rectangles, not paint order.
- Overlap doesn't model occlusion, clipping, opacity, or
z-index. DOM nesting decides specificity. A zone's geometry can extend outside a clipped scroller. - A layout viewport, on the vertical axis.
- Viewport state ignores horizontal position and uses the layout viewport. Direct references must belong to the watcher's document. Document selectors don't cross shadow roots; direct viewport references inside one are supported.
- Styles stay your responsibility.
- Choose contrasting foregrounds for every background. Respect reduced motion for transitions and compose shadow variables on the watched element where overrides apply.
- Some changes need an explicit signal.
- Call
refresh()after editing trigger classes or adding zones. Callschedule()after transforms or other geometry changes that don't trigger an observed signal. Sticky positioning still follows normal overflow rules.
Browser requirements
IntersectionObserver, ResizeObserver, MutationObserver, requestAnimationFrame, and CustomEvent. The library's browser suite covers Chromium; other engines aren't covered by that CI suite.
Small pieces. Your interface.
Read the source