RGB-Shift Image Hover

Four image tiles share one WebGL renderer; hovering a tile splits its red and blue channels along the pointer's direction (offset scales with velocity), zooms the UVs 1 → 1.08 over 0.8s and tears the first 150ms into glitched horizontal slices.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 1625 words of exact values — rewritten live as you tune the Playground.

prompt.md · RGB-Shift Image Hover
### EFFECT: RGB-Shift Image Hover
**What it does (visual):** A dark editorial page: a thin serif headline ("Studio selects") top-left with a short muted paragraph to its right, and below them a row of four portrait (3:4) image tiles with hairline captions ("Dune", "Tide", "Ash", "Flare"). The tiles fade/rise in on load. At rest the images are perfectly clean. When the pointer enters a tile three things happen at once: (1) if the slice glitch is enabled (true), for the first 150ms the image tears into about six horizontal slices that jump sideways and re-roll ~24 times per second, then snap back; (2) the picture eases into a slight zoom (UV scale 1 → 1.08 over 0.8s, decelerating) and gains a hint of contrast; (3) the red and blue channels slide apart in opposite directions, forming red/cyan ghost edges on every contour. The split's direction is the direction the pointer is moving and its size grows with pointer speed (gain 0.012 per px/ms) — a quick swipe across a tile throws the channels 10+ px apart and they ease back toward a small resting offset (0.02 UV, ~5 px at the default) within half a second while the cursor stays. Leaving the tile eases everything back to clean over 0.8s. The caption name nudges 6px right and the tag turns the accent colour (`#d4ff3a`) while hovered.

**Tech stack:**
- Three.js r160+ (`import * as THREE from 'three'`), one `WebGLRenderer` for all tiles.
- GLSL ES 1.0 fragment shader (below).
- GSAP 3.13+ (core) to tween uniform values. CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`.
- Images: the demo paints four 768×1024 canvases with high-contrast shapes (sun disc + dune curves; sine wave lines; a pale disc + ring; a radial flare + diagonal) and grain. In production use `TextureLoader` on real photos — the split reads best on hard edges.

**HTML structure:**
```html
<section class="rgb">
  <header class="rgb__header">
    <p class="rgb__eyebrow">Hover · GLSL · One shared renderer</p>
    <h1 class="rgb__title">Studio selects</h1>
    <p class="rgb__copy">Move across a tile…</p>
  </header>
  <div class="rgb__grid">
    <figure class="rgb__tile">
      <div class="rgb__media" data-image="dune"></div>          <!-- transparent box; the WebGL plane is drawn exactly here -->
      <figcaption class="rgb__caption"><span class="rgb__name">Dune</span><span class="rgb__tag">Series I · 2026</span></figcaption>
    </figure>
    <!-- 3 more .rgb__tile (tide, ash, flare) -->
  </div>
  <div class="rgb__canvas"></div>                                <!-- ONE canvas covering the section -->
