Displacement warp transition

A grayscale displacement map warps both images during the transition — the outgoing image distorts outward while the incoming one resolves into place. The noise map is generated procedurally (no external assets required). Angle controls the rotation of the displacement vectors; intensity controls how far pixels are pushed. The effect runs entirely on the GPU as a fragment shader, leaving the CPU free.

View code
<div class="c--slider-a wgl-slider" id="my-slider">
  <div class="c--slider-a__wrapper">
    <!-- Slides are empty — WebGL renders the images -->
    <div class="c--slider-a__slide wgl-slide" data-slide></div>
    <div class="c--slider-a__slide wgl-slide" data-slide></div>
    <div class="c--slider-a__slide wgl-slide" data-slide></div>
    <div class="c--slider-a__slide wgl-slide" data-slide></div>
  </div>
</div>
<div id="my-pag" class="c--slider-a__pagination"></div>
.wgl-slider { position: relative; height: 400px; overflow: hidden; background: #000; }
.wgl-slider .c--slider-a__wrapper { display: flex; }
.wgl-slide { flex-shrink: 0; width: 100%; height: 100%; }
/* Canvas injected by JS sits above slides */
.wgl-slider canvas { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
.wgl-slider { position: relative; height: 400px; overflow: hidden; background: #000; }
.wgl-slider .c--slider-a__wrapper { display: flex; }
.wgl-slide { flex-shrink: 0; width: 100%; height: 100%; }
/* Canvas injected by JS sits above slides */
.wgl-slider canvas { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
import { Slider } from '@andresclua/sliderkit'
import { arrows, pagination } from '@andresclua/sliderkit-plugins'

const IMAGES = [
  'https://picsum.photos/seed/wgl1/900/400',
  'https://picsum.photos/seed/wgl2/900/400',
  'https://picsum.photos/seed/wgl3/900/400',
  'https://picsum.photos/seed/wgl4/900/400',
]

// ---------- shaders ----------
const VERT = `
  attribute vec2 a_pos;
  attribute vec2 a_uv;
  varying vec2 vUv;
  void main() { vUv = a_uv; gl_Position = vec4(a_pos, 0.0, 1.0); }
`
const FRAG = `
  precision highp float;
  uniform sampler2D uFrom;
  uniform sampler2D uTo;
  uniform sampler2D uMap;
  uniform float uProgress;
  uniform float uIntensity;
  uniform float uAngle;
  mat2 rot(float a){return mat2(cos(a),-sin(a),sin(a),cos(a));}
  void main(){
    vec4 d = texture2D(uMap, vUv);
    vec2 dv = (vec2(d.r, d.g) - 0.5) * 2.0;
    vec2 uv0 = vUv + rot(uAngle)         * dv * uIntensity * uProgress;
    vec2 uv1 = vUv + rot(-uAngle * 3.0)  * dv * uIntensity * (1.0 - uProgress);
    gl_FragColor = mix(texture2D(uFrom,uv0), texture2D(uTo,uv1), uProgress);
  }
`

// ---------- setup ----------
const container = document.querySelector('#my-slider')
const canvas = document.createElement('canvas')
container.insertBefore(canvas, container.firstChild)
const gl = canvas.getContext('webgl')

function resize() {
  const w = container.offsetWidth, h = container.offsetHeight
  canvas.width = w * devicePixelRatio; canvas.height = h * devicePixelRatio
  canvas.style.width = w + 'px';      canvas.style.height = h + 'px'
  gl.viewport(0, 0, canvas.width, canvas.height)
}
resize()
window.addEventListener('resize', resize)

function makeProgram(vs, fs) {
  const compile = (type, src) => {
    const s = gl.createShader(type)
    gl.shaderSource(s, src); gl.compileShader(s)
    return s
  }
  const p = gl.createProgram()
  gl.attachShader(p, compile(gl.VERTEX_SHADER, vs))
  gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fs))
  gl.linkProgram(p)
  return p
}

const prog = makeProgram(VERT, FRAG)

// Fullscreen quad
const quad = { pos: new Float32Array([-1,-1,1,-1,-1,1,1,1]), uv: new Float32Array([0,1,1,1,0,0,1,0]) }
const buf = { pos: gl.createBuffer(), uv: gl.createBuffer() }
gl.bindBuffer(gl.ARRAY_BUFFER, buf.pos); gl.bufferData(gl.ARRAY_BUFFER, quad.pos, gl.STATIC_DRAW)
gl.bindBuffer(gl.ARRAY_BUFFER, buf.uv);  gl.bufferData(gl.ARRAY_BUFFER, quad.uv,  gl.STATIC_DRAW)

const aPos = gl.getAttribLocation(prog, 'a_pos')
const aUv  = gl.getAttribLocation(prog, 'a_uv')
const uFrom = gl.getUniformLocation(prog, 'uFrom')
const uTo   = gl.getUniformLocation(prog, 'uTo')
const uMap  = gl.getUniformLocation(prog, 'uMap')
const uProg = gl.getUniformLocation(prog, 'uProgress')
const uInt  = gl.getUniformLocation(prog, 'uIntensity')
const uAng  = gl.getUniformLocation(prog, 'uAngle')

// Procedural noise displacement map
function makeDMap() {
  const n = 256, d = new Uint8Array(n * n * 4)
  for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) {
    const i = (y * n + x) * 4
    const nx = x / n * 8, ny = y / n * 8
    const v = (Math.sin(nx*1.3+ny*0.7)*0.5 + Math.sin(nx*2.1-ny*1.1)*0.35 + Math.sin(nx*0.5+ny*1.9)*0.15) * 0.5 + 0.5
    d[i] = d[i+1] = d[i+2] = v * 255 | 0; d[i+3] = 255
  }
  const t = gl.createTexture()
  gl.bindTexture(gl.TEXTURE_2D, t)
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, n, n, 0, gl.RGBA, gl.UNSIGNED_BYTE, d)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)
  return t
}

