רקע חגיגי לסקשן הסיום או לרגע ההצלחה בטופס. במצב תנועה מופחתת הוא מפסיק לשגר לבד ומגיב רק ללחיצה, כי לחיצה היא בקשה מפורשת של המשתמש. זיקוקים הוא רכיב מתוך רכיבים של כליקס. מה שמורידים הוא רכיב שלם, גם כ-HTML עצמאי וגם כרכיב React. אין ספרייה חיצונית, אין שלב בנייה, ואם כליקס ייעלם מחר הקוד ימשיך לעבוד.
רכיב · קנבס · בלי ספריות · רקע
<!-- זיקוקים — הקנבס נמתח על ההורה, שצריך להיות position:relative -->
<canvas id="klix-fireworks" aria-hidden="true"></canvas>
<style>
#klix-fireworks{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
</style>
<script>
(function () {
var canvas = document.getElementById('klix-fireworks');
if (!canvas) return;
var ctx = canvas.getContext('2d');
if (!ctx) return;
var GRAVITY = 0.055;
var DRAG = 0.985;
var HUES = [46, 40, 210, 32, 50]; // זהב, ענבר, תכלת
var rockets = [];
var sparks = [];
// מתחילים כמעט מיד, כדי שמי שגלל לכאן יראה משהו ולא רקע ריק
var nextLaunch = 0.4;
function launch(w, h, tx, ty) {
var targetY = ty == null ? h * (0.2 + Math.random() * 0.3) : ty;
rockets.push({
x: tx == null ? w * (0.2 + Math.random() * 0.6) : tx,
y: h + 10,
vx: (Math.random() - 0.5) * 1.2,
vy: -((h - targetY) * 0.035 + 4),
targetY: targetY,
hue: HUES[(Math.random() * HUES.length) | 0]
});
}
function burst(x, y, hue) {
var n = 54 + ((Math.random() * 26) | 0);
for (var i = 0; i < n; i++) {
var ang = (i / n) * Math.PI * 2 + Math.random() * 0.14;
var sp = 1.6 + Math.random() * 4.2;
var max = 55 + Math.random() * 45;
sparks.push({
x: x, y: y,
vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp,
life: max, max: max,
hue: hue + (Math.random() * 16 - 8)
});
}
}
function frame(f) {
var step = Math.min(f.dt * 60, 2);
// עקבות דוהים במקום ניקוי מלא — זה מה שיוצר את הזנב של הניצוצות.
// ערך נמוך משאיר זנב ארוך ורך, ערך גבוה מוחק אותו כמעט מיד.
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = 'rgba(0,0,0,0.10)';
ctx.fillRect(0, 0, f.w, f.h);
ctx.globalCompositeOperation = 'lighter';
// שיגור עצמי רק כשהתנועה דלוקה. לחיצה משגרת תמיד, כי זו תגובה
// ישירה לפעולה של המשתמש.
if (motionEnabled()) {
nextLaunch -= f.dt;
if (nextLaunch <= 0) {
launch(f.w, f.h);
nextLaunch = 1.8 + Math.random() * 2.4;
}
}
for (var i = rockets.length - 1; i >= 0; i--) {
var r = rockets[i];
r.x += r.vx * step;
r.y += r.vy * step;
r.vy += GRAVITY * 1.4 * step;
ctx.fillStyle = 'hsla(' + r.hue + ', 95%, 72%, 0.95)';
ctx.beginPath();
ctx.arc(r.x, r.y, 2.4, 0, Math.PI * 2);
ctx.fill();
if (r.vy >= 0 || r.y <= r.targetY) {
burst(r.x, r.y, r.hue);
rockets.splice(i, 1);
}
}
for (var j = sparks.length - 1; j >= 0; j--) {
var s = sparks[j];
s.x += s.vx * step;
s.y += s.vy * step;
s.vx *= DRAG;
s.vy = s.vy * DRAG + GRAVITY * step;
s.life -= step;
if (s.life <= 0) { sparks.splice(j, 1); continue; }
var k = s.life / s.max;
ctx.fillStyle = 'hsla(' + s.hue + ', 92%, ' + (52 + k * 30) + '%, ' + k + ')';
ctx.beginPath();
ctx.arc(s.x, s.y, 1.1 + k * 1.6, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
}
mountStage(canvas, {
maxDpr: 1.5,
onPointerDown: function (p) { launch(canvas.width, canvas.height, p.x, Math.max(p.y, 40)); },
onFrame: frame
});
// תשתית הקנבס: מדידת גודל, השהיה מחוץ למסך, מעקב אחרי המצביע ושעון dt חסום
function mountStage(canvas, opts) {
var dpr = Math.min(window.devicePixelRatio || 1, opts.maxDpr || 2);
var pointer = { x: 0, y: 0, nx: 0, ny: 0, inside: false };
var w = 0, h = 0, onScreen = true, painted = false, running = true, raf = 0;
// ה-CSS לא בהכרח נטען כשהפונקציה רצה, ואז הקנבס נתקע בגודל 0.
// לכן מודדים בכל פריים ולא רק כשמשנים גודל חלון.
function syncSize() {
var cw = Math.round(canvas.clientWidth * dpr);
var ch = Math.round(canvas.clientHeight * dpr);
if (!cw || !ch || (canvas.width === cw && canvas.height === ch)) return false;
canvas.width = cw; canvas.height = ch; w = cw; h = ch;
if (opts.onResize) opts.onResize(cw, ch, dpr);
return true;
}
function readPointer(e) {
var r = canvas.getBoundingClientRect();
if (!r.width || !r.height) return;
pointer.x = (e.clientX - r.left) * dpr;
pointer.y = (e.clientY - r.top) * dpr;
pointer.nx = (e.clientX - r.left - r.width / 2) / r.height;
pointer.ny = (r.height / 2 - (e.clientY - r.top)) / r.height;
pointer.inside = e.clientX >= r.left && e.clientX <= r.right &&
e.clientY >= r.top && e.clientY <= r.bottom;
if (onScreen) painted = false; // קלט מהמשתמש תמיד מצדיק ציור מחדש
}
function onMove(e) { readPointer(e); }
function onDown(e) {
readPointer(e);
if (pointer.inside && opts.onPointerDown) opts.onPointerDown(pointer);
}
window.addEventListener('pointermove', onMove, { passive: true });
window.addEventListener('pointerdown', onDown, { passive: true });
// רץ רק כשהקנבס על המסך. בלי זה כמה אפקטים בדף אחד מתחרים על המעבד.
var io = new IntersectionObserver(function (entries) { onScreen = entries[0].isIntersecting; });
io.observe(canvas);
var last = performance.now(), start = last;
function loop() {
if (!running) return;
raf = requestAnimationFrame(loop);
if (syncSize()) painted = false;
if (!w || !h) return;
// מחוץ למסך: מציירים פריים אחד כדי שלא יישאר ריק, ואז נעצרים
var idle = document.hidden || !onScreen;
if (idle && painted) return;
var now = performance.now();
var dt = Math.min((now - last) / 1000, 1 / 20);
last = now;
opts.onFrame({ t: (now - start) / 1000, dt: dt, w: w, h: h, dpr: dpr, pointer: pointer });
painted = idle;
}
loop();
return function () {
running = false;
cancelAnimationFrame(raf);
io.disconnect();
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerdown', onDown);
};
}
// התנועה האוטומטית נתלית בתכונה על אלמנט השורש, כדי שמתג באתר יוכל לגבור
// על הגדרת מערכת ההפעלה. תנועה שהמשתמש מייצר בעצמו פועלת תמיד.
function motionEnabled() {
if (document.documentElement.dataset.motion) return document.documentElement.dataset.motion !== 'off';
return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
})();
</script>