Chromatic aberration transition
The R, G, and B channels are displaced horizontally in opposite directions during the slide transition, producing a glitch-style chromatic aberration. The shift peaks exactly at the midpoint of the animation — amount × sin(progress × π) — then resolves cleanly. amount controls separation: 0.015 is subtle, 0.04 is noticeably glitchy, 0.08+ is extreme.
View code
<div class="wgl-slider" id="my-slider">
<div class="c--slider-a__wrapper">
<!-- Slides are empty — WebGL renders the images -->
<div class="wgl-slide" data-slide></div>
<div class="wgl-slide" data-slide></div>
<div class="wgl-slide" data-slide></div>
<div class="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%; }
.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%; }
.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/rgb1/900/400',
'https://picsum.photos/seed/rgb2/900/400',
'https://picsum.photos/seed/rgb3/900/400',
'https://picsum.photos/seed/rgb4/900/400',
]
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 float uProgress; uniform float uAmount;
varying vec2 vUv;
void main() {
float peak = sin(uProgress * 3.14159265);
vec2 offR = vec2( uAmount * peak, 0.0);
vec2 offB = vec2(-uAmount * peak, 0.0);
float r = mix(texture2D(uFrom, vUv + offR).r, texture2D(uTo, vUv + offR).r, uProgress);
float g = mix(texture2D(uFrom, vUv ).g, texture2D(uTo, vUv ).g, uProgress);
float b = mix(texture2D(uFrom, vUv + offB).b, texture2D(uTo, vUv + offB).b, uProgress);
gl_FragColor = vec4(r, g, b, 1.0);
}
`
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 * Math.min(devicePixelRatio, 2)
canvas.height = h * Math.min(devicePixelRatio, 2)
canvas.style.width = w + 'px'; canvas.style.height = h + 'px'
gl.viewport(0, 0, canvas.width, canvas.height)
}
resize(); window.addEventListener('resize', resize)
function compile(type, src) {
const s = gl.createShader(type)
gl.shaderSource(s, src); gl.compileShader(s); return s
}
const prog = gl.createProgram()
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT))
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG))
gl.linkProgram(prog)
const posData = new Float32Array([-1,-1, 1,-1, -1,1, 1,1])
const uvData = new Float32Array([0,1, 1,1, 0,0, 1,0])
const posBuf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, posBuf); gl.bufferData(gl.ARRAY_BUFFER, posData, gl.STATIC_DRAW)
const uvBuf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, uvBuf); gl.bufferData(gl.ARRAY_BUFFER, uvData, 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 uProg = gl.getUniformLocation(prog, 'uProgress')
const uAmt = gl.getUniformLocation(prog, 'uAmount')
function draw(from, to, progress, amount) {
gl.useProgram(prog)
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf); gl.enableVertexAttribArray(aPos); gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0)
gl.bindBuffer(gl.ARRAY_BUFFER, uvBuf); gl.enableVertexAttribArray(aUv); gl.vertexAttribPointer(aUv, 2, gl.FLOAT, false, 0, 0)
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, from); gl.uniform1i(uFrom, 0)
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, to); gl.uniform1i(uTo, 1)
gl.uniform1f(uProg, progress)
gl.uniform1f(uAmt, amount)
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
}
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
})
}
Promise.all(IMAGES.map(loadTex)).then(textures => {
draw(textures[0], textures[0], 0, 0.018)
const slider = new Slider('#my-slider', {
loop: true, speed: 1,
plugins: [arrows(), pagination({ el: '#my-pag', type: 'dots', clickable: true })],
})
let rafId = null
slider.on('afterSlideChange', ({ index, previousIndex }) => {
if (rafId !== null) cancelAnimationFrame(rafId)
const from = textures[previousIndex], to = textures[index]
const dur = 700, 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
draw(from, to, ease, 0.018)
if (t < 1) rafId = requestAnimationFrame(tick)
else { draw(to, to, 0, 0); rafId = null }
}
rafId = requestAnimationFrame(tick)
})
}) Scrub glitch intensity
Drag the slider to manually scrub the chromatic aberration across two images. At 50% the channel separation is at maximum; at 0% and 100% the image resolves cleanly. This makes the peak-at-midpoint formula tangible — useful when tuning amount for your own transitions.
clean clean
View code
<div class="ctrl-demo">
<div class="ctrl-canvas" id="my-glitch"></div>
<div class="ctrl-row">
<span>clean</span>
<input type="range" id="my-range" min="0" max="100" value="50" />
<span>clean</span>
</div>
</div> .ctrl-demo { display: flex; flex-direction: column; gap: 10px; }
.ctrl-canvas { width: 100%; height: 320px; position: relative; overflow: hidden; background: #000; }
.ctrl-canvas canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.ctrl-row { display: flex; align-items: center; gap: 8px; font-size: 0.8rem; color: #888; }
.ctrl-row input { flex: 1; accent-color: #6C2BD9; } .ctrl-demo { display: flex; flex-direction: column; gap: 10px; }
.ctrl-canvas { width: 100%; height: 320px; position: relative; overflow: hidden; background: #000; }
.ctrl-canvas canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.ctrl-row { display: flex; align-items: center; gap: 8px; font-size: 0.8rem; color: #888; }
.ctrl-row input { flex: 1; accent-color: #6C2BD9; } // Same WebGL setup, progress driven by input instead of animation
const wrap = document.getElementById('my-glitch')
const range = document.getElementById('my-range')
// load tex1, tex2, draw(tex1, tex2, range.value/100, 0.06)
range.addEventListener('input', () => {
draw(tex1, tex2, parseFloat(range.value) / 100, 0.06)
})