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.
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)
})