{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hover-distort",
  "type": "registry:block",
  "title": "Hover Distort",
  "description": "Photo cards that distort under the pointer: its velocity is painted into a fading flow map that drives a liquid smear, travelling ripples or a pixel mosaic, with a colour split along the stroke. Touch works by dragging across a card.",
  "docs": "Hover Distort was added to public/vividsites/hover-distort.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/hover-distort",
  "categories": [
    "3d",
    "3d-effects"
  ],
  "files": [
    {
      "path": "components/vividsites/hover-distort.html",
      "type": "registry:file",
      "target": "public/vividsites/hover-distort.html",
      "content": "<!--\n  vividsites · hover-distort\n  Image cards that distort under the pointer. The pointer's velocity is painted\n  into a small flow map that decays over time, and each effect reads it:\n    liquid   the photo is pushed along the flow, like a hand through wet paint\n    ripple   rings spread from the pointer; faster strokes make taller waves\n    pixel    the stroke breaks the photo into a mosaic that heals behind you\n  The colour channels split along the pointer's velocity, and the card pushes in\n  on hover on a spring. Touch: drag across a card. Keyboard: focus a card.\n\n  Markup: one figure.vs-hover-distort per image (an <img> and a <canvas>). The <img>\n  is the no-WebGL fallback and what screen readers read.\n    data-effect                     liquid | ripple | pixel (per card, or on any ancestor)\n  Customise:\n    --vs-hover-distort-strength     how hard the effect pushes (0–2)\n    --vs-hover-distort-brush        brush radius, as a fraction of the card width (.05–.45)\n    --vs-hover-distort-split        colour split along the pointer's velocity (0 = none)\n    --vs-hover-distort-zoom         how far the photo pushes in on hover\n    --vs-hover-distort-radius       corner radius\n    --vs-hover-distort-ratio        card aspect ratio\n  JS: el.vsHoverDistort.set({ effect, strength, brush })   (el = a card)\n      el.vsHoverDistort.move(x, y)  drive it yourself (0–1 card coordinates)\n-->\n<div class=\"vs-hover-distort-row\">\n  <figure class=\"vs-hover-distort\">\n    <img src=\"cave.webp\" alt=\"Blue ice cave with a stream running toward a sunlit opening\">\n    <canvas aria-hidden=\"true\"></canvas>\n  </figure>\n  <figure class=\"vs-hover-distort\">\n    <img src=\"lava.webp\" alt=\"Fresh lava glowing through the cracks of a cooling flow\">\n    <canvas aria-hidden=\"true\"></canvas>\n  </figure>\n  <figure class=\"vs-hover-distort\">\n    <img src=\"shore.webp\" alt=\"Aerial view of surf breaking on a black sand beach\">\n    <canvas aria-hidden=\"true\"></canvas>\n  </figure>\n</div>\n\n<style>\n  .vs-hover-distort-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: clamp(12px, 1.6vw, 22px); }\n  .vs-hover-distort {\n    --vs-hover-distort-strength: 1;\n    --vs-hover-distort-brush: .2;\n    --vs-hover-distort-split: 1;\n    --vs-hover-distort-zoom: .05;\n    --vs-hover-distort-radius: 18px;\n    --vs-hover-distort-ratio: 3 / 4;\n    position: relative; margin: 0; aspect-ratio: var(--vs-hover-distort-ratio); overflow: hidden;\n    border-radius: var(--vs-hover-distort-radius); background: #c9ced4; isolation: isolate;\n    touch-action: pan-y; -webkit-tap-highlight-color: transparent; cursor: crosshair; outline: none;\n    box-shadow: 0 1px 1px rgba(20,28,40,.06), 0 6px 14px -4px rgba(20,28,40,.14), 0 26px 48px -18px rgba(20,28,40,.3);\n    transition: transform 700ms cubic-bezier(.16,1,.3,1), box-shadow 700ms cubic-bezier(.16,1,.3,1);\n  }\n  .vs-hover-distort:hover { transform: translateY(-4px); box-shadow: 0 1px 1px rgba(20,28,40,.06), 0 10px 20px -6px rgba(20,28,40,.18), 0 40px 70px -24px rgba(20,28,40,.38); }\n  .vs-hover-distort:active { transform: translateY(-2px) scale(.994); transition-duration: 150ms; }\n  .vs-hover-distort:focus-visible { box-shadow: 0 0 0 2px #fff, 0 0 0 4px #1c2430, 0 26px 48px -18px rgba(20,28,40,.3); }\n  .vs-hover-distort img, .vs-hover-distort canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }\n  .vs-hover-distort img { object-fit: cover; }\n  .vs-hover-distort canvas { opacity: 0; transition: opacity 500ms cubic-bezier(.16,1,.3,1); }\n  .vs-hover-distort[data-vs-state=\"ready\"] canvas { opacity: 1; }\n  .vs-hover-distort::after { content: \"\"; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; box-shadow: inset 0 0 0 .5px rgba(255,255,255,.18), inset 0 1px 0 rgba(255,255,255,.14); }\n  @media (prefers-reduced-motion: reduce) { .vs-hover-distort, .vs-hover-distort:hover { transition: none; transform: none; } }\n</style>\n\n<script>\n(() => {\n  const VS = `#version 300 es\n  in vec2 aP; out vec2 vUv; void main(){ vUv = aP*.5+.5; gl_Position = vec4(aP,0.,1.); }`;\n\n  /* flow map: xy = velocity painted by the pointer, z = how recently the brush passed; all of it fades */\n  const FLOW = `#version 300 es\n  precision highp float;\n  uniform sampler2D uPrev; uniform vec2 uPtr, uVel; uniform float uAspect, uFade, uOn, uBrush;\n  in vec2 vUv; out vec4 o;\n  void main(){\n    vec4 p = texture(uPrev, vUv);\n    p.xy *= uFade; p.z *= mix(uFade, 1., .35);\n    vec2 c = vUv - uPtr; c.x *= uAspect;\n    float f = exp(-dot(c,c) / max(uBrush*uBrush, 1e-4) * 2.2) * uOn;\n    p.xy += uVel * f;\n    p.z = max(p.z, f * clamp(length(uVel) * 1.8, 0., 1.));\n    p.xy = clamp(p.xy, -1., 1.);\n    o = p;\n  }`;\n\n  const DRAW = `#version 300 es\n  precision highp float;\n  uniform sampler2D uImg, uFlow; uniform vec2 uRes, uSize, uPtr, uVel;\n  uniform float uHover, uZoom, uStrength, uSplit, uTime, uEffect, uBrush, uAspect;\n  in vec2 vUv; out vec4 o;\n  vec3 tap(vec2 uv, vec2 d, float k){\n    return vec3(texture(uImg, uv - d*(1.+k)).r, texture(uImg, uv - d).g, texture(uImg, uv - d*(1.-k*.7)).b);\n  }\n  void main(){\n    float ra = uRes.x/uRes.y, ia = uSize.x/uSize.y;\n    vec2 s = ra > ia ? vec2(1., ia/ra) : vec2(ra/ia, 1.);\n    vec2 uv = (vUv - .5) * s / (1. + uZoom*uHover) + .5;\n    vec4 fl = texture(uFlow, vUv);\n    vec2 d = vec2(0.); vec3 c;\n    float spd = clamp(length(uVel) * 1.5, 0., 1.);\n    if (uEffect < .5) {\n      /* liquid: push the photo along the painted flow */\n      d = fl.xy * .15 * uStrength * s;\n      float k = min(length(d) * 1.8, .16) * uSplit;\n      c = tap(uv, d, k);\n      c += vec3(1.) * clamp(length(fl.xy), 0., 1.) * .05 * fl.z;\n    } else if (uEffect < 1.5) {\n      /* ripple: rings travelling out of the pointer, their height fed by the stroke */\n      vec2 q = vUv - uPtr; q.x *= uAspect;\n      float r = length(q);\n      float env = exp(-r / max(uBrush * 1.6, .02)) * (uHover * .35 + spd * .9 + fl.z * .8) * smoothstep(0., .05, r);   /* no pinch at the centre */\n      float w = sin(r * 70. - uTime * 11.) * env;\n      vec2 dir = r > 1e-4 ? q / r : vec2(0.);\n      dir.x /= uAspect;\n      d = dir * w * .026 * uStrength * s + fl.xy * .03 * uStrength * s;\n      float k = min(abs(w) * .5, .3) * uSplit;\n      c = tap(uv, d, k);\n      c *= 1. + w * .12 * uStrength;              /* the crests catch light */\n    } else {\n      /* pixel: the stroke breaks the image into a quadtree of cells that heal as the trail fades */\n      vec2 g1 = vec2(uRes.y / uRes.x, 1.) / 11., g2 = g1 * .5, g3 = g1 * .25;\n      vec2 c1 = (floor(vUv / g1) + .5) * g1, c2 = (floor(vUv / g2) + .5) * g2, c3 = (floor(vUv / g3) + .5) * g3;\n      float m1 = texture(uFlow, c1).z * uStrength, m2 = texture(uFlow, c2).z * uStrength, m3 = texture(uFlow, c3).z * uStrength;\n      vec2 cuv = vUv, g = g3; float lv = 0.;\n      if (m1 > .62) { cuv = c1; g = g1; lv = 1.; }\n      else if (m2 > .34) { cuv = c2; g = g2; lv = .7; }\n      else if (m3 > .1) { cuv = c3; g = g3; lv = .45; }\n      vec2 puv = (cuv - .5) * s / (1. + uZoom*uHover) + .5;\n      d = texture(uFlow, cuv).xy * .025 * uStrength * s;\n      c = tap(puv, d, lv * .16 * uSplit);\n      /* each cell gets a hairline bevel so the grid reads */\n      vec2 f = fract(vUv / g);\n      float e = min(min(f.x, f.y), min(1. - f.x, 1. - f.y));\n      c *= mix(1., .86 + .14 * smoothstep(0., .08, e), lv);\n      c += lv * .04 * smoothstep(.12, 0., f.y) ;\n    }\n    o = vec4(c, 1.);\n  }`;\n\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const EFFECTS = { liquid: 0, ripple: 1, pixel: 2 };\n  const RES = 128;\n\n  function mount(root) {\n    if (root.vsHoverDistort) return;\n    const img = root.querySelector('img'), canvas = root.querySelector('canvas');\n    const css = n => parseFloat(getComputedStyle(root).getPropertyValue(n));\n    const num = (n, d) => { const v = css(n); return Number.isFinite(v) ? v : d; };\n    const effectOf = () => { const h = root.closest('[data-effect]'); return EFFECTS[h ? h.dataset.effect : 'liquid'] ?? 0; };\n    const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, powerPreference: 'high-performance' });\n    if (!gl || !(gl.getExtension('EXT_color_buffer_float') || gl.getExtension('EXT_color_buffer_half_float'))) { root.dataset.vsState = 'nogl'; return; }\n    root.tabIndex = root.tabIndex >= 0 ? root.tabIndex : 0;\n\n    let P, quad, tImg, fb = [], cur = 0, W = 1, H = 1, raf = 0, visible = true, lost = false, last = 0;\n    const t0 = performance.now();\n    const ptr = { x: .5, y: .5, px: .5, py: .5, on: 0, vx: 0, vy: 0 };\n    const hov = { x: 0, v: 0, target: 0 };\n    let settle = 0, auto = null;\n\n    const sh = (t, s) => { const x = gl.createShader(t); gl.shaderSource(x, s); gl.compileShader(x);\n      if (!gl.getShaderParameter(x, gl.COMPILE_STATUS) && !gl.isContextLost()) throw new Error(gl.getShaderInfoLog(x)); return x; };\n    const prog = fs => { const p = gl.createProgram(); gl.attachShader(p, sh(gl.VERTEX_SHADER, VS)); gl.attachShader(p, sh(gl.FRAGMENT_SHADER, fs));\n      gl.bindAttribLocation(p, 0, 'aP'); gl.linkProgram(p); const u = {}; const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);\n      for (let i = 0; i < n; i++) { const k = gl.getActiveUniform(p, i).name; u[k] = gl.getUniformLocation(p, k); } return { p, u }; };\n    function target() {\n      const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);\n      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, RES, RES, 0, gl.RGBA, gl.HALF_FLOAT, null);\n      for (const [k, v] of [[gl.TEXTURE_MIN_FILTER, gl.LINEAR], [gl.TEXTURE_MAG_FILTER, gl.LINEAR], [gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE], [gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE]]) gl.texParameteri(gl.TEXTURE_2D, k, v);\n      const f = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, f); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0);\n      gl.clearColor(0,0,0,0); gl.clear(gl.COLOR_BUFFER_BIT); gl.bindFramebuffer(gl.FRAMEBUFFER, null); return { t, f };\n    }\n    function init() {\n      P = { flow: prog(FLOW), draw: prog(DRAW) };\n      quad = gl.createVertexArray(); gl.bindVertexArray(quad);\n      const b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW);\n      gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);\n      fb = [target(), target()]; cur = 0;\n      tImg = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, tImg); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);\n      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img); gl.generateMipmap(gl.TEXTURE_2D);\n      for (const [k, v] of [[gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR], [gl.TEXTURE_MAG_FILTER, gl.LINEAR], [gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT], [gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT]]) gl.texParameteri(gl.TEXTURE_2D, k, v);\n    }\n    function resize() {\n      const dpr = Math.min(2, devicePixelRatio || 1);\n      W = canvas.width = Math.max(1, Math.round(root.clientWidth * dpr)); H = canvas.height = Math.max(1, Math.round(root.clientHeight * dpr));\n      kick();\n    }\n\n    function frame(now) {\n      raf = 0; if (lost) return;\n      const dt = Math.min(.05, (now - (last || now)) / 1000); last = now;\n      const still = reduce.matches;\n      if (auto) auto(now);\n      /* hover spring, tension 320 friction 22 */\n      hov.v += ((hov.target - hov.x) * 320 - hov.v * 22) * dt; hov.x += hov.v * dt;\n      if (still) { hov.x = hov.target; hov.v = 0; }\n      const ivx = (ptr.x - ptr.px) / Math.max(dt, 1 / 240), ivy = (ptr.y - ptr.py) / Math.max(dt, 1 / 240);\n      ptr.px = ptr.x; ptr.py = ptr.y;\n      /* smoothed velocity for the colour split and ripple height */\n      const a = 1 - Math.exp(-dt * 14); ptr.vx += (ivx - ptr.vx) * a; ptr.vy += (ivy - ptr.vy) * a;\n      const brush = Math.max(.02, num('--vs-hover-distort-brush', .2));\n      gl.bindVertexArray(quad);\n      gl.useProgram(P.flow.p); let u = P.flow.u;\n      gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, fb[cur].t); gl.uniform1i(u.uPrev, 0);\n      gl.uniform2f(u.uPtr, ptr.x, ptr.y); gl.uniform2f(u.uVel, still ? 0 : ivx * .07, still ? 0 : ivy * .07);\n      gl.uniform1f(u.uAspect, W / H); gl.uniform1f(u.uFade, Math.pow(.94, dt * 60)); gl.uniform1f(u.uOn, ptr.on); gl.uniform1f(u.uBrush, brush);\n      gl.bindFramebuffer(gl.FRAMEBUFFER, fb[1 - cur].f); gl.viewport(0, 0, RES, RES); gl.drawArrays(gl.TRIANGLES, 0, 3); cur = 1 - cur;\n\n      gl.useProgram(P.draw.p); u = P.draw.u;\n      gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, tImg); gl.uniform1i(u.uImg, 0);\n      gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, fb[cur].t); gl.uniform1i(u.uFlow, 1);\n      gl.uniform2f(u.uRes, W, H); gl.uniform2f(u.uSize, img.naturalWidth, img.naturalHeight);\n      gl.uniform2f(u.uPtr, ptr.x, ptr.y); gl.uniform2f(u.uVel, still ? 0 : ptr.vx * .07, still ? 0 : ptr.vy * .07);\n      gl.uniform1f(u.uHover, hov.x); gl.uniform1f(u.uZoom, num('--vs-hover-distort-zoom', .05));\n      gl.uniform1f(u.uStrength, still ? 0 : num('--vs-hover-distort-strength', 1)); gl.uniform1f(u.uSplit, num('--vs-hover-distort-split', 1));\n      gl.uniform1f(u.uTime, (now - t0) / 1000); gl.uniform1f(u.uEffect, effectOf()); gl.uniform1f(u.uBrush, brush); gl.uniform1f(u.uAspect, W / H);\n      gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, W, H); gl.drawArrays(gl.TRIANGLES, 0, 3);\n      if (root.dataset.vsState !== 'ready') root.dataset.vsState = 'ready';\n      /* sleep once the spring is home and the paint has dried */\n      const moving = Math.abs(hov.x - hov.target) > .001 || Math.abs(hov.v) > .001 || Math.hypot(ptr.vx, ptr.vy) > .002;\n      settle = moving ? 0 : settle + dt;\n      if (visible && (hov.target > 0 || settle < 1.6 || auto)) raf = requestAnimationFrame(frame);\n    }\n    const kick = () => { if (!raf && visible && !lost && P) { last = 0; settle = 0; raf = requestAnimationFrame(frame); } };\n\n    const at = e => { const r = root.getBoundingClientRect(); ptr.x = (e.clientX - r.left) / r.width; ptr.y = 1 - (e.clientY - r.top) / r.height; };\n    root.addEventListener('pointerenter', e => { at(e); ptr.px = ptr.x; ptr.py = ptr.y; ptr.on = 1; hov.target = 1; kick(); });\n    root.addEventListener('pointermove', e => { at(e); ptr.on = 1; if (e.pointerType !== 'mouse') hov.target = 1; kick(); }, { passive: true });\n    root.addEventListener('pointerdown', e => { at(e); ptr.px = ptr.x; ptr.py = ptr.y; ptr.on = 1; hov.target = 1; kick(); }, { passive: true });\n    root.addEventListener('pointerleave', () => { ptr.on = 0; hov.target = 0; kick(); });\n    root.addEventListener('pointercancel', () => { ptr.on = 0; hov.target = 0; kick(); });\n    /* keyboard focus: a slow figure-eight stroke so the effect is visible without a pointer */\n    root.addEventListener('focus', () => { if (root.matches(':focus-visible') && !reduce.matches) { const s0 = performance.now(); auto = now => { const t = (now - s0) / 1000; ptr.x = .5 + Math.sin(t * 1.6) * .28; ptr.y = .5 + Math.sin(t * 3.2) * .16; ptr.on = 1; }; } hov.target = 1; kick(); });\n    root.addEventListener('blur', () => { auto = null; ptr.on = 0; hov.target = 0; kick(); });\n\n    canvas.addEventListener('webglcontextlost', e => { e.preventDefault(); lost = true; cancelAnimationFrame(raf); raf = 0; root.dataset.vsState = 'lost'; });\n    canvas.addEventListener('webglcontextrestored', () => { lost = false; try { init(); kick(); } catch { root.dataset.vsState = 'nogl'; } });\n    new IntersectionObserver(([en]) => { visible = en.isIntersecting; if (visible) kick(); }).observe(root);\n\n    const go = () => { try { init(); } catch (err) { root.dataset.vsState = 'nogl'; console.warn('[hover-distort]', err.message); return; }\n      new ResizeObserver(resize).observe(root); resize();\n      root.vsHoverDistort = {\n        set(o = {}) {\n          if (o.effect && EFFECTS[o.effect] != null) root.dataset.effect = o.effect;\n          if (o.strength != null) root.style.setProperty('--vs-hover-distort-strength', o.strength);\n          if (o.brush != null) root.style.setProperty('--vs-hover-distort-brush', o.brush);\n          kick();\n        },\n        hover(on) { hov.target = on ? 1 : 0; ptr.on = on ? 1 : 0; kick(); },\n        move(x, y) { ptr.x = x; ptr.y = 1 - y; ptr.on = 1; hov.target = 1; kick(); },\n      }; };\n    img.complete && img.naturalWidth ? go() : img.addEventListener('load', go, { once: true });\n  }\n  const boot = () => document.querySelectorAll('.vs-hover-distort').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', boot) : boot();\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "3D effects",
    "tags": [
      "image",
      "hover",
      "displacement",
      "flow map",
      "liquid",
      "ripple",
      "pixelate",
      "rgb split",
      "cards"
    ],
    "tech": [
      "GLSL",
      "JS",
      "CSS"
    ],
    "deps": [],
    "source": "hraun",
    "free": true,
    "accent": "#ff7a3d",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/hover-distort/index.html",
    "thumbnail": "https://vividsites.app/api/component/hover-distort/thumb.jpg",
    "video": "https://vividsites.app/api/component/hover-distort/thumb.mp4",
    "page": "https://vividsites.app/c/hover-distort"
  }
}