component Confetti { outputs { start(payload: { sourceEvent?: Event; duration: number }) complete(payload: { sourceEvent?: Event; duration: number }) } props { label: string = "Celebrate" duration: number = 1200 count: number = 80 autoStart: boolean = false disabled: boolean = false size: string = "default" color: string = "primary" class: string = "" } state running = false functions { shared function pieces() { return Array.from({ length: Math.max(1, Math.min(200, count)) }) } shared function pieceStyle(index) { return "--wrn-confetti-index:" + index + ";--wrn-confetti-x:" + ((index * 37) % 100) + ";--wrn-confetti-delay:" + 0 + "ms;" } client function animateFrame(canvas, particles, started) { var context = canvas.getContext("2d") var progress = (Date.now() - started) / Math.max(1, duration) context.clearRect(0, 0, 720, 420) if (progress >= 1 || !canvas.isConnected) { return } particles.forEach(function (piece) { piece.vx = piece.vx * 0.99 piece.vy = piece.vy + 0.19 piece.x = piece.x + piece.vx piece.y = piece.y + piece.vy piece.rotation = piece.rotation + piece.spin context.save() context.translate(piece.x, piece.y) context.rotate(piece.rotation) context.globalAlpha = Math.min(1, (1 - progress) * 4) context.fillStyle = piece.color context.fillRect(-3, -4, 6, 8 * Math.cos(piece.rotation)) context.restore() }) setTimeout(animateFrame, 16, canvas, particles, started) } client function animateBurst(sourceEvent) { var root = sourceEvent.currentTarget.closest(".wrn-confetti") var canvas = root.querySelector("canvas") if (!canvas || window.matchMedia("(prefers-reduced-motion: reduce)").matches) { return } var context = canvas.getContext("2d") if (!context) { return } canvas.width = 720 canvas.height = 420 var particles = Array.from({length:Math.min(200, Math.max(1, count))}, function (_, index) { var angle = (-155 + Math.random() * 130) * Math.PI / 180 var speed = 5 + Math.random() * 9 return {x:360,y:273,vx:Math.cos(angle) * speed,vy:Math.sin(angle) * speed,rotation:Math.random() * 6,spin:(Math.random() - 0.5) * 0.3,color:["#8b5cf6","#14b8a6","#f59e0b","#f472b6","#60a5fa"][index % 5]} }) animateFrame(canvas, particles, Date.now()) } client function finish(sourceEvent) { running = false output.complete({ sourceEvent: sourceEvent, duration: duration }) } client function start(sourceEvent) { if (disabled || running) { return } running = true if (sourceEvent) { animateBurst(sourceEvent) } output.start({ sourceEvent: sourceEvent, duration: duration }) setTimeout(finish, duration, sourceEvent) } } lifecycle { mount { if (autoStart) { start(null) } } } view {
} style { .wrn-confetti__canvas { position:absolute; width:720px; height:420px; left:50%; top:50%; transform:translate(-50%,-65%); pointer-events:none; z-index:5; } .wrn-confetti__burst { display:none; } .wrn-confetti[data-autostart="true"] .wrn-confetti__burst { display:block; } .wrn-confetti { position: relative; display: inline-flex; } .wrn-confetti__trigger { display: inline-flex; min-height: 2.5rem; align-items: center; gap: 0.5rem; padding: 0.55rem 0.85rem; border: 1px solid var(--wrn-color-primary); border-radius: var(--wrn-radius); color: var(--wrn-color-on-primary); background: var(--wrn-color-primary); font: inherit; font-weight: 700; cursor: pointer; } .wrn-confetti__trigger > span:first-child { width: 1rem; height: 1rem; } .wrn-confetti__trigger:focus-visible { outline: 2px solid var(--wrn-color-focus); outline-offset: 2px; } .wrn-confetti__trigger:disabled { cursor: not-allowed; opacity: 0.55; } .wrn-confetti__burst { position: absolute; inset: 50% 0 auto; z-index:10; height: 0; pointer-events: none; } .wrn-confetti__burst > i { position: absolute; left:50%; width: 0.45rem; height: 0.7rem; border-radius: 0.1rem; opacity: 0; background: hsl(calc(var(--wrn-confetti-index) * 47deg) 80% 55%); transform: translate(-50%, 0) rotate(calc(var(--wrn-confetti-index) * 23deg)); } .wrn-confetti[data-running="true"] .wrn-confetti__burst > i { animation: wrn-confetti-burst var(--wrn-confetti-duration) linear var(--wrn-confetti-delay) both; } @keyframes wrn-confetti-burst { 0% { opacity: 1; transform: translate(-50%, 0) rotate(0); } 30% { opacity: 1; transform: translate(calc(-50% + (var(--wrn-confetti-x) - 50) * 6px), calc(-5rem - var(--wrn-confetti-x) * 1.2px)) rotate(180deg); } 100% { opacity: 0; transform: translate(calc(-50% + (var(--wrn-confetti-x) - 50) * 9px), 14rem) rotate(540deg); } } @media (prefers-reduced-motion: reduce) { .wrn-confetti[data-running="true"] .wrn-confetti__burst > i { animation-duration: 1ms; animation-delay: 0ms; } } } }