DEV Community

Cover image for Three small design decisions in a "toggle effects via CSS class" library, and the tradeoffs behind them
Iurii Rogulia
Iurii Rogulia

Posted on AI-assisted

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 <body>. 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'],
});
Enter fullscreen mode Exit fullscreen mode

Any code, anywhere, adding or removing halloween-eyes on <body> — 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 <body> (highest priority)
  2. A classic <script src="..."> tag's own ?s=/?e= query params, captured once via document.currentScript while the script is synchronously executing
  3. A hardcoded library default

The precedence is resolved independently for the start and end edge — you can override just the end date and let the start fall through to the query param or default. More importantly, it's re-resolved on every sync call, not cached at load:

export function getSeasonWindow(): { start: string; end: string } {
  return {
    start: bodyAttrOr('data-halloween-start', CONFIG.seasonStart),
    end: bodyAttrOr('data-halloween-end', CONFIG.seasonEnd),
  };
}
Enter fullscreen mode Exit fullscreen mode

This matters because the data-* attributes are in the MutationObserver's attributeFilter. If a page changes data-halloween-end at runtime (say, a marketing team extends the promotion by a week via a CMS field), the new value takes effect on the very next mutation-triggered sync — no page reload needed. Caching the resolved window at module load, which is what I did in the first draft, silently broke that.

3. Fail-open on malformed date input, plus a wraparound range and leap-day clamp

Date parsing for DD-MM strings turned out to have more edge cases than expected:

  • Malformed input fails open, not closed. If data-halloween-start is set to garbage (typo, wrong format), the library defaults to running, not to silently disabling itself:

    export function isWithinSeason(now: Date, startStr: string, endStr: string): boolean {
    const start = parseDDMM(startStr);
    const end = parseDDMM(endStr);
    if (!start || !end) return true; // fail open
    ...

The reasoning: a config typo that silently turns the whole feature off is a much worse failure mode for this library's use case (a seasonal decoration someone configured once and forgot about) than one that just always runs. A support ticket for "it's running when it shouldn't" is easier to diagnose than "it silently never ran and nobody noticed for a year."

  • The range can wrap across New Year's (e.g., a 25-1205-01 window), which flips the comparison from AND to OR:

    if (startDate <= endDate) {
    return now >= startDate && now <= endDate;
    }
    return now >= startDate || now <= endDate;

  • 29-02 is accepted as a valid date year-round, not just in leap years, and gets clamped to 28-02 when resolved against a non-leap year. This avoids a config that silently stops working every three years out of four, which is exactly the kind of intermittent bug that's miserable to track down months after the config was written.

What I'd still call unfinished

The season gate is only re-evaluated when something mutates the watched attributes — there's no polling for "it's now past midnight, recheck the date." If a page is left open across a season boundary with zero DOM mutations in between, the library won't notice until something else touches class or the data-halloween-* attributes (a reload, another caller of the public halloween() re-sync function). For a page that's actually being interacted with this is a non-issue in practice, but it's a real gap I haven't closed, not an oversight I'm unaware of.


Source: https://github.com/rogulia/halloween.js
Live demo: https://halloween.js.org/

Top comments (0)