Iurii RoguliaIurii Rogulia
AboutServicesPricingProjectsStackReviewsPhrasesBlog
Contact
Iurii ships.

Iurii Rogulia, senior full-stack software engineer. Professionally building software since 2001.

Think of a number
PricingQuality checklistPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland 🇫🇮

[email protected]
  1. Home
  2. /
  3. Blog
  4. /
  5. halloween.js: Drop-In Halloween Effects for Any Website (TypeScript)

Iurii Ships: halloween.js: Drop-In Halloween Effects for Any Website (TypeScript)

A 9 KB TypeScript library that puts Halloween decorations on any website with a single CSS class.

August 28, 2026· 10 min read

halloween.js adds Halloween decorations to any website — one class flips them on, a real-calendar season window turns them off automatically, and a MutationObserver keeps the effects in sync with the page instead of relying on an init call.

Stack

TypeScript

Services

npmGitHub Actions

Topics

Open SourceVanilla JSAnimationAccessibility
halloween.js: Drop-In Halloween Effects for Any Website (TypeScript)

halloween.js is a small library I built to put Halloween on a website: spider webs in the corners, blinking eyes, flying witches, a dropping spider, a rising tombstone, each with subtle/normal/party intensity presets and a color that inherits the host page's theme via currentColor. Drop in a script tag or npm install it, add class="halloween" to <body>, and the page is decorated. It's seasonal by default — the effects run inside a real-calendar window around Halloween (18 Oct–2 Nov) and stop the rest of the year, so nobody has to remember to switch it off on November 3rd.

That one-class interface is where the interesting engineering problem sits. Most small JS libraries want you to call something — a line your code has to remember to run, at the right time, before the effect it's wiring up exists. That works until the thing controlling the library isn't a call site anymore — it's a class toggled from three different places, or a route change in a single-page app that never re-runs your bootstrap script. halloween.js doesn't need to be told when to turn on. It watches the DOM and reacts.

The Problem With "Call This When You're Ready"

The common pattern for a page-effects library looks like this: import it, call an init function once the DOM is ready, pass it some config, and from then on your application code and the library's internal state can drift. If a route change adds a class the library doesn't know to check for, nothing happens until the next explicit re-init — if there ever is one.

halloween.js's one input is that same class on <body>: class="halloween" turns the whole system on; additional classes (halloween-eyes, halloween-witches, halloween-webs, and so on) opt into individual decorations. There's no required call to make that happen. A MutationObserver watches <body>'s class attribute and re-evaluates the master switch and every effect class on every mutation:

// illustrative — pattern-level TypeScript matching the documented
// class-driven sync model, not a literal excerpt from the repo's src/
const observer = new MutationObserver(() => {
  syncEffectsToClassList(document.body.classList);
});
 
observer.observe(document.body, {
  attributes: true,
  attributeFilter: ["class"],
});
 
function syncEffectsToClassList(classList: DOMTokenList) {
  const isActive = classList.contains("halloween") && isWithinSeasonWindow();
  for (const effect of EFFECTS) {
    const shouldRun = isActive && classList.contains(effect.className);
    effect.setRunning(shouldRun);
  }
}

That's the whole integration surface for the common case. document.body.classList.toggle("halloween-eyes", condition) from anywhere — a click handler, a feature flag check, a Next.js route change — is enough on its own. There's no bootstrap step to forget, and no state to fall out of sync with because there's only one state: the classList itself.

The library still exports a halloween() function, but it isn't an init call in the traditional sense. It forces an immediate re-check ahead of the observer's next automatic pass, running the exact same master-switch and season logic the observer already runs. Use it when you've just changed several classes at once and want the effects to reconcile before the next paint. Nothing is broken without it.

The trade-off: a MutationObserver is a persistent observer with a callback that fires on every relevant DOM mutation, which is marginally more overhead than a function that runs once and returns. For a library gating a handful of screen-corner decorations, that cost is negligible. It would not be the right default for something reacting to high-frequency DOM churn.

