Celebrations

Year-round seasonal effects - eight celebrations, each with traits you can switch off or thin out.

Preview

In season, a sparkle disc on every page of this site turns the day's celebration on and off. Here you can run any of them, any day. Pick one, set how much of it you want, and switch individual traits off - Santa's sleigh, the steam train, the witches. Changes apply straight away.

Preview every celebration
Today-
ShowingNone
StatusStopped
Particles0
What appears

Pick a celebration to see what it draws.

<div class="celebrate-demo">
    <div class="celebrate-controls">
        <label class="celebrate-control">Celebration
            <select class="form-select form-select-sm" data-model="theme.value" data-on-change="themeChanged">
                <option value="auto">Auto - today's, if any</option>
                <option value="christmas">๐ŸŽ„ Christmas</option>
                <option value="halloween">๐ŸŽƒ Halloween</option>
                <option value="valentines">๐Ÿ’• Valentine's</option>
                <option value="guy-fawkes">๐ŸŽ† Guy Fawkes</option>
                <option value="st-patricks">โ˜˜๏ธ St Patrick's</option>
                <option value="st-andrews">๐Ÿด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ St Andrew's</option>
                <option value="st-davids">๐Ÿด๓ ง๓ ข๓ ท๓ ฌ๓ ณ๓ ฟ St David's</option>
                <option value="st-georges">๐Ÿด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ St George's</option>
            </select>
        </label>
        <div class="btn-group btn-group-sm" role="group" aria-label="Intensity">
            <button type="button" class="btn btn-sm" data-bind-class="intensity.value === 'light' ? 'btn-primary' : 'btn-secondary'" data-on-click="setIntensity('light')">Light</button>
            <button type="button" class="btn btn-sm" data-bind-class="intensity.value === 'medium' ? 'btn-primary' : 'btn-secondary'" data-on-click="setIntensity('medium')">Medium</button>
            <button type="button" class="btn btn-sm" data-bind-class="intensity.value === 'heavy' ? 'btn-primary' : 'btn-secondary'" data-on-click="setIntensity('heavy')">Heavy</button>
        </div>
        <button type="button" class="btn btn-sm" data-bind-class="running.value ? 'btn-danger' : 'btn-success'" data-on-click="toggle" data-bind-text="running.value ? 'Stop' : 'Start'">Start</button>
    </div>

    <div class="celebrate-stats">
        <div class="celebrate-stat"><span data-icon="calendar"></span><div><small>Today</small><strong data-bind-text="today.value">-</strong></div></div>
        <div class="celebrate-stat"><span data-icon="sparkles"></span><div><small>Showing</small><strong data-bind-text="showing.value">None</strong></div></div>
        <div class="celebrate-stat"><span data-icon="activity"></span><div><small>Status</small><strong data-bind-class="running.value ? 'text-success' : 'text-danger'" data-bind-text="running.value ? 'Running' : 'Stopped'">Stopped</strong></div></div>
        <div class="celebrate-stat"><span data-icon="box"></span><div><small>Particles</small><strong data-bind-text="particles.value">0</strong></div></div>
    </div>

    <div class="card celebrate-traits-card">
        <div class="card-header flex items-center justify-between">
            <strong>What appears</strong>
            <span class="btn-group btn-group-sm">
                <button type="button" class="btn btn-secondary btn-sm" data-on-click="allTraits(true)">All on</button>
                <button type="button" class="btn btn-secondary btn-sm" data-on-click="allTraits(false)">All off</button>
            </span>
        </div>
        <div class="card-body">
            <p class="text-muted" data-if="traits.value.length === 0">Pick a celebration to see what it draws.</p>
            <div class="celebrate-traits" data-each="traits.value key=name">
                <label><input type="checkbox" data-bind-checked="enabled" data-on-change="$root.setTrait($data)"> <span data-bind-text="label"></span></label>
            </div>
        </div>
    </div>

    <div class="celebrate-cards" data-each="themes key=id">
        <button type="button" class="celebrate-card" data-bind-class="$root.theme.value === id && 'active'" data-on-click="$root.show($data)">
            <span class="celebrate-emoji" data-bind-text="emoji"></span>
            <strong data-bind-text="name"></strong>
            <small class="celebrate-dates" data-bind-text="dates"></small>
            <small class="celebrate-draws" data-bind-text="draws"></small>
        </button>
    </div>
</div>
/**
 * Preview every celebration - from public/examples/celebrations, rebuilt on bindings.
 *
 * The celebrations are the domma-celebrate package; this site serves its build from
 * the dommajs-site plugin. A preview runs whatever the date, and even for a visitor
 * who asked for less motion - they came here to see it.
 */
