רקע שמזמין נגיעה. האדוות הן תגובה ישירה למשתמש, ולכן הן פועלות גם כשהתנועה האוטומטית כבויה — בדיוק ההבחנה שגורמת לאתר להרגיש קשוב ולא רועש. פני מים הוא רכיב מתוך רכיבים של כליקס. מה שמורידים הוא רכיב שלם, גם כ-HTML עצמאי וגם כרכיב React. אין ספרייה חיצונית, אין שלב בנייה, ואם כליקס ייעלם מחר הקוד ימשיך לעבוד.
רכיב · WebGL · בלי ספריות · רקע
<!-- פני מים — הקנבס נמתח על ההורה, שצריך להיות position:relative -->
<canvas id="klix-water" aria-hidden="true"></canvas>
<style>
/* הגרדיאנט הזה הוא מה שנשאר אם אין WebGL2 */
#klix-water{position:absolute;inset:0;width:100%;height:100%;
background:linear-gradient(180deg,#0b2540,#04101f)}
</style>
<script>
(function () {
var canvas = document.getElementById('klix-water');
if (!canvas) return;
var FRAGMENT = "#version 300 es\nprecision highp float;\nout vec4 outColor;\nuniform vec2 u_res;\nuniform float u_time;\nuniform vec2 u_mouse;\n\nfloat hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }\n\nfloat noise(vec2 p){\n vec2 i = floor(p); vec2 f = fract(p);\n float a = hash(i), b = hash(i + vec2(1.0, 0.0));\n float c = hash(i + vec2(0.0, 1.0)), d = hash(i + vec2(1.0, 1.0));\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;\n}\n\n// ארבע אוקטבות ולא שש. ההבדל הוויזואלי ברקע מטושטש זניח,\n// והחיסכון משמעותי כששיידר רץ על מסך מלא.\nfloat fbm(vec2 p){\n float v = 0.0; float amp = 0.5;\n for (int i = 0; i < 4; i++) { v += amp * noise(p); p *= 2.02; amp *= 0.5; }\n return v;\n}\n\nuniform vec3 u_ripples[4];\n// זמן אמת שרץ תמיד. האדוות הן תגובה ישירה לנגיעה של המשתמש, ולכן הן\n// מתנגנות גם כשהתנועה כבויה, בניגוד לגלים שברקע.\nuniform float u_rtime;\n\nfloat surface(vec2 uv, float t){\n float h = fbm(uv * 2.4 + vec2(t * 0.16, t * 0.09));\n h += 0.5 * fbm(uv * 5.0 - vec2(t * 0.22, t * 0.13));\n\n // האצבע גוררת מערבולת קטנה על פני המים\n float d = length(uv - u_mouse);\n h += 0.18 * sin(d * 26.0 - t * 5.0) * exp(-d * 5.5);\n\n for (int i = 0; i < 4; i++){\n float age = u_rtime - u_ripples[i].z;\n if (u_ripples[i].z < 0.0 || age > 3.2) continue;\n float dr = length(uv - u_ripples[i].xy);\n float ring = sin(dr * 34.0 - age * 9.0); // טבעת שמתרחקת ודועכת\n h += 0.5 * ring * exp(-dr * 5.0) * exp(-age * 1.3);\n }\n return h;\n}\n\nvoid main(){\n vec2 uv = (gl_FragCoord.xy - 0.5 * u_res.xy) / u_res.y;\n float t = u_time;\n\n // נורמל מקורב מהפרש גבהים. אפסילון גדול יחסית, אחרת הקאוסטיקה יוצאת\n // רועשת ומקשה על קריאת הטקסט שמעליה.\n float e = 0.02;\n float hC = surface(uv, t);\n float hX = surface(uv + vec2(e, 0.0), t);\n float hY = surface(uv + vec2(0.0, e), t);\n vec2 grad = vec2(hX - hC, hY - hC) / e;\n\n vec3 deep = vec3(0.008, 0.031, 0.070);\n vec3 shallow = vec3(0.043, 0.157, 0.290);\n vec3 foam = vec3(0.545, 0.788, 0.965);\n vec3 gold = vec3(0.784, 0.643, 0.082);\n\n float depth = smoothstep(-0.6, 1.8, hC);\n vec3 col = mix(deep, shallow, depth);\n\n // קאוסטיקה: קווי אור במקומות שבהם פני המים מתכנסים. עדין בכוונה —\n // הרקע כאן משרת את הטקסט ולא להפך.\n float caustic = pow(max(0.0, 1.0 - length(grad) * 0.55), 8.0);\n col += foam * caustic * 0.20;\n\n col += gold * smoothstep(1.05, 1.6, hC) * 0.14; // הבהוב על פסגות הגלים\n\n float vig = smoothstep(1.35, 0.2, length(uv));\n col *= mix(0.35, 1.0, vig);\n\n outColor = vec4(col, 1.0);\n}";
var MAX_RIPPLES = 4;
// vec3 לכל אדווה: x, y, וזמן הלידה. זמן שלילי פירושו משבצת ריקה.
var ripples = new Float32Array(MAX_RIPPLES * 3).fill(-1);
var slot = 0;
var clock = 0;
function drop(p) {
var i = (slot % MAX_RIPPLES) * 3;
ripples[i] = p.nx;
ripples[i + 1] = p.ny;
ripples[i + 2] = clock;
slot++;
}
function uniforms(f, gl, uloc) {
// שעון אמת שרץ תמיד. האדוות הן תגובה ישירה לנגיעה, ולכן הן
// מתנגנות גם כשהתנועה האוטומטית כבויה.
clock = f.t;
gl.uniform1f(uloc('u_rtime'), f.t);
gl.uniform3fv(uloc('u_ripples'), ripples);
}
mountShader(canvas, FRAGMENT, uniforms, drop);
// מריץ fragment shader על מלבן שמכסה את הקנבס. מחזיר פונקציית עצירה,
// או null אם אין WebGL2 — ואז נשאר רקע ה-CSS שמוגדר על הקנבס.
function mountShader(canvas, fragment, extra, onDown) {
var gl = canvas.getContext('webgl2', { antialias: false, alpha: true });
if (!gl) return null; // אין WebGL2 — נשארים עם רקע ה-CSS
function compile(src, type) {
var s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
console.error('shader compile failed:', gl.getShaderInfoLog(s));
return null;
}
return s;
}
var vs = compile('#version 300 es\nin vec2 a_pos;void main(){gl_Position=vec4(a_pos,0.0,1.0);}',
gl.VERTEX_SHADER);
var fs = compile(fragment, gl.FRAGMENT_SHADER);
if (!vs || !fs) return null;
var prog = gl.createProgram();
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
console.error('program link failed:', gl.getProgramInfoLog(prog));
return null;
}
gl.useProgram(prog);
var buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
var loc = gl.getAttribLocation(prog, 'a_pos');
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
var cache = {};
function uloc(name) {
if (!(name in cache)) cache[name] = gl.getUniformLocation(prog, name);
return cache[name];
}
// שעון שמתקדם רק כשהתנועה דלוקה, ונבדק בכל פריים כדי שמתג יגיב מיד.
// הסמן נשאר חי תמיד, כי הוא מונע ישירות על ידי המשתמש.
var clock = 0;
var smooth = { x: 0, y: 0 };
return mountStage(canvas, {
// רקעים ערפיליים נראים זהים ב-dpr 1 ומתיחה ב-CSS, וזה חוסך
// פי ארבעה עבודה במסכים צפופים.
maxDpr: 1,
onResize: function (w, h) {
gl.viewport(0, 0, w, h);
gl.uniform2f(uloc('u_res'), w, h);
},
onPointerDown: onDown,
onFrame: function (f) {
if (motionEnabled()) clock += f.dt;
smooth.x += (f.pointer.nx - smooth.x) * 0.06;
smooth.y += (f.pointer.ny - smooth.y) * 0.06;
gl.uniform1f(uloc('u_time'), clock);
gl.uniform2f(uloc('u_mouse'), smooth.x, smooth.y);
if (extra) extra(f, gl, uloc);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
});
}
// תשתית הקנבס: מדידת גודל, השהיה מחוץ למסך, מעקב אחרי המצביע ושעון 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>