// Load image as texture
function loadTex(src) {
  return new Promise(resolve => {
    const img = new Image(); img.crossOrigin = 'anonymous'
    img.onload = () => {
      const t = gl.createTexture()
      gl.bindTexture(gl.TEXTURE_2D, t)
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img)
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
      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)
      resolve(t)
    }
    img.src = src
  })
}

function draw(fromTex, toTex, mapTex, progress) {
  gl.useProgram(prog)
  gl.bindBuffer(gl.ARRAY_BUFFER, buf.pos)
  gl.enableVertexAttribArray(aPos); gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0)
  gl.bindBuffer(gl.ARRAY_BUFFER, buf.uv)
  gl.enableVertexAttribArray(aUv);  gl.vertexAttribPointer(aUv, 2, gl.FLOAT, false, 0, 0)
  gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, fromTex); gl.uniform1i(uFrom, 0)
  gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, toTex);   gl.uniform1i(uTo, 1)
  gl.activeTexture(gl.TEXTURE2); gl.bindTexture(gl.TEXTURE_2D, mapTex);  gl.uniform1i(uMap, 2)
  gl.uniform1f(uProg, progress)
  gl.uniform1f(uInt, 0.35)
  gl.uniform1f(uAng, Math.PI / 4)
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
}

const mapTex = makeDMap()

Promise.all(IMAGES.map(loadTex)).then(textures => {
  draw(textures[0], textures[0], mapTex, 0)

  const slider = new Slider('#my-slider', {
    loop: true,
    speed: 1,  // instant CSS — WebGL handles the visual transition
    plugins: [
      arrows(),
      pagination({ el: '#my-pag', type: 'dots', clickable: true }),
    ],
  })

  let animId = null
  slider.on('afterSlideChange', ({ index, previousIndex }) => {
    if (animId) cancelAnimationFrame(animId)
    const from = textures[previousIndex]
    const to   = textures[index]
    const dur  = 1000
    const start = performance.now()
    const tick = now => {
      const t = Math.min((now - start) / dur, 1)
      const ease = t < 0.5 ? 2*t*t : -1+(4-2*t)*t  // easeInOut
      draw(from, to, mapTex, ease)
      if (t < 1) animId = requestAnimationFrame(tick)
      else draw(to, to, mapTex, 0)
    }
    animId = requestAnimationFrame(tick)
  })
})

