{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "page-wipe",
  "type": "registry:block",
  "title": "Page Wipe",
  "description": "A full-bleed section transition in WebGL: the next page floods in behind a wobbling liquid edge led by a coloured band, with a lensing meniscus and a pushed, shaded old page. Wipes up, left or radially from the click, and the edge peeks in under a hovering pointer.",
  "docs": "Page Wipe was added to public/vividsites/page-wipe.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/page-wipe",
  "categories": [
    "3d",
    "3d-effects"
  ],
  "files": [
    {
      "path": "components/vividsites/page-wipe.html",
      "type": "registry:file",
      "target": "public/vividsites/page-wipe.html",
      "content": "<!--\n  vividsites · page-wipe\n  A full-bleed page transition. The next section floods in behind a liquid, wobbling\n  edge: two coloured layers lead, the new picture follows with a lensing meniscus, and\n  the old one is shaded and pushed as the edge passes. Hover and the edge peeks in\n  under the pointer. Click / tap, scroll or swipe (opt-in), or use the keyboard.\n\n  Markup:\n    data-images      comma list of \"src [focusX]\" — focusX (0–1) keeps the subject in\n                     frame when the section is narrower than the photo (phones)\n    data-direction   up | left | radial   (radial grows from the click point)\n    data-wheel       add it to let the wheel / a vertical swipe inside the component\n                     move between sections (off by default so it never hijacks a page)\n    Each <img> child is the no-WebGL fallback and supplies the alt text.\n  Customise (CSS variables on .vs-page-wipe):\n    --vs-page-wipe-band       colour of the leading band\n    --vs-page-wipe-wobble     how liquid the edge is, 0 (straight) … 2 (sloshing)\n    --vs-page-wipe-duration   length of one wipe, e.g. 1.4s\n    --vs-page-wipe-height     section height (default 100svh)\n  JS:\n    el.vsPageWipe.next([x, y]) / .prev()   x/y 0–1 (radial origin)\n    el.vsPageWipe.go(i)\n    el.vsPageWipe.set({ band, wobble, duration, direction })\n  Events: 'vs-page-wipe:change' → detail.index\n-->\n<div class=\"vs-page-wipe\" data-direction=\"up\"\n     data-images=\"dunes.webp .5, glasshouse.webp .5, ridge.webp .62\">\n  <img class=\"vs-page-wipe-img\" src=\"dunes.webp\" alt=\"Rolling dunes of ochre, saffron and crimson pigment powder\">\n  <img class=\"vs-page-wipe-img\" src=\"glasshouse.webp\" alt=\"A Victorian glasshouse corridor lined with palms\" hidden>\n  <img class=\"vs-page-wipe-img\" src=\"ridge.webp\" alt=\"A skier carving down a snow ridge above an alpine range at dusk\" hidden>\n  <canvas class=\"vs-page-wipe-canvas\" aria-hidden=\"true\"></canvas>\n</div>\n\n<style>\n  .vs-page-wipe {\n    --vs-page-wipe-band: #e4ff5c;\n    --vs-page-wipe-wobble: 1;\n    --vs-page-wipe-duration: 1.4s;\n    --vs-page-wipe-height: 100svh;\n    position: relative; width: 100%; height: var(--vs-page-wipe-height); min-height: 320px;\n    overflow: hidden; background: #1a1a1a; isolation: isolate; cursor: pointer; outline: none;\n    -webkit-tap-highlight-color: transparent; touch-action: pan-y;\n  }\n  .vs-page-wipe[data-wheel] { touch-action: none; }\n  .vs-page-wipe-img, .vs-page-wipe-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }\n  .vs-page-wipe-img { object-fit: cover; transition: opacity .7s cubic-bezier(.16,1,.3,1); }\n  .vs-page-wipe-img[hidden] { display: block; opacity: 0; }\n  .vs-page-wipe-canvas { opacity: 0; transition: opacity .5s cubic-bezier(.16,1,.3,1); }\n  .vs-page-wipe[data-vs-state=\"ready\"] .vs-page-wipe-canvas { opacity: 1; }\n  .vs-page-wipe:focus-visible::after { content: \"\"; position: absolute; inset: 6px; border-radius: 10px; pointer-events: none;\n    box-shadow: 0 0 0 2px var(--vs-page-wipe-band); }\n</style>\n\n<script>\n(() => {\n  const VS = `#version 300 es\n  in vec2 aP; out vec2 vUv;\n  void main(){ vUv = aP*.5+.5; gl_Position = vec4(aP,0.,1.); }`;\n\n  const FS = `#version 300 es\n  precision highp float;\n  uniform sampler2D uA, uB; uniform vec4 uXA, uXB;\n  uniform vec3 uBand; uniform vec2 uOrigin, uMouse;\n  uniform float uAspect, uTime, uWob, uDir, uRev, uMaxD, uPeek, uSeed;\n  uniform vec3 uP;              /* progress of the three layers: band, second band, new page */\n  in vec2 vUv; out vec4 o;\n  float h21(vec2 p){ p = fract(p*vec2(123.34, 456.21)); p += dot(p, p+45.32); return fract(p.x*p.y); }\n  float vnoise(vec2 p){ vec2 i = floor(p), f = fract(p); f = f*f*(3.-2.*f);\n    return mix(mix(h21(i), h21(i+vec2(1,0)), f.x), mix(h21(i+vec2(0,1)), h21(i+vec2(1,1)), f.x), f.y); }\n  float fbm(vec2 p){ float s = 0., a = .5; for (int i = 0; i < 4; i++) { s += a*vnoise(p); p = p*2.07 + 13.3; a *= .5; } return s/.9375; }\n  const float M = .3;\n  /* along = how far along the wipe a pixel sits (0 → 1); across = coordinate along the edge */\n  void axes(vec2 uv, out float along, out vec2 across, out vec2 dir){\n    vec2 p = vec2(uv.x*uAspect, uv.y);\n    if (uDir < .5) { along = uRev > .5 ? 1.-uv.y : uv.y; across = vec2(p.x, 0.); dir = vec2(0., uRev > .5 ? -1. : 1.); }\n    else if (uDir < 1.5) { along = uRev > .5 ? uv.x : 1.-uv.x; across = vec2(uv.y, 0.); dir = vec2(uRev > .5 ? 1. : -1., 0.); }\n    else { vec2 d = p - vec2(uOrigin.x*uAspect, uOrigin.y); along = length(d)/uMaxD; vec2 n = d/max(length(d), 1e-4);\n      across = n*1.1; dir = vec2(n.x/uAspect, n.y); }\n  }\n  float edgeAt(float prog, vec2 across, float seed, float env){\n    float w = (fbm(across*1.7 + vec2(uSeed, uTime*.45 + uSeed)) - .5)*.2\n            + (fbm(across*3.3 + vec2(seed, uTime*.8 - seed)) - .5)*.06\n            + sin(across.x*3.1 + across.y*2.3 + uTime*2.2 + seed*.3)*.03;\n    float bulge = uDir < 1.5 ? .08*(1. - pow(clamp(abs(across.x/(uDir < .5 ? uAspect : 1.) - .5)*2., 0., 1.), 2.)) : 0.;\n    float base = mix(-M, 1. + M, prog);\n    float amp = uDir > 1.5 ? clamp(base/.35, 0., 1.) : 1.;   /* a small radial blob stays round */\n    return base + (w*uWob + bulge*min(uWob, 1.))*env*amp;\n  }\n  vec3 texA(vec2 uv){ return texture(uA, clamp(uv, .001, .999)*uXA.xy + uXA.zw).rgb; }\n  vec3 texB(vec2 uv){ return texture(uB, clamp(uv, .001, .999)*uXB.xy + uXB.zw).rgb; }\n  void main(){\n    vec2 uv = vUv;\n    float along; vec2 across, dir; axes(uv, along, across, dir);\n    float env1 = pow(max(sin(3.14159*clamp(uP.x, 0., 1.)), 0.), .7), env3 = env1;\n    float b1 = edgeAt(uP.x, across, uSeed, env1);\n    float b2 = edgeAt(uP.x, across, uSeed + 7.3, env1) - .05*env1;\n    float b3 = edgeAt(uP.x, across, uSeed + 3.1, env1) - .1*env1;\n    /* hover peek: the band licks in at the edge under the pointer */\n    if (uPeek > .001) {\n      float near;\n      if (uDir < .5) near = exp(-pow((uv.x - uMouse.x)*uAspect, 2.)/.03);\n      else if (uDir < 1.5) near = exp(-pow(uv.y - uMouse.y, 2.)/.03);\n      else near = 1.;\n      float wob = (fbm(across*2.4 + vec2(uSeed, uTime*.8)) - .5)*.05;\n      float pk = uDir > 1.5 ? uPeek*.045 + wob*uPeek : uPeek*(.012 + .07*near) + wob*uPeek*near;\n      b1 = max(b1, pk);\n      b2 = max(b2, pk*.45 - .01);\n    }\n    /* the old page: pushed ahead of the edge, shaded by the band, a little heat-blur */\n    float ahead = max(along - b1, 0.);\n    float push = exp(-ahead/.06);\n    vec2 ua = uv - dir*(uP.x*.05 + push*.018*env1);\n    vec3 A = texA(ua);\n    float bl = push*env1;\n    /* sampled unconditionally: a texture() inside a divergent branch picks the wrong mip at quad edges */\n    vec2 st = dir*.0045*bl; A = (A*2. + texA(ua+st) + texA(ua-st) + texA(ua+2.*st) + texA(ua-2.*st))/6.;\n    A *= 1. - .32*exp(-ahead/.035)*step(.0001, max(b1 + M*.9, 0.));\n    /* the new page: slides in with the edge, lensed at the meniscus */\n    float inside = b3 - along;\n    float lens = exp(-max(inside, 0.)/.05);\n    vec2 ub = uDir > 1.5 ? uv - (uv - uOrigin)*(1. - uP.z)*.28 - dir*lens*.03*env3\n                         : uv + dir*((1. - uP.z)*.14 - lens*.035*env3);\n    vec3 B = vec3(texB(ub + dir*lens*.004).r, texB(ub).g, texB(ub - dir*lens*.004).b);\n    /* layers */\n    vec3 band = uBand;\n    vec3 L1 = band*(1.05 - .16*smoothstep(0., .06, b1 - along)) + (h21(uv*900.) - .5)*.02;\n    L1 += vec3(1.)*.22*exp(-max(b1 - along, 0.)/.0035);\n    vec3 L2 = B*.3 + band*.05 + band*.25*exp(-max(b2 - along, 0.)/.008);\n    vec3 c = A;\n    float aa1 = fwidth(along - b1)*1.2 + 1e-5, aa2 = fwidth(along - b2)*1.2 + 1e-5, aa3 = fwidth(along - b3)*1.2 + 1e-5;\n    c = mix(c, L1, smoothstep(aa1, -aa1, along - b1));\n    c = mix(c, L2, smoothstep(aa2, -aa2, along - b2));\n    c += band*.35*exp(-abs(along - b1)/.004)*step(along, b1);\n    c = mix(c, B, smoothstep(aa3, -aa3, along - b3));\n    /* a wet highlight riding the new page's edge, and its shadow on the band */\n    c += vec3(1.)*.16*exp(-max(inside, 0.)/.006)*step(0., inside)*env3;\n    c *= 1. - .35*exp(-max(-inside, 0.)/.02)*step(inside, 0.)*step(along, b2)*env3;\n    o = vec4(c, 1.);\n  }`;\n\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const DIRS = { up: 0, left: 1, radial: 2 };\n  const loadImg = src => new Promise((res, rej) => { const i = new Image(); i.decoding = 'async'; i.onload = () => res(i); i.onerror = rej; i.src = src; });\n  const parseTime = v => { v = String(v).trim(); const n = parseFloat(v); return !Number.isFinite(n) ? 1.4 : /ms$/.test(v) ? n / 1000 : n; };\n  const toRgb = s => { const cv = document.createElement('canvas').getContext('2d'); cv.fillStyle = '#000'; cv.fillStyle = s; const h = cv.fillStyle;\n    return h[0] === '#' ? [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255) : (h.match(/[\\d.]+/g) || [0, 0, 0]).slice(0, 3).map(x => x / 255); };\n  const ease = x => x < .5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;\n\n  function mount(root) {\n    if (root.vsPageWipe) return;\n    const canvas = root.querySelector('.vs-page-wipe-canvas');\n    const imgs = [...root.querySelectorAll('.vs-page-wipe-img')];\n    const list = (root.dataset.images || imgs.map(i => i.getAttribute('src')).join(',')).split(',').map(s => s.trim()).filter(Boolean)\n      .map(s => { const [src, fx] = s.split(/\\s+/); return { src, fx: fx != null ? +fx : .5 }; });\n    const N = list.length;\n    let index = 0, target = -1, rev = 0, queued = null;\n    const props = { band: [.9, 1, .36], wob: 1, dur: 1.4, dir: 0 };\n    const setAlt = () => { imgs.forEach((im, i) => { im.hidden = i !== index; }); root.setAttribute('aria-label', (imgs[index]?.alt || 'Section') + ' — activate for the next section'); };\n    root.setAttribute('role', 'button'); if (!root.hasAttribute('tabindex')) root.tabIndex = 0;\n    const emit = () => root.dispatchEvent(new CustomEvent('vs-page-wipe:change', { detail: { index } }));\n\n    const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, powerPreference: 'high-performance' });\n    let dirty = true, raf = 0, visible = true, lost = false, last = 0, W = 1, H = 1, P = null, quad = null;\n    let clock = 0, running = false, rush = 1, origin = [.5, .5], seed = 0;\n    const tex = new Array(N).fill(null), images = new Array(N).fill(null);\n    const mouse = { x: .5, y: .5, tx: .5, ty: .5, pk: 0, in: false };\n\n    function readProps() {\n      const cs = getComputedStyle(root);\n      props.band = toRgb(cs.getPropertyValue('--vs-page-wipe-band').trim() || '#e4ff5c');\n      const w = parseFloat(cs.getPropertyValue('--vs-page-wipe-wobble')); props.wob = Number.isFinite(w) ? w : 1;\n      props.dur = Math.max(.25, parseTime(cs.getPropertyValue('--vs-page-wipe-duration') || '1.4s'));\n      props.dir = DIRS[root.dataset.direction] ?? 0;\n      dirty = true; kick();\n    }\n    function apply(o = {}) {\n      if (o.band != null) root.style.setProperty('--vs-page-wipe-band', o.band);\n      if (o.wobble != null) root.style.setProperty('--vs-page-wipe-wobble', o.wobble);\n      if (o.duration != null) root.style.setProperty('--vs-page-wipe-duration', typeof o.duration === 'number' ? o.duration + 's' : o.duration);\n      if (o.direction != null) root.dataset.direction = o.direction;\n      if (gl) readProps();\n    }\n    const fallbackGo = i => { index = ((i % N) + N) % N; setAlt(); emit(); };\n    setAlt();\n    if (!gl) { root.dataset.vsState = 'nogl'; root.vsPageWipe = { next: () => fallbackGo(index + 1), prev: () => fallbackGo(index - 1), go: fallbackGo, set: apply, get index() { return index; } }; bindInput(); return; }\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    function init() {\n      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);\n      if (!gl.getProgramParameter(p, gl.LINK_STATUS) && !gl.isContextLost()) throw new Error(gl.getProgramInfoLog(p));\n      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); }\n      P = { p, u };\n      quad = gl.createVertexArray(); gl.bindVertexArray(quad);\n      const b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b);\n      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      tex.fill(null); images.forEach((im, i) => im && upload(i));\n    }\n    function upload(i) {\n      const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);\n      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, images[i]); gl.generateMipmap(gl.TEXTURE_2D);\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n      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);\n      tex[i] = t;\n    }\n    /* cover-crop, a touch oversized so the parallax never shows an edge */\n    function xf(i) {\n      const im = images[i]; if (!im) return [1, 1, 0, 0];\n      const fa = W / H, ia = im.naturalWidth / im.naturalHeight, z = .9;\n      let sx = 1, sy = 1; if (fa > ia) sy = ia / fa; else sx = fa / ia; sx *= z; sy *= z;\n      const ox = Math.min(1 - sx, Math.max(0, list[i].fx - sx / 2));\n      return [sx, sy, ox, (1 - sy) / 2];\n    }\n    function maxD(o) { const a = W / H; let m = 0; for (const [x, y] of [[0, 0], [1, 0], [0, 1], [1, 1]]) m = Math.max(m, Math.hypot((x - o[0]) * a, y - o[1])); return m; }\n    function draw(now) {\n      const nextI = running ? target : (index + 1) % N;\n      const u = P.u; gl.viewport(0, 0, W, H); gl.useProgram(P.p); gl.bindVertexArray(quad);\n      gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, tex[index]); gl.uniform1i(u.uA, 0);\n      gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, tex[nextI] || tex[index]); gl.uniform1i(u.uB, 1);\n      gl.uniform4f(u.uXA, ...xf(index)); gl.uniform4f(u.uXB, ...xf(tex[nextI] ? nextI : index));\n      gl.uniform3f(u.uBand, ...props.band); gl.uniform1f(u.uAspect, W / H); gl.uniform1f(u.uTime, now / 1000);\n      gl.uniform1f(u.uWob, props.wob); gl.uniform1f(u.uDir, props.dir); gl.uniform1f(u.uRev, rev); gl.uniform1f(u.uSeed, seed);\n      const o = running || props.dir !== 2 ? origin : [mouse.x, mouse.y];\n      gl.uniform2f(u.uOrigin, o[0], o[1]); gl.uniform1f(u.uMaxD, maxD(o)); gl.uniform2f(u.uMouse, mouse.x, mouse.y);\n      gl.uniform1f(u.uPeek, reduce.matches ? 0 : mouse.pk);\n      /* one progress drives three wobbling layers: band, dark band, then the page */\n      const t = running ? clock / props.dur : 0;\n      const pr = running ? ease(Math.min(1, t)) : 0;\n      gl.uniform3f(u.uP, pr, pr, pr);\n      gl.drawArrays(gl.TRIANGLES, 0, 3);\n    }\n    function frame(now) {\n      raf = 0; if (lost || !tex[index]) return;\n      const dt = Math.min(.05, (now - (last || now)) / 1000); last = now;\n      const k = 1 - Math.exp(-dt * 10); mouse.x += (mouse.tx - mouse.x) * k; mouse.y += (mouse.ty - mouse.y) * k;\n      const pt = mouse.in && !running ? 1 : 0; mouse.pk += (pt - mouse.pk) * (1 - Math.exp(-dt * (pt ? 5 : 9)));\n      if (running) { clock += dt * rush * (reduce.matches ? 3 : 1); if (clock >= props.dur) finish(); }\n      if (dirty || running || mouse.pk > .002) { draw(now); dirty = false; }\n      if (root.dataset.vsState !== 'ready') root.dataset.vsState = 'ready';\n      if (visible && (running || mouse.pk > .002 || pt)) raf = requestAnimationFrame(frame);\n    }\n    function kick() { if (gl && !raf && visible && !lost && tex[index]) { last = 0; raf = requestAnimationFrame(frame); } }\n    function finish() {\n      index = target; running = false; target = -1; clock = 0; rush = 1; dirty = true; setAlt(); emit();\n      if (queued) { const q = queued; queued = null; start(q.i, q.o, q.r); }\n    }\n    function start(i, o, r = 0) {\n      if (running) { rush = 3; queued = { i, o, r }; kick(); return; }\n      const t = ((i % N) + N) % N;\n      if (t === index || !tex[t]) return;\n      target = t; rev = r; origin = o || [mouse.x, mouse.y]; seed = Math.random() * 40; clock = 0; rush = 1; running = true; kick();\n    }\n    function resize() {\n      const dpr = Math.min(2, devicePixelRatio || 1);\n      const w = Math.max(1, Math.round(root.clientWidth * dpr)), h = Math.max(1, Math.round(root.clientHeight * dpr));\n      if (w === W && h === H) return; W = canvas.width = w; H = canvas.height = h; dirty = true; kick();\n    }\n    root.vsPageWipe = {\n      next: o => start(index + 1, o), prev: o => start(index - 1, o, 1), go: (i, o) => start(i, o || [.5, .5], i < index ? 1 : 0),\n      set: apply, get index() { return index; } };\n    bindInput();\n\n    function bindInput() {\n      const api = () => root.vsPageWipe;\n      const local = e => { const r = root.getBoundingClientRect(); return [(e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height]; };\n      let sx = 0, sy = 0, swiped = false;\n      root.addEventListener('pointermove', e => { const [x, y] = local(e); mouse.tx = x; mouse.ty = y;\n        if (e.pointerType === 'mouse') { if (!mouse.in) { mouse.x = x; mouse.y = y; } mouse.in = true; kick(); } });\n      root.addEventListener('pointerleave', () => { mouse.in = false; kick(); });\n      root.addEventListener('pointerdown', e => { sx = e.clientX; sy = e.clientY; swiped = false; const [x, y] = local(e); mouse.x = mouse.tx = x; mouse.y = mouse.ty = y; });\n      root.addEventListener('pointerup', e => { if (e.pointerType === 'mouse' || !root.hasAttribute('data-wheel')) return;\n        const dy = e.clientY - sy; if (Math.abs(dy) > 44 && Math.abs(dy) > Math.abs(e.clientX - sx)) { swiped = true; dy < 0 ? api().next([.5, .5]) : api().prev([.5, .5]); } });\n      root.addEventListener('click', e => { if (swiped) return; const [x, y] = local(e); api().next([x, y]); });\n      let acc = 0, lock = 0;\n      root.addEventListener('wheel', e => { if (!root.hasAttribute('data-wheel')) return; e.preventDefault();\n        const now = performance.now(); if (now < lock) return; acc += e.deltaY;\n        if (Math.abs(acc) > 50) { acc > 0 ? api().next([.5, .5]) : api().prev([.5, .5]); acc = 0; lock = now + 650; } }, { passive: false });\n      root.addEventListener('keydown', e => {\n        if (['Enter', ' ', 'ArrowDown', 'PageDown', 'ArrowRight'].includes(e.key)) { e.preventDefault(); api().next([.5, .5]); }\n        if (['ArrowUp', 'PageUp', 'ArrowLeft'].includes(e.key)) { e.preventDefault(); api().prev([.5, .5]); } });\n    }\n\n    new MutationObserver(readProps).observe(root, { attributes: true, attributeFilter: ['style', 'class', 'data-direction'] });\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(); dirty = true; kick(); } catch { root.dataset.vsState = 'nogl'; } });\n    new IntersectionObserver(([en]) => { visible = en.isIntersecting; if (visible) kick(); }).observe(root);\n    document.addEventListener('visibilitychange', () => { visible = !document.hidden; if (visible) kick(); });\n    try { init(); } catch (err) { root.dataset.vsState = 'nogl'; console.warn('[page-wipe]', err.message); return; }\n    readProps();\n    new ResizeObserver(resize).observe(root); resize();\n    list.forEach((it, i) => loadImg(it.src).then(im => { images[i] = im; if (!lost) upload(i); if (i === index) { root.dispatchEvent(new CustomEvent('vs:ready')); dirty = true; kick(); } }).catch(() => {}));\n  }\n  const boot = () => document.querySelectorAll('.vs-page-wipe').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', boot) : boot();\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "3D effects",
    "tags": [
      "transition",
      "wipe",
      "page",
      "liquid",
      "section",
      "shader",
      "barba",
      "overlay"
    ],
    "tech": [
      "GLSL",
      "JS",
      "CSS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#e4ff5c",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/page-wipe/index.html",
    "thumbnail": "https://vividsites.app/api/component/page-wipe/thumb.jpg",
    "video": "https://vividsites.app/api/component/page-wipe/thumb.mp4",
    "page": "https://vividsites.app/c/page-wipe"
  }
}