Building a Calendar-Based Halloween Theme in Vue

Building a Calendar-Based Halloween Theme in Vue

●1 ●8 ●35
calendar_today ago • schedule1 min read

Building a Calendar-Based Halloween Theme in Vue

A seasonal theme can make a website feel a little more alive. The challenge is making it appear at the right time, disappear afterward, and stay out of the way of the actual content.

On rusu-ionut.ro, I use an occasion calendar to select small seasonal decorations. The calendar and welcome decorations were adapted from Recipe Finder.

Here's how the Halloween implementation works: define a date window, resolve the active occasion, share the result across Vue components, and render decorations without shifting the page layout.

Start with an occasion calendar

Instead of putting a Halloween date check inside every component, I keep seasonal windows in one calendar. Here's the Halloween entry:

export const OCCASIONS = {
  HALLOWEEN: "Halloween",
  // Other occasions...
} as const;

export const OCCASION_CALENDAR = [
  {
    OCCASION: OCCASIONS.HALLOWEEN,
    START: { MONTH: 10, DAY: 1 },
    END: { MONTH: 11, DAY: 2 },
  },
  // Other occasion windows...
];

The window is inclusive, so Halloween decorations can appear from October 1 through November 2. The year is intentionally absent: this schedule repeats annually.

The full calendar also includes occasions such as Christmas and New Year's Eve. Dates outside all configured windows return no occasion, allowing the site to use its regular appearance.

Compare month and day

For a yearly calendar, we only need the month and day. One way to compare them is to turn each pair into a number:

function toCalendarDayRank(month: number, day: number): number {
  return month * 100 + day;
}

October 1 becomes 1001, October 31 becomes 1031, and November 2 becomes 1102. These aren't timestamps or durations; they simply preserve the order of dates within a year.

A normal window can use an inclusive comparison:

dayRank >= startRank && dayRank <= endRank

But a window can also cross New Year. For a December-to-January window, the end rank is smaller than the start rank. That requires an or comparison:

function isDayInWindow(
  dayRank: number,
  startRank: number,
  endRank: number,
): boolean {
  return startRank <= endRank
    ? dayRank >= startRank && dayRank <= endRank
    : dayRank >= startRank || dayRank <= endRank;
}

The first branch handles Halloween. The second handles a window such as December 27 through January 5.

Resolve the active occasion

The calendar resolver reads the visitor's local date and finds the first matching window. This is a simplified version of the implementation, using the helpers above:

function getActiveOccasion(date = new Date()) {
  // JavaScript months start at zero; calendar months start at one.
  const dayRank = toCalendarDayRank(
    date.getMonth() + 1,
    date.getDate(),
  );

  return (
    OCCASION_CALENDAR.find((entry) => {
      const startRank = toCalendarDayRank(
        entry.START.MONTH,
        entry.START.DAY,
      );
      const endRank = toCalendarDayRank(
        entry.END.MONTH,
        entry.END.DAY,
      );

      return isDayInWindow(dayRank, startRank, endRank);
    })?.OCCASION ?? null
  );
}

Now the rest of the app can ask for an occasion without knowing the dates:

getActiveOccasion(new Date(2026, 9, 31)); // "Halloween"
getActiveOccasion(new Date(2026, 8, 30)); // null

JavaScript's Date constructor uses zero-based months: 9 means October.

This implementation uses the visitor's local clock. Visitors in different time zones can therefore see a seasonal decoration begin at different moments. A site that needs one global switch-over time would need a different date policy.

Map the occasion to Vue components

The calendar answers when an occasion is active. A separate theme registry answers what to render.

On rusu-ionut.ro, Halloween has a pumpkin for the startup splash and a title decoration for the home page:

[OCCASIONS.HALLOWEEN]: {
  SPLASH_ACCENT: {
    GLYPH: OccasionPumpkin,
    CLASS: "-bottom-3 -left-5 w-[4.75rem]",
  },
  TITLE_ACCENT: () =>
    import("@/components/occasions/title/TitleHalloween.vue"),
},