Season Gating: Fail Open, Not Fail Closed

The library is seasonal by default: effects only run inside a window around Halloween (18 Oct – 2 Nov by default). Two ways to override it — data-halloween-start / data-halloween-end attributes on <body>, or ?s=/?e= query params on the script's own src tag for the no-build case. Attribute values take precedence, and each boundary resolves independently, so you can override just the start date and leave the default end date in place.

There are two ways to handle a bad date-range config: reject it silently and disable the whole library, or reject it silently and run unrestricted. halloween.js does the second — a malformed but non-empty value fails open.

// illustrative — pattern-level TypeScript matching the documented
// calendar-validated, fail-open season window behavior
function resolveSeasonWindow(startAttr: string | null, endAttr: string | null) {
  const start = startAttr ? parseCalendarDate(startAttr) : DEFAULT_START;
  const end = endAttr ? parseCalendarDate(endAttr) : DEFAULT_END;
 
  // a present-but-invalid override fails open: unrestricted,
  // not disabled — a config typo shouldn't take down a working page
  if (startAttr && start === null) return UNRESTRICTED;
  if (endAttr && end === null) return UNRESTRICTED;
 
  return { start, end };
}
 
function parseCalendarDate(value: string): CalendarDate | null {
  const [day, month] = value.split("-").map(Number);
  if (!isValidCalendarDayForMonth(day, month)) return null; // rejects 31-04
  return { day, month }; // 29-02 is valid; resolved against the actual year at check time
}

parseCalendarDate doesn't just check "is this a two-digit-dash-two-digit string". It validates against the actual calendar. 31-04 is rejected outright (April has 30 days). 29-02 is accepted as a valid date year-round, but resolving whether today falls before or after it has to account for the year: in a non-leap year, 29-02 has to behave as 28 Feb, not silently vanish from the calendar or throw.

Why fail-open rather than fail-closed: a marketing site owner typing a date attribute by hand is going to typo it eventually — a wrong separator, a transposed day and month, an end date they meant as a start date. If that typo disabled the whole library, the failure is invisible until someone notices, weeks later, that decorations never appeared, with no error anywhere. Fail-open makes the failure visible instead: the effects ran a bit longer than intended. Annoying, self-correcting, and nowhere near as bad as a silently broken feature nobody's watching for.

A naive implementation goes wrong in the other direction. String-comparing "18-10" <= today <= "02-11" breaks the moment the window wraps a year boundary. The default window doesn't — 18 Oct–2 Nov stays within one calendar year — but a custom window spanning December into January would silently never match. And it has no calendar validation at all, so 31-04 would silently "work" as a date that never actually exists.

Reduced Motion, Checked Live

prefers-reduced-motion is usually read once, on load, with a matchMedia(...).matches check that never runs again. That's wrong for two reasons: the user can change the OS-level setting while the page is open, and even sites that do add a listener often let an in-flight animation finish before honoring the change.

halloween.js does both differently — it listens live, and it removes running effects immediately rather than waiting out their current animation:

// illustrative — pattern-level TypeScript matching the documented
// live reduced-motion handling
const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
 
function applyReducedMotionState(reduced: boolean) {
  if (!reduced) return;
  // stop spawning new ambient effects, and remove anything
  // currently on screen immediately — not after its animation finishes
  for (const effect of AMBIENT_EFFECTS) {
    effect.stop();
    effect.removeInstancesImmediately();
  }
}
 
reducedMotionQuery.addEventListener("change", (event) => {
  applyReducedMotionState(event.matches);
});
 
applyReducedMotionState(reducedMotionQuery.matches);

Ambient effects — blinking eyes, flying witches, a dropping spider, rising tombstones — stop spawning and clear immediately. Static corner webs are unaffected, since they were never animated to begin with. Every decorative SVG carries aria-hidden, since none of it is content — it's page chrome, and screen readers shouldn't have to navigate around it.

A listener that toggles a flag but lets a setTimeout-driven animation loop run to completion passes a code review and still fails the accessibility requirement. The user asked for the motion to stop now.