const CELEBRATE = '/plugins/dommajs-site/public/vendor/celebrate/domma-celebrate.esm.js';

const THEMES = [
    {id: 'christmas', emoji: '๐ŸŽ„', name: 'Christmas', dates: '1 Dec - 1 Jan', draws: "Snowflakes, Santa's sleigh and reindeer, trees, wreaths, toy trains, robins, elves"},
    {id: 'halloween', emoji: '๐ŸŽƒ', name: 'Halloween', dates: '26-31 Oct', draws: 'Pumpkins, spiders, witches, bats, gravestones, jack-o-lanterns'},
    {id: 'valentines', emoji: '๐Ÿ’•', name: "Valentine's Day", dates: '9-14 Feb', draws: 'Hearts, rose petals, cupid, heart garlands, sparkles'},
    {id: 'guy-fawkes', emoji: '๐ŸŽ†', name: 'Guy Fawkes', dates: '1-5 Nov', draws: 'Sparks, rockets, bonfires, Catherine wheels, Roman candles'},
    {id: 'st-patricks', emoji: 'โ˜˜๏ธ', name: "St Patrick's", dates: '12-17 Mar ยท Ireland', draws: 'Shamrocks, gold coins, leprechauns, banshees, pots of gold, rainbows'},
    {id: 'st-andrews', emoji: '๐Ÿด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ', name: "St Andrew's", dates: '25-30 Nov ยท Scotland', draws: 'Thistles, snowflakes, a bagpiper, saltire flags, tartan'},
    {id: 'st-davids', emoji: '๐Ÿด๓ ง๓ ข๓ ท๓ ฌ๓ ณ๓ ฟ', name: "St David's", dates: '24 Feb - 1 Mar ยท Wales', draws: 'Daffodils, leeks, dragons, daffodil fields'},
    {id: 'st-georges', emoji: '๐Ÿด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ', name: "St George's", dates: '18-23 Apr ยท England', draws: 'Rose petals, knights, dragons, St George flags'}
];

export async function mount(root) {
    const {Celebrations} = await import(CELEBRATE);
    const known = Celebrations.getThemes();

    const theme = M.observable('auto');
    const intensity = M.observable('medium');
    const running = M.observable(false);
    const showing = M.observable('None');
    const particles = M.observable(0);
    const today = M.observable(D().format('dddd D MMMM YYYY'));
    const traits = M.observable([]);
    let chosenTraits = {};       // kept across celebrations: each theme reads only its own names
    let effect = null;

    const nameOf = (id) => (id && known[id] ? `${known[id].emoji} ${known[id].displayName}` : 'None');
    const refresh = () => {
        showing.value = nameOf(theme.value === 'auto' ? Celebrations.getCurrentTheme() : theme.value);
        particles.value = effect?.particles ? effect.particles.length + (effect.specialParticles?.length || 0) : 0;
        const list = effect ? effect.getTraits() : {};
        traits.value = Object.entries(list).map(([name, t]) => ({name, label: t.label, enabled: !!t.enabled}));
    };

    async function load() {
        effect?.destroy();
        effect = new Celebrations({theme: theme.value, intensity: intensity.value, enabled: false,
            traits: chosenTraits, respectMotionPreference: false});
        await effect.init();
        if (running.value) effect.start();
        refresh();
    }

    const state = {
        theme, intensity, running, showing, particles, today, traits, themes: THEMES,
        async themeChanged() {
            if (effect) await effect.setTheme(theme.value); else await load();
            refresh();
        },
        setIntensity(level) {
            intensity.value = level;
            effect?.setIntensity(level);
        },
        toggle() {
            if (!effect) return;
            running.value = !running.value;
            if (running.value) effect.start();
            else { effect.pause(); effect.canvasManager?.clear(); }
            refresh();
        },
        async show(card) {
            theme.value = card.id;
            await state.themeChanged();
            if (!running.value) state.toggle();
        },
        setTrait(trait, event) {
            chosenTraits[trait.name] = event.target.checked;
            effect?.setTrait(trait.name, event.target.checked);
            refresh();
        },
        allTraits(on) {
            if (!effect) return;
            const settings = Object.fromEntries(Object.keys(effect.getTraits()).map(n => [n, on]));
            chosenTraits = {...chosenTraits, ...settings};
            effect.setTraits(settings);
            refresh();
        }
    };

    const bindings = M.applyBindings(state, root);
    await load();
    const ticker = setInterval(() => {
        particles.value = effect?.particles ? effect.particles.length + (effect.specialParticles?.length || 0) : 0;
    }, 1000);

    return () => {
        clearInterval(ticker);
        effect?.destroy();
        bindings?.dispose?.();
    };
}
.celebrate-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; margin-bottom: 1rem; }
.celebrate-control { display: flex; align-items: center; gap: .5rem; margin: 0; }
.celebrate-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: .75rem; margin-bottom: 1rem; }
.celebrate-stat { display: flex; align-items: center; gap: .75rem; padding: .75rem 1rem; border: 1px solid var(--dm-border); border-radius: 10px; background: var(--dm-surface-raised); }
.celebrate-stat small { display: block; color: var(--dm-text-muted); }
.celebrate-traits-card { margin-bottom: 1rem; }
.celebrate-traits { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: .5rem; }
.celebrate-traits label { display: flex; align-items: center; gap: .5rem; cursor: pointer; }
.celebrate-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: .75rem; }
.celebrate-card { display: flex; flex-direction: column; align-items: center; gap: .25rem; padding: 1rem; border: 1px solid var(--dm-border); border-radius: 12px; background: var(--dm-surface-raised); color: var(--dm-text); text-align: center; cursor: pointer; transition: transform .2s ease, border-color .2s ease; }
.celebrate-card:hover { transform: translateY(-2px); border-color: var(--dm-primary); }
.celebrate-card.active { border-color: var(--dm-primary); box-shadow: 0 0 0 2px color-mix(in oklab, var(--dm-primary), transparent 60%); }
.celebrate-emoji { font-size: 2rem; line-height: 1; }
.celebrate-dates { color: var(--dm-text-secondary); }
.celebrate-draws { color: var(--dm-text-muted); font-size: .75rem; }

