− Font size: 100% +
Dark mode

canvas-watch

A dependency-free geometry watcher for
background-aware interfaces and reactive viewport state.

Live example Background overlap
paper-trigger
rose-trigger
ink-trigger
Same element.unassigned
Another elementWaiting for a zone
AaNo qualifying zone
↗No qualifying zone

Two elements, one watcher. Each follows the background underneath it.

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-watch

Any framework

@mzebley/canvas-watch

The core has no framework dependencies.

Svelte 5.7+

@mzebley/canvas-watch/svelte

An 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
JavaScript
<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. */
Svelte
<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.

Live example Background overlap
paper-trigger
rose-trigger
ink-trigger
Stay a little longerA floating player
Applied classNo qualifying zone
canvaschange event · 0 changes
{
  "appliedClass": null,
  "previousClass": null
}
View the shadow styles
.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.

Live example Nested backgrounds
paper-trigger · parent
ink-trigger · nested
Specific wins.unassigned
Applied classNo qualifying zone
canvaschange event · 0 changes
{
  "appliedClass": null,
  "previousClass": null
}
View the nested markup
<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.

Live example Combined coverage
Two adjacent zones. One class.
rose-trigger
rose-trigger
One surface.unassigned
Applied classNo qualifying zone
Duplicate the left zoneDuplicate rectangles don't add coverage.
canvaschange event · 0 changes
{
  "appliedClass": null,
  "previousClass": null
}
View the combined-zone markup
<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.

Live example Your class names
paper-trigger
rose-trigger
ink-trigger
Your styles.unassigned
Applied classNo qualifying zone
canvaschange event · 0 changes
{
  "appliedClass": null,
  "previousClass": null
}
View the watcher configuration
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) => { … } }}
The winner rule

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.

Live exampleThis page's actual viewport
#viewport-beacon

Waiting for a measurement

belowwithinabovemissing

Scroll past the striped reference, or remove it. The state follows its position in this page.

Remove the reference
The referenceHere I am.#viewport-beacon
<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.

Use the core callback instead
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. -->
HTML-owned reference stateState is also shown by the live viewport example above.

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.

Watcher options
Option / defaultWhat it controls
watchSelector.watch-bg-canvasFind the elements that receive overlap classes.
triggerSelector[class*="-trigger"]Find candidate background zones.
triggerSuffix-triggerIdentify the zone classes on each candidate.
appliedPrefixover-Prefix the class applied to a watched element.
classMapundefinedMap trigger classes to your own applied classes.
threshold0.5Require this share of the watched element's area, from 0 to 1.
triggerRootMargin200Keep 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, and missing. For imperative subscriptions, use observeViewport.
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.
TypeScript exports
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.

  1. Gate by visibility. IntersectionObserver keeps overlap work focused on visible elements and nearby zones.
  2. Coalesce the signals. Scroll, resize, relevant mutations, and observer callbacks feed one frame scheduler per watcher.
  3. Read, commit, notify. Each unique rectangle is read once per pass. Classes are committed before consumer callbacks run.
  4. 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. Call schedule() 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