Shipping It: One Source, Three Build Targets

Consumers still have to get the library into their project the way their stack expects. halloween.js ships from one TypeScript source, built with tsup, into three targets:

  • ESM and CJS, each with its own .d.ts / .d.cts declaration file, for npm/bundler consumers on either module system
  • A self-contained IIFE — styles inlined, no separate CSS file to remember to link — for the no-build case: WordPress, a plain HTML page, any CMS with a script-tag field

The IIFE build is the one under real size pressure, since it's the one loaded synchronously on a page that has nothing to do with build tooling: 9,102 bytes gzipped, styles included, zero runtime dependencies.

One package.json detail decides whether the ESM build's auto-init survives: sideEffects has to be declared explicitly. The library's ESM entry point does real work when it's imported — attaching the MutationObserver, the reduced-motion listener — as a module-level side effect, not inside an exported function a bundler can see is called. Without "sideEffects": false scoped correctly (or the file listed as having side effects), a bundler doing aggressive tree-shaking on import "halloween.js" with no other imports used from it can legally conclude the import does nothing and drop it. For a library whose entire common-case API is "import it and it works," that's a silent, hard-to-diagnose failure hitting exactly the consumers who did everything right.

Release is gated by CI: the full check suite — typecheck, all 130 tests, build, package-export verification — runs on Node 22 and 24 for every push and PR. Publishing is a separate workflow, triggered only by a v* tag, that verifies the tag matches package.json's version before it runs npm publish, using npm's trusted publishing via GitHub Actions OIDC. No npm token sits in CI secrets waiting to leak.

Where This Pattern Doesn't Apply

A class-driven MutationObserver sync model isn't the right choice everywhere:

  • It needs JavaScript to run at all. The IIFE requires the script tag to load and execute — there's no server-rendered fallback that shows decorations at first paint before JS runs. For a purely decorative, non-critical effect that's an acceptable trade-off; it wouldn't be for anything that has to be visible without JavaScript.
  • It assumes DOM class state is the right sync signal. If an application already tracks the relevant state somewhere else — a global store, a URL param, a WebSocket message — routing that through a body class just to trigger this library is an extra indirection, not a simplification. The pattern earns its keep specifically because classList is already the lowest-common-denominator signal every framework and no-build page can produce.
  • A page already running a heavier animation library (GSAP, Lottie, a full canvas engine) gets little from adding a second, smaller one for one specific effect — the marginal maintenance cost of a second dependency usually isn't worth it once one is already paying for a heavier toolkit's capabilities.
  • MutationObserver support is universal in any browser this library's other requirements (ES2020+, matchMedia) already assume, so this isn't a live constraint today. It's listed because it's the actual dependency the sync model rests on, not because it's expected to matter in practice.

The Underlying Idea

None of this is specific to Halloween decorations. "Watch a piece of state your application already maintains, react to changes, and provide an escape hatch for forcing an immediate check" is a pattern that applies to feature flags, A/B test variants, theme switching — anywhere a library's on/off state should track application state instead of asking the application to remember to tell it.

The project card has the full write-up, including the results table and a closer look at the release pipeline: halloween.js project. Source is on GitHub, the package is on npm, and there's a live demo at halloween.js.org.


If you're shipping something small — a library, a widget, an MVP's first version — the same three things apply: typed builds for every consumer, defaults that fail safely instead of taking the whole thing down, and a CI pipeline that won't publish anything that hasn't passed its own tests. That's the discipline behind an MVP build I ship, not just an open-source side project. Get in touch.

Iurii RoguliaAvailable

MVP Development

Shipping a small library well isn't just "does it work" — typed builds for every consumer, defaults that fail safely, a CI pipeline that gates on tests before it publishes. That's the same discipline I bring to a first version of your product.

More about this service

Relevant client work

View all projects
halloween.js — Zero-Dependency Halloween Effects for Any Website
halloween.js — Zero-Dependency Halloween Effects for Any Website
August 26, 2026
halloween.js — Zero-Dependency Halloween Effects for Any Website