The package

Celebrations are their own package, domma-celebrate, so a site with no Domma in it can use them. Domma ships the build at dist/celebrate/.

Manual control

import {Celebrations} from './dist/celebrate/domma-celebrate.esm.js';

const celebrations = new Celebrations({
    theme: 'christmas',     // or 'auto' for today's, if any
    intensity: 'medium',    // 'light', 'medium' or 'heavy'
    enabled: true
});

// false when there is nothing to show - no celebration in season, or a visitor
// who asked for reduced motion. It never throws.
await celebrations.init();

await celebrations.setTheme('halloween');
celebrations.setIntensity('heavy');
celebrations.pause();
celebrations.start();
celebrations.destroy();

Turning individual traits off

A theme is not one switch. You may want the snow and the trees but not a steam train crossing your header:

const celebrations = new Celebrations({
    theme: 'christmas',
    traits: {
        train: false,   // no steam train
        elf: false,     // no elves
        tree: 0.5,      // half as many trees
        firework: 2     // twice as many fireworks
    }
});

celebrations.setTrait('sleigh', false);
celebrations.setTraits({robin: false, wreath: 0.5});
celebrations.getTraits();   // what this theme can switch

false is off and a number is a density multiplier - 0 is off, 1 is untouched, 2 is twice as many. The panel in the preview is built from getTraits().

The celebrations

Theme Draws Dates
christmas Snowflakes, Santa's sleigh, reindeer, trees, wreaths, robins, toy trains, elves 1 Dec - 1 Jan
halloween Pumpkins, bats, ghosts, witches, gravestones, spiders 26-31 Oct
valentines Hearts, rose petals, cupid, love letters 9-14 Feb
guy-fawkes Fireworks, bonfires, rockets, Catherine wheels, Roman candles 1-5 Nov
st-patricks Shamrocks, gold coins, leprechauns, pots of gold, rainbows 12-17 Mar
st-andrews Thistles, the saltire, a bagpiper, heather 25-30 Nov
st-davids Daffodils, leeks, the Welsh dragon, flags 24 Feb - 1 Mar
st-georges Rose petals, knights, dragons, the St George flag 18-23 Apr

Intensity

  • light - fewer particles, subtle, easiest on the processor
  • medium - balanced; the default
  • heavy - every particle and decoration

Without Domma

The same package works on any site from one script tag - it mounts the canvas and a control disc of its own, and remembers whether the visitor turned it off:

<script src="domma-celebrate.min.js" data-celebrate></script>

npm install domma-celebrate.

Automatic detection

With theme: 'auto' it reads today's date, finds the celebration in season and starts it - or does nothing (getCurrentTheme() is null) when there is none.