Three small design decisions in a “toggle effects via CSS class” library, and the tradeoffs behind them

Three small design decisions in a “toggle effects via CSS class” library, and the tradeoffs behind them

I built halloween.js, a small library that adds Halloween-themed page effects (blinking eyes, flying witches, a dropping spider, screen-corner webs) to any website, driven entirely by CSS classes on . The effects themselves aren’t interesting — CSS animations and a setTimeout scheduler. What I want to write about are three decisions that turned out harder than they looked once real usage exposed the edge cases.

1. Reactive sync via MutationObserver instead of an imperative API

The obvious API for a library like this is imperative: Halloween.start("eyes"). I built that first, then threw most of it away.

The problem: this library is meant to be dropped into contexts where you don’t control JS execution order — a WordPress header, a page builder, a CMS field that toggles a class based on user state. An imperative API assumes you can call a function at the right moment. In practice, the “right moment” doesn’t exist in these environments.

So instead, the library watches document.body‘s classList:

const observer = new MutationObserver(trySync);
observer.observe(document.body, {
  attributes: true,
  attributeFilter: ['class', 'data-halloween-start', 'data-halloween-end'],
});

Any code, anywhere, adding or removing halloween-eyes on — a page builder’s visual toggle, a classList.toggle() in unrelated code, a browser extension — gets picked up and re-synced automatically. No init call, no “did this run before or after my class change” race.

The non-obvious part: when a class is removed, the corresponding effect has to stop immediately, not after its current animation cycle finishes. If a spider is mid-drop and you flip halloween off, waiting for the drop-and-climb animation to complete before tearing down the node means up to several seconds of an effect running after it was explicitly turned off — which, from the caller’s perspective, looks like a bug (“I removed the class, why is it still animating”). So trySync() does a hard stop-and-remove on any mutation where the gate condition is now false, rather than a graceful fade-out queued behind the current animation frame.

2. Three-source config with per-edge precedence, resolved fresh every time

The library only runs within a season window (defaults to ~2 weeks around Halloween), so it can be left on a page year-round. That window’s start/end can come from three places:

  1. A data-halloween-start/data-halloween-end attribute on (highest priority)
  2. A classic

Previous Post

A quarter of Nvidia’s business next year comes from labs it is financing

Next Post

Free Online Composites Course Helps Build the Next Generation of Manufacturing Talent

Related Posts