</section>
```

**CSS requirements:**
- `.rgb`: `position: relative; min-height: 100vh; display: grid; grid-template-rows: auto 1fr; gap: clamp(1.5rem, 3vw, 2.5rem); padding: clamp(1.5rem, 4vw, 3.5rem) clamp(1.25rem, 6vw, 5rem) clamp(1.5rem, 4vw, 3rem); overflow: hidden; background: var(--bg, #0a0a0a); color: var(--text, #ededed);`
- `.rgb__header`: `position: relative; z-index: 2; display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 1rem 3rem;` eyebrow spans both columns (mono 11px uppercase `.14em`). Title: display serif `font-weight: 300; font-size: clamp(2.4rem, 5.5vw, 5.5rem); line-height: .92; letter-spacing: -0.04em`. Copy: `max-width: 38ch; color: var(--muted); font-size: clamp(.85rem, 1vw, .95rem)`.
- `.rgb__grid`: `position: relative; z-index: 2; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1.5rem; align-content: start;`
- `.rgb__tile`: `position: relative; margin: 0; cursor: crosshair;` `.rgb__media`: `aspect-ratio: 3/4; width: 100%; background: transparent;` (a placeholder box only — it must stay transparent so the plane on the canvas underneath shows through it).
- `.rgb__caption`: `display: flex; justify-content: space-between; align-items: baseline; padding-top: .8rem; margin-top: .9rem; border-top: 1px solid var(--line, rgba(255,255,255,.08));` `.rgb__name`: serif 400 `clamp(1.1rem, 1.6vw, 1.5rem)`, `transition: transform .5s cubic-bezier(.2,.7,.2,1)`; `.rgb__tag`: mono 10px uppercase muted, `transition: color .35s`. `.rgb__tile:hover .rgb__name { transform: translateX(6px) } .rgb__tile:hover .rgb__tag { color: var(--accent, #d4ff3a) }`.
- `.rgb__canvas`: `position: absolute; inset: 0; z-index: 1; pointer-events: none;` canvas `width: 100%; height: 100%; display: block`. (z-index 1 sits above the section background and below the grid; the media boxes are transparent so the planes show through them — never give them a background or radius.)

**Animation spec (exact values):**
Constants: `HOVER_BASE = 0.02`, `VEL_GAIN = 0.012`, `MAX_SHIFT = 0.05`, `SHIFT_LERP = 0.1`, `VEL_DECAY = 0.94`, `ZOOM_TO = 1.08`, `ZOOM_DURATION = 0.8`, `GLITCH = true`, `GLITCH_DURATION = 0.15`.
1. **Renderer**: `WebGLRenderer({ antialias: true, alpha: true })`, `setPixelRatio(Math.min(devicePixelRatio, 2))`, `setClearColor(0x000000, 0)`, sized to the section via `ResizeObserver`.
2. **Camera**: `OrthographicCamera(0, width, 0, -height, -1, 1)` — pixel units, y down (positions are negated).
3. **Planes**: one shared `PlaneGeometry(1, 1)`; per tile a `ShaderMaterial` with uniforms `uMap` (CanvasTexture, `colorSpace = NoColorSpace`, `LinearFilter`, clamp wrap), `uShift: Vector2(0,0)`, `uZoom: 1`, `uGlitch: 0`, `uHover: 0`, `uTime: 0`.
4. **Sync (every frame)**: `r = media.getBoundingClientRect()`, `s = canvasWrapper.getBoundingClientRect()`; `mesh.scale.set(r.width, r.height, 1)`; `mesh.position.set(r.left − s.left + r.width/2, −(r.top − s.top + r.height/2), 0)`.
5. **Vertex shader**: `vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);`
6. **Fragment shader (complete)**:
```glsl
precision highp float;
uniform sampler2D uMap; uniform vec2 uShift; uniform float uZoom; uniform float uGlitch; uniform float uHover; uniform float uTime;
varying vec2 vUv;
float hash(float n) { return fract(sin(n) * 43758.5453123); }
void main() {
  vec2 uv = (vUv - 0.5) / uZoom + 0.5;                       // zoom about the centre
  float row = floor(uv.y * 14.0);                             // 14 horizontal slices
  float seed = hash(row * 7.31 + floor(uTime * 24.0));        // re-rolled 24×/s
  uv.x += (seed - 0.5) * 0.22 * uGlitch * step(0.55, seed);  // ~45% of rows tear by up to ±11% width
  float r = texture2D(uMap, uv + uShift).r;
  float g = texture2D(uMap, uv).g;
  float b = texture2D(uMap, uv - uShift).b;
  vec3 col = vec3(r, g, b);
  col = mix(col, (col - 0.5) * 1.08 + 0.5, uHover);           // +8% contrast while hovered
  gl_FragColor = vec4(col, 1.0);
}
```
7. **Pointer velocity** (`pointermove` on the section): `speed = hypot(dx, dy) / max(dt_ms, 1)` (px/ms, clamped to 4); `speedSmoothed = max(speedSmoothed, speed)`; for the hovered tile `dir = normalize(dx, −dy)` (flip y into UV space). Every frame `speedSmoothed *= 0.94`.
8. **Shift target (every frame)**: hovered → `mag = min(0.02 + speedSmoothed × 0.012, 0.05)`, else `0`; `target = dir × mag`; `uShift.lerp(target, 0.1)`.
9. **Enter** (`pointerenter` on `.rgb__tile`): `gsap.to(uZoom, { value: 1.08, duration: 0.8, ease: 'power3.out', overwrite: true })`; `gsap.to(uHover, { value: 1, duration: 0.4, ease: 'power2.out' })`; only when `GLITCH` (true) is true: `gsap.fromTo(uGlitch, { value: 1 }, { value: 0, duration: 0.15, ease: 'steps(3)' })`.
10. **Leave**: `gsap.to(uZoom, { value: 1, duration: 0.8, ease: 'power3.out' })`; `gsap.to(uHover, { value: 0, duration: 0.5, ease: 'power2.out' })`; `gsap.set(uGlitch, { value: 0 })`; `hovered = false` (so the shift target becomes 0 and eases out at 0.1/frame ≈ 0.4s).
11. **Load intro**: `gsap.from('.rgb__title', { y: 30, opacity: 0, duration: 1.1, ease: 'power3.out', delay: 0.1 })`; `gsap.from('.rgb__tile', { y: 24, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.08, delay: 0.3 })` (the planes follow because rects are re-read each frame).

**Interaction:** Hover enter = 150ms slice glitch (if true) + 0.8s zoom-in to 1.08 + contrast lift + channel split that starts along the entry direction. While hovering, every movement re-aims the split along the travel direction and speed boosts its size (× 0.012) up to 0.05 UV (~12 px on a 250 px tile); at rest it settles to 0.02 UV. Leave = everything eases back over 0.5–0.8s. The tiles are `cursor: crosshair`. In production wrap each tile in an `<a>`; the planes don't intercept pointer events (`pointer-events: none` on the canvas).

**Responsive behavior:** Under 768px the grid becomes 2×2 (`repeat(2, minmax(0, 1fr))`, gap 1rem) and captions stack; the header collapses to one column. The renderer and plane rects adapt automatically because rects are read each frame. On touch devices `pointerenter`/`pointerleave` fire on tap/release, so a tap gives the glitch + zoom + a split along the tap's movement; this is a degraded (tap, not hover) experience by nature.

**Accessibility:** `prefers-reduced-motion: reduce` → no intro, no hover tweens, no velocity tracking and no rAF loop: planes are rendered once (and on resize) with `uShift = 0`, `uZoom = 1`, `uGlitch = 0`, so the images simply display cleanly; caption hover transitions are disabled in CSS. The canvas is `aria-hidden`; each tile is a `<figure>` with a `<figcaption>` so the images have names. If tiles become links, add a visible focus ring (`outline: 2px solid var(--accent); outline-offset: 4px`).

**Performance rules:** Exactly one WebGL context, one render loop and four draw calls — never one renderer per tile. DPR capped at `Math.min(devicePixelRatio, 2)` (halved in the library hover preview). `getBoundingClientRect` ×5 per frame is negligible; if you have many tiles, read rects only on scroll/resize. No mipmaps (`LinearFilter`) because the planes are drawn at roughly texture scale. Stop the loop on `ml:pause` and `visibilitychange`, resume on `ml:resume`. Cleanup: stop the loop, `ctx.revert()` (kills uniform tweens), disconnect the `ResizeObserver`, remove the pointer listener, dispose each texture and material, the geometry and the renderer, remove the canvas.

**Customization knobs:**
- `HOVER_BASE` (0.02) resting split; `VEL_GAIN` (0.012) velocity sensitivity; `MAX_SHIFT` (0.05) ceiling.
- `SHIFT_LERP` (0.1) — lower = dreamier lag; `VEL_DECAY` (0.94) — how long a flick lingers.
- `ZOOM_TO` (1.08) / `ZOOM_DURATION` (0.8); slice glitch on/off (true), `GLITCH_DURATION` (0.15) and the shader's `14.0` rows / `0.22` tear amplitude.
- Caption hover accent `--accent` (#d4ff3a).
- Swap `texture2D(uMap, uv ± uShift)` channel assignment to split green instead of blue for a magenta/green look.
- Replace the procedural `IMAGES` with `new THREE.TextureLoader().load(src)` (keep `NoColorSpace`).

**Acceptance criteria:**
- [ ] One `<canvas>` exists in the section; all four images render exactly inside their `.rgb__media` boxes, including after a window resize.
- [ ] At rest the images show no colour fringing.
- [ ] Entering a tile produces a brief (≈150ms) horizontal slice tear (when true is true), then a smooth zoom to 1.08 over 0.8s.
- [ ] The red/blue split points along the pointer's movement direction and grows with speed, settling to a small constant offset while the cursor rests on the tile.
- [ ] Leaving a tile returns it to clean within ~0.8s; other tiles are never affected.
- [ ] Reduced motion: images display statically with no split, zoom or glitch.
- [ ] No console errors; only one WebGL context is created.

Source

Full source code

<section class="rgb" aria-label="RGB-shift image hover grid">
  <header class="rgb__header">
    <p class="rgb__eyebrow">Hover · GLSL · One shared renderer</p>
    <h1 class="rgb__title">Studio selects</h1>
    <p class="rgb__copy">Move across a tile: the red and blue channels split along your direction of travel, the image eases into a slight zoom and the first 150ms tear into horizontal slices.</p>
  </header>

  <div class="rgb__grid">
    <figure class="rgb__tile">
      <div class="rgb__media" data-image="dune"></div>
      <figcaption class="rgb__caption"><span class="rgb__name">Dune</span><span class="rgb__tag">Series I · 2026</span></figcaption>
    </figure>
    <figure class="rgb__tile">
      <div class="rgb__media" data-image="tide"></div>
      <figcaption class="rgb__caption"><span class="rgb__name">Tide</span><span class="rgb__tag">Series II · 2026</span></figcaption>
    </figure>
    <figure class="rgb__tile">
      <div class="rgb__media" data-image="ash"></div>
      <figcaption class="rgb__caption"><span class="rgb__name">Ash</span><span class="rgb__tag">Series III · 2025</span></figcaption>
    </figure>
    <figure class="rgb__tile">
      <div class="rgb__media" data-image="flare"></div>
      <figcaption class="rgb__caption"><span class="rgb__name">Flare</span><span class="rgb__tag">Series IV · 2025</span></figcaption>
    </figure>
  </div>

  <div class="rgb__canvas" aria-hidden="true"></div>
</section>