The splash drawing is imported with the splash so it can appear with the first paint. The title accent uses a dynamic import, so the app only loads that decoration when it is needed. Separating the schedule and artwork also makes it easier to change a date window without editing a Vue component.

Share one decision across the app

The splash and home headline should show the same occasion. The app resolves the date once at the root and shares the resulting theme with Vue's provide and inject.

The essential pattern looks like this:

// Root component
provideOccasionTheme();
// A descendant such as the splash or hero
const occasionTheme = useOccasionTheme();

The composable also prepares the title decoration with defineAsyncComponent. If that decorative component fails to load, the site keeps working without it.

There is a deliberate trade-off: the occasion is read once per app session. If a visitor keeps the page open across midnight, the splash and headline remain consistent with each other until the app is reloaded. A site that must change immediately at midnight could add a timer and refresh the shared state.

Render decorations without moving the content

A Halloween theme doesn't need to replace the whole page. On rusu-ionut.ro, the accent adds a pumpkin and two bats around the home headline.

The decoration sits in an absolutely positioned layer over a relatively positioned name:

<span class="relative">
  {{ firstName }}

  <component
    :is="occasionTheme.TitleAccent"
    v-if="occasionTheme"
  />
</span>

The shared decoration layer is hidden from assistive technology and ignores pointer events:

<span
  aria-hidden="true"
  class="pointer-events-none absolute inset-0 select-none"
>
  <slot />
</span>

That keeps decorative artwork out of the reading order and prevents it from intercepting clicks. Absolute positioning also keeps the accent out of normal document flow, so loading it doesn't need to move the headline.

The bat animation respects reduced-motion preferences:

<OccasionBat
  class="animate-[occasion-flutter_5s_ease-in-out_infinite_alternate]
         motion-reduce:animate-none"
/>

The result is still recognizable when animation is disabled.

Test the calendar boundaries

Date logic is easy to get almost right. Boundary tests make the intended behavior explicit:

expect(getActiveOccasion(new Date(2026, 9, 1)))
  .toBe(OCCASIONS.HALLOWEEN);

expect(getActiveOccasion(new Date(2026, 10, 2)))
  .toBe(OCCASIONS.HALLOWEEN);

expect(getActiveOccasion(new Date(2026, 8, 30)))
  .toBeNull();

The project's tests also check a window crossing New Year and walk all 366 days of a leap year to verify that no two windows claim the same date. That matters because find() returns the first match: an accidental overlap would otherwise make calendar order decide the winner.

Browser tests complement those checks by freezing dates and checking the splash and headline at phone, tablet, and desktop widths, including reduced motion and a title accent that fails to load.

Applying the idea to another site

The same pattern can fit different products. Recipe Finder provided the seasonal decorations and calendar adapted for rusu-ionut.ro. A recipe site could use the active occasion to decorate a welcome screen, while a personal site can place a small accent around its headline.

The reusable flow is:

Local date → calendar window → active occasion → theme config → Vue decoration

Each part has a clear job. The calendar controls timing, the theme configuration selects artwork, and Vue components decide where the artwork belongs. When no window matches, the regular site remains available.

2 Comments

1 vote
1
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

The Ancient Roman Calendar in Your Python Terminal: A Love Letter to Useless Code

nevmenandr - Oct 14

Tailwind CSS for Beginners: From Zero to Your First Component

muhammadfarhan.dev - Jul 21

How I Started Writing Unit Tests for Vue Components - Part 2

ByteMinds - Nov 21, 2025

Building a Member Portal in Vue.js: Lessons from a Professional Association Project

Luis Cruz - May 19

Show & Tell: Building a Self-Healing SEO Architecture for a Vue SPA

Ionuț RUSU - May 1
chevron_left
741 Points • 44 Badges
Suceava, Romania • rusu-ionut.ro
11Posts
18Comments
26Connections
I'm a full-stack developer passionate about resilient systems and intuitive interfaces. Writing abou... Show more

Related Jobs

View all jobs →

Commenters (This Week)

15 comments
5 comments
5 comments

Contribute meaningful comments to climb the leaderboard and earn badges!