/** * -------------------------------------------------------------------------- * Tabler confetti.ts * Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE) * -------------------------------------------------------------------------- */ import BaseComponent from './bootstrap/base-component' import EventHandler from './bootstrap/dom/event-handler' import Manipulator from './bootstrap/dom/manipulator' import SelectorEngine from './bootstrap/dom/selector-engine' import { isDisabled } from './bootstrap/util/index' import type { ComponentConfig as BaseConfig, ElementSelector } from './bootstrap/types' type ComponentConfig = { /** how many pieces one burst pours out */ count: number /** ms during which pieces keep pouring; the first frames get the most */ duration: number /** how fast the pour thins out; higher means a sharper splash at the start */ decay: number /** share of the viewport height at the bottom where pieces fade out */ fade: number /** multiplier for the fall speed */ speed: number /** piece colours; `null` reads the Tabler palette from CSS custom properties */ colors: string[] | null } type ComponentConfigInput = Partial> & { colors?: string[] | string | null } type Particle = { emitter: Emitter x: number y: number w: number h: number color: string vx: number vy: number speed: number angle: number spin: number flip: number flipSpeed: number sway: number alpha: number } type Emitter = { element: HTMLElement config: ComponentConfig colors: string[] startedAt: number emitted: number /** `stop()` was called: nothing new is poured, what is in the air still falls */ stopped: boolean /** every piece has landed and `end` was fired */ done: boolean /** the instance was disposed: the pieces still land, but `end` is not fired */ detached: boolean } /** * Constants */ const NAME = 'confetti' const DATA_KEY = `bs.${NAME}` const EVENT_KEY = `.${DATA_KEY}` const EVENT_START = `start${EVENT_KEY}` const EVENT_END = `end${EVENT_KEY}` const EVENT_CLICK_DATA_API = `click${EVENT_KEY}.data-api` const CLASS_NAME_CANVAS = `${NAME}-canvas` const SELECTOR_DATA_TOGGLE = `[data-bs-toggle="${NAME}"], [data-tblr-toggle="${NAME}"]` // Palette names looked up as `--tblr-` on the root element; the hex // values are the fallback for a page that loads the script without the CSS. const PALETTE: [string, string][] = [ ['blue', '#066fd1'], ['azure', '#4299e1'], ['indigo', '#4263eb'], ['purple', '#ae3ec9'], ['pink', '#d6336c'], ['red', '#d63939'], ['orange', '#f76707'], ['yellow', '#f59f00'], ['lime', '#74b816'], ['green', '#2fb344'], ['teal', '#0ca678'], ['cyan', '#17a2b8'], ] // The physics below is tuned for this viewport height; taller windows get // proportionally faster pieces so the fall takes the same time everywhere. const REFERENCE_HEIGHT = 800 const FRAME_MS = 1000 / 60 const MAX_DPR = 2 const Default: ComponentConfig = { count: 220, duration: 3500, decay: 3.5, fade: 0.15, speed: 1, colors: null, } const DefaultType: Record = { count: 'number', duration: 'number', decay: 'number', fade: 'number', speed: 'number', colors: '(array|string|null)', } /** * Stage * * One fixed canvas shared by every burst on the page. It is created on the * first burst and removed once the last piece has landed, so an idle page * carries no extra element and no running animation frame. */ const stage = { canvas: null as HTMLCanvasElement | null, context: null as CanvasRenderingContext2D | null, particles: [] as Particle[], emitters: [] as Emitter[], frame: 0, lastTick: 0, add(emitter: Emitter): boolean { this._mount() if (!this.context) { return false } this.emitters.push(emitter) if (!this.frame) { this.lastTick = performance.now() this.frame = requestAnimationFrame((now) => this._tick(now)) } return true }, _mount(): void { if (this.canvas) { return } const canvas = document.createElement('canvas') canvas.className = CLASS_NAME_CANVAS canvas.setAttribute('aria-hidden', 'true') Object.assign(canvas.style, { position: 'fixed', inset: '0', width: '100%', height: '100%', zIndex: '9999', pointerEvents: 'none', display: 'block', }) const context = canvas.getContext('2d') if (!context) { return } document.body.append(canvas) this.canvas = canvas this.context = context this._resize() window.addEventListener('resize', this._onResize) }, _unmount(): void { cancelAnimationFrame(this.frame) this.frame = 0 window.removeEventListener('resize', this._onResize) this.canvas?.remove() this.canvas = null this.context = null this.particles = [] this.emitters = [] }, _onResize: (): void => { stage._resize() }, _resize(): void { if (!this.canvas || !this.context) { return } // The backing store is scaled for HiDPI screens; the CSS size above keeps // the element at the viewport size, so the drawing stays sharp and 1:1. const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR) this.canvas.width = document.documentElement.clientWidth * dpr this.canvas.height = window.innerHeight * dpr this.context.setTransform(dpr, 0, 0, dpr, 0, 0) }, _spawn(emitter: Emitter): Particle { const { colors, config } = emitter const speed = (window.innerHeight / REFERENCE_HEIGHT) * config.speed return { emitter, x: Math.random() * window.innerWidth, y: -20 - Math.random() * 30, w: 6 + Math.random() * 3.6, h: 10 + Math.random() * 4.4, color: colors[Math.floor(Math.random() * colors.length)] ?? '#000', vx: (Math.random() - 0.5) * 1.2, vy: (1.5 + Math.random() * 2.1) * speed, speed, angle: Math.random() * Math.PI * 2, spin: (Math.random() - 0.5) * 0.25, flip: Math.random() * Math.PI * 2, flipSpeed: 0.12 + Math.random() * 0.15, sway: Math.random() * Math.PI * 2, alpha: 1, } }, // Pour: the share of pieces emitted by time t follows 1 - e^(-k t), so the // first frames get the most and the tail thins out to single pieces. _pour(emitter: Emitter, now: number): void { const { count, duration, decay } = emitter.config const elapsed = now - emitter.startedAt if (emitter.stopped || elapsed >= duration) { return } const t = elapsed / duration const target = Math.round((count * (1 - Math.exp(-decay * t))) / (1 - Math.exp(-decay))) while (emitter.emitted < target) { this.particles.push(this._spawn(emitter)) emitter.emitted++ } }, _tick(now: number): void { const context = this.context if (!context) { return } // Steps are scaled to the real frame time so a 120 Hz screen or a // throttled tab animate at the same pace as 60 Hz. const step = Math.min((now - this.lastTick) / FRAME_MS, 3) this.lastTick = now const width = window.innerWidth const height = window.innerHeight context.clearRect(0, 0, width, height) for (const emitter of this.emitters) { this._pour(emitter, now) } for (let i = this.particles.length - 1; i >= 0; i--) { const p = this.particles[i] const fadeFrom = height * (1 - p.emitter.config.fade) p.sway += 0.05 * step p.vy = Math.min(p.vy + 0.025 * p.speed * step, 4.2 * p.speed) p.x += (p.vx + Math.sin(p.sway) * 0.7) * step p.y += p.vy * step p.angle += p.spin * step p.flip += p.flipSpeed * step if (p.y > fadeFrom) { p.alpha = Math.max(0, 1 - (p.y - fadeFrom) / (height - fadeFrom)) } if (p.alpha === 0) { this.particles.splice(i, 1) continue } context.save() context.globalAlpha = p.alpha context.translate(p.x, p.y) context.rotate(p.angle) // The flip squashes the piece along one axis, which reads as tumbling. context.scale(1, Math.cos(p.flip)) context.fillStyle = p.color context.fillRect(-p.w / 2, -p.h / 2, p.w, p.h) context.restore() } this._settle(now) if (this.emitters.length === 0) { this._unmount() return } this.frame = requestAnimationFrame((next) => this._tick(next)) }, // An emitter is finished once it pours nothing more and none of its pieces // is still in the air; `end` fires on its element and it leaves the stage. _settle(now: number): void { for (let i = this.emitters.length - 1; i >= 0; i--) { const emitter = this.emitters[i] const pouring = !emitter.stopped && now - emitter.startedAt < emitter.config.duration const airborne = this.particles.some((p) => p.emitter === emitter) if (pouring || airborne) { continue } this.emitters.splice(i, 1) emitter.done = true if (!emitter.detached) { EventHandler.trigger(emitter.element, EVENT_END) } } }, } /** * Class definition * * Pours a short shower of confetti over the page, like a bucket emptied from * the top edge: dense at first, then thinning out. Nothing is drawn when the * user prefers reduced motion, but the events still fire. */ class Confetti extends BaseComponent { declare _element: HTMLElement declare _config: ComponentConfig _emitter: Emitter | null = null constructor(element: ElementSelector, config?: ComponentConfigInput) { super(element, config) } // Getters static get Default(): ComponentConfig { return Default } static get DefaultType(): Record { return DefaultType } static get NAME(): string { return NAME } // Public burst(): void { const startEvent = EventHandler.trigger(this._element, EVENT_START) if (startEvent?.defaultPrevented) { return } // A second burst while one is pouring restarts the pour; pieces already // in the air keep falling. this.stop() if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { EventHandler.trigger(this._element, EVENT_END) return } this._emitter = { element: this._element, config: this._config, colors: this._colors(), startedAt: performance.now(), emitted: 0, stopped: false, done: false, detached: false, } if (!stage.add(this._emitter)) { this._emitter = null EventHandler.trigger(this._element, EVENT_END) } } stop(): void { if (this._emitter && !this._emitter.done) { this._emitter.stopped = true } this._emitter = null } dispose(): void { if (this._emitter) { this._emitter.detached = true } this.stop() super.dispose() } // Private _configAfterMerge(config: BaseConfig): BaseConfig { if (typeof config.colors === 'string') { config.colors = config.colors .split(',') .map((color) => color.trim()) .filter(Boolean) } return config } _colors(): string[] { const { colors } = this._config if (colors && colors.length > 0) { return colors } // The palette is read at burst time, so a theme switched at runtime is // picked up without re-creating the instance. const style = getComputedStyle(document.documentElement) return PALETTE.map(([name, fallback]) => style.getPropertyValue(`--tblr-${name}`).trim() || fallback) } } /** * Data API implementation * * A trigger bursts on its `data-bs-target` when it has one, otherwise on * itself, so the events fire where the page can listen for them. */ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (this: HTMLElement, event: Event) { if (['A', 'AREA'].includes(this.tagName)) { event.preventDefault() } if (isDisabled(this)) { return } const target = SelectorEngine.getElementFromSelector(this) || this const config = Manipulator.getDataAttributes(this) const instance = Confetti.getOrCreateInstance(target, config) as Confetti // The instance lives on the target and keeps the config it was created // with, so re-read the trigger's options on every click; several triggers // can then share one target with their own look. if (target !== this) { instance._config = instance._getConfig(config) as ComponentConfig } instance.burst() }) export default Confetti