Utilities

Confetti

A burst of confetti over the page on a canvas, fired by a button, on mount, or by confetti_fire().

#utility#animation#button#interactive

Demo

Preview

Press Celebrate.

Get it

It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/confetti.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/confetti.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/confetti.qwe
component ConfettiDemoLive() {    state bursts = 0    <div.demo-stack>        <div.demo-row>            <Confetti trigger="🎉 Celebrate" on_done={() => { bursts += 1 }}/>            <Confetti from="sides" count={160}/>            <Button id="confetti-demo-fire" variant="outline" on_click={() => { confetti_fire() }}>Fire from both sides too</Button>        </div>        <p.demo-note>{bursts == 0 ? "Press Celebrate." : `Bursts over: ${bursts}.`}</p>    </div>}view ConfettiDemo() =    <div.demo-stack>        <ConfettiDemoLive/>    </div>

Source

ui/confetti.qwe
// Confetti: a burst of confetti over the page, drawn on a canvas, for a// moment worth it: an order placed, a goal reached. Fired by its own button, on// mount, or by confetti_fire() from anywhere on the page.////   <Confetti trigger="Celebrate"/>//   <Confetti auto={true} count={200}/>                    once, when it is put on the page//   <Confetti/>  ...  confetti_fire()                      from any component's handler//// trigger: the text of a button that fires it (none by default). auto: fires// once when it first shows. count: how many pieces (120). colors: CSS colours// to draw with (by default the theme's accent, success, warning, danger and// info). spread: how wide the burst opens, in degrees (70). from: "center"// (default, from the bottom middle of the window) or "sides" (from both bottom// corners). duration: seconds before the last piece is gone (3). It covers the// window and lets clicks through. With reduced motion it fires nothing, only// on_done. on_done(): inside one of your components, called with nothing when// the burst is over. confetti_fire() fires every Confetti on the page.import ui/theme.qwe use ui_clslet confetti_css = css`.ui-confetti { display: contents; }.ui-confetti-canvas { position: fixed; inset: 0; z-index: 90; width: 100vw; height: 100vh; pointer-events: none; }.ui-confetti-canvas[hidden] { display: none; }.ui-confetti-btn { display: inline-flex; align-items: center; gap: 8px; height: 38px; padding: 0 16px; border: 0; border-radius: var(--ui-radius-sm);  background: var(--ui-accent); color: var(--ui-accent-fg); font: 500 14px/1 var(--ui-font); cursor: pointer; }.ui-confetti-btn:hover { background: color-mix(in srgb, var(--ui-accent) 88%, var(--ui-fg)); }.ui-confetti-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }`// Fires every Confetti on the page.fn confetti_fire() {    js("document.dispatchEvent", js_new("CustomEvent", "ui-confetti"))}// The pieces, a moment later: moved by their speed, slowed by the air, pulled down, turned.fn confetti_step(ps: list, dt: num) -> list =    ps | map((p) => { ...p, x: p.x + p.vx * dt, y: p.y + p.vy * dt, vx: p.vx * (1 - 0.9 * dt), vy: p.vy * (1 - 0.9 * dt) + 900 * dt,                      turn: p.turn + p.spin * dt, age: p.age + dt })component Confetti(trigger: str? = null, auto: bool = false, count: int = 120, colors: list? = null, spread: int = 70,                   from: str = "center", duration: num = 3, on_done = null, class: str? = null) {    style global confetti_css    state running = false    state canvas = null    let finish = () => {        running = false        on_done && on_done()    }    state draw = null       // the frame step, kept so that it can ask for itself again    // Draws the pieces, then asks for the next frame until they are all gone.    let frame = (ps: list, last: num) => {        let ctx = canvas.getContext("2d")        let t = now()        let dt = min([0.05, (t - last) / 1000])        let next = confetti_step(ps, dt) | filter((p) => p.age < p.life && p.y < canvas.height + 40)        ctx.clearRect(0, 0, canvas.width, canvas.height)        for p in next {            ctx.save()            ctx.globalAlpha = max([0, min([1, (p.life - p.age) * 2])])            ctx.translate(p.x, p.y)            ctx.rotate(p.turn)            ctx.fillStyle = p.color            ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h * abs(math.cos(p.turn * 1.7)))            ctx.restore()        }        if len(next) > 0 {            js("requestAnimationFrame", (_t = null) => { draw(next, t) })        } else {            ctx.clearRect(0, 0, canvas.width, canvas.height)            finish()        }    }    let fire = () => {        if js("matchMedia", "(prefers-reduced-motion: reduce)").matches {            on_done && on_done()            return null        }        let dpr = js("devicePixelRatio") ?? 1        let w = js("innerWidth")        let h = js("innerHeight")        canvas.width = round(w * dpr)        canvas.height = round(h * dpr)        let look = js("getComputedStyle", canvas)        let paints = colors ?? (["--ui-accent", "--ui-success", "--ui-warning", "--ui-danger", "--ui-info"]            | map((v) => trim(look.getPropertyValue(v))) | filter((c) => c != ""))        let pal = len(paints) > 0 ? paints : ["#5b4bdb", "#18794e", "#a35200", "#c62a2f", "#0b6bcb"]        let pieces = range(count) | map((i) => {            let left_side = from == "sides" && i % 2 == 0            let ox = from == "sides" ? (left_side ? 0 : w) : w / 2            let aim = from == "sides" ? (left_side ? -60 : -120) : -90            let a = (aim + (random() - 0.5) * spread) * math.pi / 180            let speed = (900 + random() * 700) * dpr * (h / 900 + 0.3)            {                x: ox * dpr, y: h * dpr, vx: math.cos(a) * speed, vy: math.sin(a) * speed,                w: (6 + random() * 6) * dpr, h: (8 + random() * 8) * dpr, turn: random() * 6.3, spin: (random() - 0.5) * 12,                color: pal[i % len(pal)], age: 0, life: duration * (0.6 + random() * 0.4)            }        })        running = true        draw = frame        frame(pieces, now())    }    <div class={ui_cls(["ui-confetti", class])} @mount={(_el) => {        let hear = (_e = null) => { if canvas != null { fire() } }        js("document.addEventListener", "ui-confetti", hear)        () => js("document.removeEventListener", "ui-confetti", hear)    }}>        {trigger != null && <button.ui-confetti-btn type="button" @click={() => { if !running { fire() } }}>{trigger}</button>}        <canvas.ui-confetti-canvas aria-hidden="true" hidden={!running}                @mount={(el) => {                    canvas = el                    if auto { fire() }                }}></canvas>    </div>}
Thread
610Useful? Like it, or add to its thread below.

Thread 0 posts

On Confetti: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.