Hover-reveal between two images

Standalone implementation: no slider required. The displacement warp triggers on mouseenter/mouseleave, exactly like the original Codrops reference. Useful for hero sections, product reveals, or before/after comparisons. The angle and intensity can be tuned independently for each direction.

View code
<div class="hover-reveal" id="my-reveal">
  <!-- Canvas injected by JS -->
</div>
.hover-reveal {
  width: 100%;
  height: 360px;
  position: relative;
  overflow: hidden;
  cursor: pointer;
}
.hover-reveal {
  width: 100%;
  height: 360px;
  position: relative;
  overflow: hidden;
  cursor: pointer;
}
const VERT = `
  attribute vec2 a_pos; attribute vec2 a_uv; varying vec2 vUv;
  void main() { vUv = a_uv; gl_Position = vec4(a_pos,0.0,1.0); }
`
const FRAG = `
  precision highp float;
  uniform sampler2D uFrom; uniform sampler2D uTo; uniform sampler2D uMap;
  uniform float uProgress; uniform float uIntensity; uniform float uAngle;
  mat2 rot(float a){return mat2(cos(a),-sin(a),sin(a),cos(a));}
  void main(){
    vec4 d = texture2D(uMap, vUv);
    vec2 dv = (vec2(d.r, d.g) - 0.5) * 2.0;
    vec2 uv0 = vUv + rot(uAngle)         * dv * uIntensity * uProgress;
    vec2 uv1 = vUv + rot(-uAngle * 3.0)  * dv * uIntensity * (1.0 - uProgress);
    gl_FragColor = mix(texture2D(uFrom,uv0), texture2D(uTo,uv1), uProgress);
  }
`

const IMAGE1 = 'https://picsum.photos/seed/rv1/900/360'
const IMAGE2 = 'https://picsum.photos/seed/rv2/900/360'

// ... same WebGL setup as the slider version ...
// animate dispFactor: 0→1 on mouseenter, 1→0 on mouseleave
const el = document.getElementById('my-reveal')
el.addEventListener('mouseenter', () => animateTo(1))
el.addEventListener('mouseleave', () => animateTo(0))

Text overlay with slide transitions

Slide text lives directly in the HTML for SEO — crawlers index every caption without executing JavaScript. The canvas covers the in-slide text visually; the .slide-text overlay mirrors the active slide's content and animates on transition. The overlay is optional: remove it and the text stays in the DOM for search engines but nothing is rendered on screen.

01 / 04

Mountain Light

Alpine photography series

02 / 04

Ocean Depths

Underwater exploration

03 / 04

City at Dusk

Urban landscapes

04 / 04

Forest Silence

Nature photography

View code
<div class="wgl-slider wgl-text-slider" id="my-slider">
  <div class="c--slider-a__wrapper">
    <!-- Text is real HTML inside each slide — indexed by crawlers -->
    <div class="wgl-slide" data-slide>
      <p class="slide-tag">01 / 04</p>
      <h2 class="slide-title">Mountain Light</h2>
      <p class="slide-sub">Alpine photography series</p>
    </div>
    <div class="wgl-slide" data-slide>
      <p class="slide-tag">02 / 04</p>
      <h2 class="slide-title">Ocean Depths</h2>
      <p class="slide-sub">Underwater exploration</p>
    </div>
    <div class="wgl-slide" data-slide>
      <p class="slide-tag">03 / 04</p>
      <h2 class="slide-title">City at Dusk</h2>
      <p class="slide-sub">Urban landscapes</p>
    </div>
    <div class="wgl-slide" data-slide>
      <p class="slide-tag">04 / 04</p>
      <h2 class="slide-title">Forest Silence</h2>
      <p class="slide-sub">Nature photography</p>
    </div>
  </div>
  <!--
    Optional visual overlay — remove this div to keep text SEO-only.
    JS reads content from the active slide's DOM, not from a JS array.
  -->
  <div class="slide-text" id="slide-text" aria-hidden="true"></div>
