Created
April 24, 2026 18:06
-
-
Save nusu/bd9f76adeaf35c089b9e6867564e624c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React, { useEffect, useRef } from 'react' | |
| import { useDialKit, DialRoot } from 'dialkit' | |
| import 'dialkit/styles.css' | |
| const IMG = "https://images.unsplash.com/photo-1774332080727-2a5da87d0104?q=80&w=2187&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" | |
| const VS = ` | |
| attribute vec2 p; | |
| varying vec2 v; | |
| void main(){ v = p*0.5+0.5; v.y = 1.0-v.y; gl_Position = vec4(p,0,1); } | |
| ` | |
| // Radial jelly wave expanding from a click point. | |
| // uOrigin - the click point (uv space) | |
| // uTravel - current radius of the ring | |
| // uBandHeight - ring thickness (gaussian sigma-ish) | |
| // uPullAmount - peak radial displacement | |
| // uOvalW - aspect ratio for the oval | |
| // d - spring displacement signal (can go negative) | |
| const FS = ` | |
| precision highp float; | |
| varying vec2 v; | |
| uniform sampler2D tex; | |
| uniform float d; | |
| uniform float uTravel; | |
| uniform float uBandHeight; | |
| uniform float uPullAmount; | |
| uniform float uOvalW; | |
| uniform float uCA; | |
| uniform vec2 uOrigin; | |
| void main(){ | |
| vec2 uv = v; | |
| vec2 rel = uv - uOrigin; | |
| // Slight horizontal squash so the ring reads as a soft oval, not perfect circle | |
| rel.x /= uOvalW; | |
| float dist = length(rel); | |
| // Gaussian ring centered at uTravel | |
| float ring = exp(-pow((dist - uTravel) / uBandHeight, 2.0) * 4.0); | |
| // Outward radial direction from origin | |
| vec2 dir = normalize(uv - uOrigin + 1e-6); | |
| // Pixels in the ring get pulled outward; bounce sign comes from d. | |
| vec2 pull = dir * d * uPullAmount * ring; | |
| vec2 uvSample = uv - pull; | |
| // Chromatic aberration along the ring's leading edge | |
| float edge = ring * (1.0 - ring) * 4.0; | |
| vec2 ca = dir * uCA * edge * abs(d); | |
| float r = texture2D(tex, uvSample + ca).r; | |
| float g = texture2D(tex, uvSample).g; | |
| float b = texture2D(tex, uvSample - ca).b; | |
| gl_FragColor = vec4(r, g, b, 1.0); | |
| } | |
| ` | |
| function compile(gl, type, src) { | |
| const s = gl.createShader(type) | |
| gl.shaderSource(s, src) | |
| gl.compileShader(s) | |
| if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) console.error(gl.getShaderInfoLog(s)) | |
| return s | |
| } | |
| export default function JellyInteractive() { | |
| const params = useDialKit('Jelly Interactive', { | |
| stiffness: [391, 20, 800], | |
| damping: [7, 1, 60], | |
| pullAmount: [0.03, 0, 0.3], | |
| bandHeight: [0.36, 0.05, 1.0], | |
| ovalWidth: [0.93, 0.1, 2.0], | |
| chromaticAberration: [0.01, 0, 0.05], | |
| }) | |
| const canvasRef = useRef(null) | |
| const drawRef = useRef(() => { }) | |
| const paramsRef = useRef(params) | |
| paramsRef.current = params | |
| const animTokenRef = useRef(0) | |
| useEffect(() => { | |
| const canvas = canvasRef.current | |
| const gl = canvas.getContext('webgl', { premultipliedAlpha: false }) | |
| const prog = gl.createProgram() | |
| gl.attachShader(prog, compile(gl, gl.VERTEX_SHADER, VS)) | |
| gl.attachShader(prog, compile(gl, gl.FRAGMENT_SHADER, FS)) | |
| gl.linkProgram(prog) | |
| gl.useProgram(prog) | |
| const buf = gl.createBuffer() | |
| gl.bindBuffer(gl.ARRAY_BUFFER, buf) | |
| gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW) | |
| const pLoc = gl.getAttribLocation(prog, 'p') | |
| gl.enableVertexAttribArray(pLoc) | |
| gl.vertexAttribPointer(pLoc, 2, gl.FLOAT, false, 0, 0) | |
| const u = { | |
| d: gl.getUniformLocation(prog, 'd'), | |
| travel: gl.getUniformLocation(prog, 'uTravel'), | |
| band: gl.getUniformLocation(prog, 'uBandHeight'), | |
| pull: gl.getUniformLocation(prog, 'uPullAmount'), | |
| oval: gl.getUniformLocation(prog, 'uOvalW'), | |
| ca: gl.getUniformLocation(prog, 'uCA'), | |
| origin: gl.getUniformLocation(prog, 'uOrigin'), | |
| } | |
| const tex = gl.createTexture() | |
| gl.bindTexture(gl.TEXTURE_2D, tex) | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) | |
| drawRef.current = (d, travel, origin) => { | |
| const p = paramsRef.current | |
| gl.uniform1f(u.d, d) | |
| gl.uniform1f(u.travel, travel) | |
| gl.uniform1f(u.band, p.bandHeight) | |
| gl.uniform1f(u.pull, p.pullAmount) | |
| gl.uniform1f(u.oval, p.ovalWidth) | |
| gl.uniform1f(u.ca, p.chromaticAberration) | |
| gl.uniform2f(u.origin, origin[0], origin[1]) | |
| gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4) | |
| } | |
| const img = new Image() | |
| img.crossOrigin = 'anonymous' | |
| img.onload = () => { | |
| const W = 560 | |
| const H = Math.round(W * img.height / img.width) | |
| canvas.width = W | |
| canvas.height = H | |
| gl.viewport(0, 0, W, H) | |
| gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false) | |
| gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img) | |
| drawRef.current(0, -10, [0.5, 0.5]) | |
| } | |
| img.src = IMG | |
| }, []) | |
| useEffect(() => { drawRef.current(0, -10, [0.5, 0.5]) }, | |
| [params.bandHeight, params.pullAmount, params.ovalWidth, params.chromaticAberration]) | |
| const onCanvasClick = (e) => { | |
| const myToken = ++animTokenRef.current | |
| const rect = canvasRef.current.getBoundingClientRect() | |
| const x = (e.clientX - rect.left) / rect.width | |
| const y = (e.clientY - rect.top) / rect.height | |
| const origin = [x, y] | |
| // Ring radius needs to grow large enough to leave the canvas from the | |
| // farthest corner relative to the click point. | |
| const farthest = Math.max( | |
| Math.hypot(x, y), | |
| Math.hypot(1 - x, y), | |
| Math.hypot(x, 1 - y), | |
| Math.hypot(1 - x, 1 - y), | |
| ) | |
| const totalDist = farthest + 0.1 | |
| let xs = 0, vs = 0 | |
| let last = performance.now() | |
| const startTime = last | |
| const travelMs = 700 | |
| const pullStiffness = 600 | |
| const pullDamping = 28 | |
| let phase = 'pull' | |
| const step = (now) => { | |
| if (animTokenRef.current !== myToken) return // superseded by a newer click | |
| const dt = Math.min(0.032, (now - last) / 1000) | |
| last = now | |
| const { stiffness, damping } = paramsRef.current | |
| const travelT = Math.min(1, (now - startTime) / travelMs) | |
| const travelDist = travelT * totalDist | |
| const sub = 4 | |
| const h = dt / sub | |
| if (phase === 'pull') { | |
| for (let i = 0; i < sub; i++) { | |
| const f = -pullStiffness * (xs - 1) - pullDamping * vs | |
| vs += f * h | |
| xs += vs * h | |
| } | |
| if (xs >= 0.98) phase = 'release' | |
| } else { | |
| for (let i = 0; i < sub; i++) { | |
| const f = -stiffness * xs - damping * vs | |
| vs += f * h | |
| xs += vs * h | |
| } | |
| if (Math.abs(xs) < 0.002 && Math.abs(vs) < 0.02 && travelT >= 1) { | |
| drawRef.current(0, -10, origin) | |
| return | |
| } | |
| } | |
| drawRef.current(xs, travelDist, origin) | |
| requestAnimationFrame(step) | |
| } | |
| requestAnimationFrame(step) | |
| } | |
| return ( | |
| <div style={{ | |
| minHeight: '100vh', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| gap: 16, | |
| }}> | |
| <canvas | |
| ref={canvasRef} | |
| onClick={onCanvasClick} | |
| style={{ width: 420, borderRadius: 8, display: 'block', cursor: 'crosshair' }} | |
| /> | |
| <div style={{ color: '#666', fontSize: 12 }}>click anywhere on the photo</div> | |
| <a href="/" style={{ color: '#888', fontSize: 12 }}>← back</a> | |
| <DialRoot /> | |
| </div> | |
| ) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment