diff --git a/.changeset/confetti.md b/.changeset/confetti.md new file mode 100644 index 000000000..1a0b3b726 --- /dev/null +++ b/.changeset/confetti.md @@ -0,0 +1,7 @@ +--- +"@tabler/core": minor +"@tabler/preview": minor +"@tabler/docs": minor +--- + +Added a `Confetti` component with `data-bs-toggle="confetti"`, a demo page and docs. diff --git a/core/js/src/confetti.ts b/core/js/src/confetti.ts new file mode 100644 index 000000000..6da6056f7 --- /dev/null +++ b/core/js/src/confetti.ts @@ -0,0 +1,439 @@ +/** + * -------------------------------------------------------------------------- + * 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 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 +} + +/** + * 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): void { + this.emitters.push(emitter) + this._mount() + + if (!this.frame) { + this.lastTick = performance.now() + this.frame = requestAnimationFrame((now) => this._tick(now)) + } + }, + + _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', + }) + + document.body.append(canvas) + this.canvas = canvas + this.context = canvas.getContext('2d') + 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 = window.innerWidth * 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 + 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 as BaseConfig | undefined) + } + + // 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, + } + + stage.add(this._emitter) + } + + stop(): void { + if (this._emitter && !this._emitter.done) { + this._emitter.stopped = true + } + + this._emitter = null + } + + dispose(): void { + 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 instance = Confetti.getOrCreateInstance(target) as Confetti + instance.burst() +}) + +export default Confetti diff --git a/core/js/tabler.ts b/core/js/tabler.ts index 48ec34c79..50f8012ab 100644 --- a/core/js/tabler.ts +++ b/core/js/tabler.ts @@ -1,4 +1,5 @@ import './src/autosize' +import './src/confetti' import './src/countup' import './src/input-mask' import './src/dropdown' @@ -17,6 +18,7 @@ export * from './src/bootstrap' // Tabler's own components export { default as Autosize } from './src/autosize' +export { default as Confetti } from './src/confetti' export { default as CountUp } from './src/countup' export { default as InputMask } from './src/input-mask' export { default as Sortable } from './src/sortable' diff --git a/core/js/tests/unit/confetti.spec.ts b/core/js/tests/unit/confetti.spec.ts new file mode 100644 index 000000000..61d4e87d1 --- /dev/null +++ b/core/js/tests/unit/confetti.spec.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest' +import { clearFixture, getFixture } from '../helpers/fixture' +import Confetti from '../../src/confetti' + +describe('Confetti', () => { + let fixtureEl: HTMLElement + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + // Let every burst land so one test's canvas never leaks into the next. + for (const element of fixtureEl.querySelectorAll('button, div')) { + Confetti.getInstance(element)?.dispose() + } + + clearFixture() + vi.restoreAllMocks() + return vi.waitFor(() => expect(canvas()).toBeNull()) + }) + + const button = (): HTMLElement => fixtureEl.querySelector('button')! + const canvas = (): HTMLCanvasElement | null => document.querySelector('.confetti-canvas') + + // A short pour with fast pieces: the whole shower is over well within a second. + const QUICK = { count: 5, duration: 20, speed: 60 } + + const reduceMotion = (matches: boolean): void => { + vi.spyOn(window, 'matchMedia').mockImplementation((query: string) => ({ matches, media: query }) as MediaQueryList) + } + + describe('NAME', () => { + it('should return plugin name', () => { + expect(Confetti.NAME).toBe('confetti') + }) + }) + + describe('Default', () => { + it('should expose the defaults and their types', () => { + expect(Confetti.Default.count).toBe(220) + expect(Confetti.Default.colors).toBeNull() + expect(Confetti.DefaultType.colors).toBe('(array|string|null)') + }) + }) + + describe('burst', () => { + it('should mount one shared canvas and remove it after the pieces land', async () => { + fixtureEl.innerHTML = '' + reduceMotion(false) + + const instance = new Confetti(button(), QUICK) + instance.burst() + + const mounted = canvas()! + expect(mounted).not.toBeNull() + expect(mounted.getAttribute('aria-hidden')).toBe('true') + expect(mounted.style.pointerEvents).toBe('none') + + instance.burst() + expect(document.querySelectorAll('.confetti-canvas')).toHaveLength(1) + + await vi.waitFor(() => expect(canvas()).toBeNull(), { timeout: 3000 }) + }) + + it('should fire start and end events', async () => { + fixtureEl.innerHTML = '' + reduceMotion(false) + + const start = vi.fn() + const end = vi.fn() + button().addEventListener('start.bs.confetti', start) + button().addEventListener('end.bs.confetti', end) + + new Confetti(button(), QUICK).burst() + expect(start).toHaveBeenCalledTimes(1) + expect(end).not.toHaveBeenCalled() + + await vi.waitFor(() => expect(end).toHaveBeenCalledTimes(1), { timeout: 3000 }) + }) + + it('should not start when the start event is prevented', () => { + fixtureEl.innerHTML = '' + reduceMotion(false) + + button().addEventListener('start.bs.confetti', (event) => event.preventDefault()) + new Confetti(button(), QUICK).burst() + + expect(canvas()).toBeNull() + }) + + it('should skip the animation but still end when the user prefers reduced motion', () => { + fixtureEl.innerHTML = '' + reduceMotion(true) + + const end = vi.fn() + button().addEventListener('end.bs.confetti', end) + new Confetti(button(), QUICK).burst() + + expect(canvas()).toBeNull() + expect(end).toHaveBeenCalledTimes(1) + }) + }) + + describe('stop', () => { + it('should stop pouring and let the pieces in the air land', async () => { + fixtureEl.innerHTML = '' + reduceMotion(false) + + const end = vi.fn() + button().addEventListener('end.bs.confetti', end) + + const instance = new Confetti(button(), { count: 50, duration: 60_000, speed: 60 }) + instance.burst() + expect(canvas()).not.toBeNull() + + instance.stop() + await vi.waitFor(() => expect(end).toHaveBeenCalledTimes(1), { timeout: 3000 }) + expect(canvas()).toBeNull() + }) + }) + + describe('colors', () => { + it('should accept a comma separated list from a data attribute', () => { + fixtureEl.innerHTML = '' + + const instance = new Confetti(button()) + expect(instance._config.colors).toEqual(['#f00', '#0f0', '#00f']) + }) + + it('should read the Tabler palette from custom properties by default', () => { + fixtureEl.innerHTML = '' + document.documentElement.style.setProperty('--tblr-blue', 'rgb(1, 2, 3)') + + const colors = new Confetti(button())._colors() + document.documentElement.style.removeProperty('--tblr-blue') + + expect(colors).toHaveLength(12) + expect(colors[0]).toBe('rgb(1, 2, 3)') + // No CSS is loaded in the test page: the other names fall back to hex. + expect(colors[1]).toBe('#4299e1') + }) + }) + + describe('data-api', () => { + it('should burst on click without instantiating manually', async () => { + fixtureEl.innerHTML = '' + reduceMotion(false) + + button().click() + + const instance = Confetti.getInstance(button()) as Confetti + expect(instance).not.toBeNull() + expect(instance._config.count).toBe(5) + expect(canvas()).not.toBeNull() + }) + + it('should burst on the target of a data-tblr-toggle trigger and prevent the default on a link', () => { + fixtureEl.innerHTML = '
' + reduceMotion(true) + + const start = vi.fn() + const target = fixtureEl.querySelector('#party')! + target.addEventListener('start.bs.confetti', start) + + const event = new MouseEvent('click', { bubbles: true, cancelable: true }) + fixtureEl.querySelector('a')!.dispatchEvent(event) + + expect(event.defaultPrevented).toBe(true) + expect(start).toHaveBeenCalledTimes(1) + expect(Confetti.getInstance(fixtureEl.querySelector('a')!)).toBeNull() + }) + + it('should ignore a disabled trigger', () => { + fixtureEl.innerHTML = '' + reduceMotion(true) + + const start = vi.fn() + button().addEventListener('start.bs.confetti', start) + button().click() + + expect(start).not.toHaveBeenCalled() + }) + }) +}) diff --git a/docs/content/ui/components/confetti.mdx b/docs/content/ui/components/confetti.mdx new file mode 100644 index 000000000..4eb60c0ed --- /dev/null +++ b/docs/content/ui/components/confetti.mdx @@ -0,0 +1,121 @@ +--- +title: Confetti +summary: Confetti pours a short shower of colored pieces over the page. Use it once, for a moment worth celebrating, like a finished setup, a first order or a signup. +description: Celebrate a success with a confetti shower triggered from a button or from JavaScript, with the amount, length, speed and colors set by data attributes. +added-in: '1.6.0' +related: [/ui/components/toast, /ui/components/modal, /ui/components/empty] +source: core/js/src/confetti.ts +--- +import Example from '@components/Example.astro'; + +## Overview + +Confetti is a small reward for the user. The pieces pour from the top edge like a bucket emptied over the page, dense at first and thinning out, then fall, tumble and fade out near the bottom. The whole shower takes a few seconds and clears by itself. + +The pieces are drawn on one canvas that covers the page and ignores the mouse, so nothing under it stops working. The canvas is added on the first burst and removed once the last piece has landed. + + + + + +## Usage + +Add `data-bs-toggle="confetti"` to a button or a link. A click pours one shower. A second click while the first one is still pouring starts over; the pieces already in the air keep falling. + + + +Celebrate + + +Use confetti for a moment, not for a page. One burst when a setup is finished or an order is placed feels earned; a burst on every click does not. Prefer one trigger per screen, and never pour on page load without a reason the user can see. + +### Amount and length + +Set `data-bs-count` for the number of pieces and `data-bs-duration` for how long they keep pouring, in milliseconds. The pour is always dense at the start and thins out towards the end, whatever its length. + + + + + + +### Colors + +By default the pieces use the Tabler palette, read from the `--tblr-blue`, `--tblr-red` and the other color custom properties at the time of the burst, so a changed theme is picked up. Pass a comma-separated list in `data-bs-colors` to use your own colors. + + + + + + +### Speed + +Set `data-bs-speed` to scale how fast the pieces fall. `1` is the default. A lower value floats, a higher one drops. The fall is scaled to the height of the window as well, so it takes about the same time on a phone and on a large screen. + + + + + + +### Target + +Point `data-bs-target` at another element to run the shower from there. The element does not change how the shower looks; it is where the events fire and where the instance is stored, so a page can listen in one place. + + + +Order #1042 + + +## Options + +Every option is a `data-bs-*` attribute on the element, or a key in the config object passed to the constructor. + +| Option | Default | Description | +| --- | --- | --- | +| `count` | `220` | How many pieces one burst pours out. | +| `duration` | `3500` | How long the pieces keep pouring, in milliseconds. The fall adds a few seconds after that. | +| `decay` | `3.5` | How fast the pour thins out. A higher value gives a sharper splash at the start and a longer thin tail. | +| `fade` | `0.15` | The share of the window height at the bottom where the pieces fade out. | +| `speed` | `1` | Multiplier for the fall speed. | +| `colors` | Tabler palette | The colors of the pieces. A comma-separated list or an array of CSS colors. | + +## JavaScript + +Tabler wraps the element in a `Confetti` component that works like the Bootstrap components: it is created once per element and stored on it. The data API creates one for the trigger, or for its `data-bs-target`, on the first click. + +Create the component yourself to burst from code, for example once a form has been saved. Any element can hold it, the body included: + +```js +const confetti = tabler.Confetti.getOrCreateInstance(document.body, { count: 150 }); + +form.addEventListener('submit', async (event) => { + event.preventDefault(); + await save(form); + confetti.burst(); +}); +``` + +| Method | Description | +| --- | --- | +| `burst()` | Pours one shower. Fires `start.bs.confetti` first; cancel it with `preventDefault()` to stop the burst. | +| `stop()` | Stops pouring. The pieces already in the air keep falling. | +| `dispose()` | Stops pouring and removes the component from the element. | +| `getInstance(element)` | Static. Returns the component for the element, or `null`. | +| `getOrCreateInstance(element, config)` | Static. Returns the component for the element and creates it when needed. | + +| Event | Description | +| --- | --- | +| `start.bs.confetti` | Fired on the element before a burst. Cancelable. | +| `end.bs.confetti` | Fired on the element once the last piece of that burst has landed. | + +```js +document.getElementById('order').addEventListener('end.bs.confetti', () => { + window.location.href = '/orders/1042'; +}); +``` + +## Accessibility + +- The canvas is hidden from assistive technology with `aria-hidden` and ignores the mouse, so the page stays usable while the pieces fall. +- The shower is decoration. Say what happened in text as well, in a toast, an alert or a heading, so users who do not see the animation get the same message. +- When the user asks for reduced motion, nothing is drawn. The `start` and `end` events still fire, so code that waits for the end keeps working. +- Keep the shower short and rare. A few seconds after a real success is a reward; frequent or long showers get in the way. diff --git a/preview/pages/confetti.astro b/preview/pages/confetti.astro new file mode 100644 index 000000000..8d7e6437e --- /dev/null +++ b/preview/pages/confetti.astro @@ -0,0 +1,150 @@ +--- +import DefaultLayout from '@shared/layouts/DefaultLayout.astro' +import CaptureScript from '@shared/components/CaptureScript.astro' +import Card from '@ui/Card.astro' +import CardBody from '@ui/CardBody.astro' +import CardTitle from '@ui/CardTitle.astro' +import CardSubtitle from '@ui/CardSubtitle.astro' +import DocsLink from '@ui/DocsLink.astro' +import Button from '@ui/Button.astro' +import ButtonList from '@ui/ButtonList.astro' +import Badge from '@ui/Badge.astro' +--- + + + + + + + + +
+
+ + + Basic + Add data-bs-toggle="confetti" to a button. A click pours the shower; it thins out and clears by itself. +
+ +
+ + + Amount and length + Set data-bs-count for the number of pieces and data-bs-duration for how long they keep pouring, in milliseconds. + +
+ +
+ + + Colors + By default the pieces use the Tabler palette. Pass a comma-separated list in data-bs-colors to use your own. + +
+ +
+ + + Speed + Set data-bs-speed to scale the fall. 1 is the default, a lower value floats, a higher one drops. + +
+ +
+ + + Target and events + Point data-bs-target at another element. start.bs.confetti and end.bs.confetti fire on that element, not on the trigger. +
+
+ + + + + +
+
+
+ +
+ + + From JavaScript + Call burst() on a tabler.Confetti instance when something else finishes, for example a form that was saved. +
+
+ + +
+
+
+
diff --git a/screenshots/pages/confetti.astro b/screenshots/pages/confetti.astro new file mode 100644 index 000000000..4f8073ef5 --- /dev/null +++ b/screenshots/pages/confetti.astro @@ -0,0 +1,102 @@ +--- +// Confetti frozen mid-shower over a success card. The component animates on a +// canvas with requestAnimationFrame, which a screenshot cannot wait for, so the +// inline script below takes over the clock before tabler.js runs: it queues +// animation frames instead of scheduling them, seeds Math.random, bursts once +// the component is loaded and then replays a fixed number of frames by hand. +// The canvas keeps its last frame, and the clock is handed back so the +// layout's ready signal (and a click on the button) still work. +import ScreenshotLayout from '../layouts/ScreenshotLayout.astro' +import Card from '@ui/Card.astro' +import CardBody from '@ui/CardBody.astro' +import Button from '@ui/Button.astro' +--- + + + + +
+ + +
+

Your workspace is ready

+

Invite your team and start your first project.

+