</div>
.wgl-text-slider { position: relative; aspect-ratio: 2/1; overflow: hidden; background: #000; }
.wgl-text-slider canvas { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
/* In-slide text is covered by the canvas — in DOM for SEO, not visible */
.wgl-slide .slide-tag, .wgl-slide .slide-title, .wgl-slide .slide-sub { margin: 0; }
/* Visual overlay above the canvas */
.slide-text {
  position: absolute; bottom: 36px; left: 36px; z-index: 2; color: #fff;
  text-shadow: 0 2px 12px rgba(0,0,0,0.5);
}
@keyframes textLeave {
  to { opacity: 0; transform: translateY(-24px); }
}
@keyframes textEnter {
  from { opacity: 0; transform: translateY(24px); }
  to   { opacity: 1; transform: translateY(0); }
}
.slide-text.is-leaving { animation: textLeave 0.3s ease forwards; }
.slide-text.is-entering { animation: textEnter 0.4s ease forwards; }
.wgl-text-slider { position: relative; aspect-ratio: 2/1; overflow: hidden; background: #000; }
.wgl-text-slider canvas { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
/* In-slide text is covered by the canvas — in DOM for SEO, not visible */
.wgl-slide .slide-tag, .wgl-slide .slide-title, .wgl-slide .slide-sub { margin: 0; }
/* Visual overlay above the canvas */
.slide-text {
  position: absolute; bottom: 36px; left: 36px; z-index: 2; color: #fff;
  text-shadow: 0 2px 12px rgba(0,0,0,0.5);
}
@keyframes textLeave {
  to { opacity: 0; transform: translateY(-24px); }
}
@keyframes textEnter {
  from { opacity: 0; transform: translateY(24px); }
  to   { opacity: 1; transform: translateY(0); }
}
.slide-text.is-leaving { animation: textLeave 0.3s ease forwards; }
.slide-text.is-entering { animation: textEnter 0.4s ease forwards; }
import { Slider } from '@andresclua/sliderkit'
import { arrows } from '@andresclua/sliderkit-plugins'

// ... WebGL displacement setup (same as first demo) ...

const sliderEl = document.getElementById('my-slider')
const slides   = [...sliderEl.querySelectorAll('[data-slide]')]
const textEl   = sliderEl.querySelector('.slide-text')  // null if omitted

// Read text from the active slide's DOM — no JS array needed
function syncText(i) {
  if (!textEl) return
  const s = slides[i]
  textEl.querySelector('.slide-tag').textContent   = s.querySelector('.slide-tag').textContent
  textEl.querySelector('.slide-title').textContent = s.querySelector('.slide-title').textContent
  textEl.querySelector('.slide-sub').textContent   = s.querySelector('.slide-sub').textContent
}

// Seed overlay from slide 0
textEl.innerHTML = slides[0].innerHTML

const slider = new Slider('#my-slider', {
  loop: true, speed: 1,
  plugins: [arrows()],
})

slider.on('beforeSlideChange', () => {
  textEl?.classList.remove('is-entering')
  textEl?.classList.add('is-leaving')
})

slider.on('afterSlideChange', ({ index, previousIndex }) => {
  animateTransition(textures[previousIndex], textures[index], 1000)
  setTimeout(() => {
    textEl?.classList.remove('is-leaving')
    syncText(index)
    requestAnimationFrame(() => requestAnimationFrame(() => {
      textEl?.classList.add('is-entering')
    }))
  }, 350)
})