Zero-dependency Halloween screen-corner decorations and ambient page effects — one script tag or npm install, class-driven, seasonal by default, under 10 KB

n8n Verified Community Node — EU VAT Validation in Workflows
n8n Verified Community Node — EU VAT Validation in Workflows
August 6, 2026
n8n Verified Community Node — EU VAT Validation in Workflows

Verified n8n node that puts VIES VAT validation and EU rate lookups inside a workflow, so onboarding or invoicing can decide on a real VAT number instead of a

eu-vat-rates-data — Free & Open-Source EU VAT Rates Dataset
eu-vat-rates-data — Free & Open-Source EU VAT Rates Dataset
February 25, 2026
eu-vat-rates-data — Free & Open-Source EU VAT Rates Dataset

Free, open-source EU VAT rates for all 27 member states + UK. Published as native packages for npm, PyPI, Packagist, Go, and RubyGems.

What clients say

“

I'd built most of our MVP with Cursor and it looked finished — it compiled, the tests were green, the demo worked. It just wouldn't survive real users.

Sebastian Falk 🇸🇪

Founder

Stack

Next.jsTypeScript

Topics

Technical DebtAICode ReviewArchitecture
“

We wanted to add an AI feature that turns messy user notes into structured records, but our first attempt returned unpredictable JSON that broke the app half the time.

Bram de Vries 🇳🇱

Product Lead

Stack

Next.jsTypeScript

Services

OpenAI

Topics

AILLMStructured Outputs
“

I had a validated idea and a deadline tied to an accelerator demo day, but no product.

Aino Virtanen 🇫🇮

Founder

Stack

Next.jsTypeScript

Databases

PostgreSQL

Topics

MVPProductScopeSaaS

Related articles

Publishing One Package to Five Registries with GitHub Actions
March 27, 2026· 10 min
Publishing One Package to Five Registries with GitHub Actions

How to publish one dataset to npm, PyPI, Go Module, RubyGems, and Packagist automatically with GitHub Actions — architecture, versioning, and per-ecosystem

Stack

TypeScriptPythonPHPGoRuby

Services

GitHub ActionsnpmPyPIPackagistRubyGems

Topics

Open SourceTax/VATAutomation
IndexNow in Next.js: Instant Indexing After Every Deploy
April 14, 2026· 18 min
IndexNow in Next.js: Instant Indexing After Every Deploy

IndexNow implementation guide for Next.js: key generation, TypeScript client with retry logic, GitHub Actions workflow, and pitfalls that break submissions

Stack

TypeScriptNode.js

Services

GitHub Actions

Topics

SEOAutomationPerformanceArchitecture
SVG Keyframe Animation in Pure CSS (No Library)
March 5, 2026· 7 min
SVG Keyframe Animation in Pure CSS (No Library)

CSS SVG animation with pure @keyframes: sync multiple tracks, fix the SVG transform override trap, and build smooth loops without GSAP or Framer Motion.

Stack

TypeScriptReact

Topics

AnimationPerformanceSVG
Structured Logging in Next.js with Pino (Request IDs to stdout)
August 21, 2026· 11 min
Structured Logging in Next.js with Pino (Request IDs to stdout)

Structured logging in Next.js with pino: JSON to stdout, a per-request x-request-id, a wrapper that logs method/path/status/latency, and no secrets in logs.

Stack

Next.jsTypeScript

Libraries

pino

Topics

ObservabilityDevOpsInfrastructure
A Typed MDX Content Pipeline with Velite (Next.js Tutorial)
August 19, 2026· 9 min
A Typed MDX Content Pipeline with Velite (Next.js Tutorial)

Build a type-safe content layer with Velite: Zod schemas for MDX and JSON, computed fields, one typed import, and a related-posts codegen step.

Stack

Next.jsTypeScript

Services

MDX

Topics

ArchitectureContentBuild ToolsSEO