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