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.
- Hover
- threejs
- glsl
- gsap
- canvas2d
- advanced
- heavy · WebGL
- mobile: degraded
- reduced-motion ✓
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.
### 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. Add this effect to my existing site/section: **RGB-Shift Image Hover**. Keep my grid of image tiles but make each image box a transparent placeholder (`aspect-ratio` preserved) and add ONE absolutely positioned `.rgb__canvas` over the section. Create a single Three.js `WebGLRenderer({ alpha: true })` (DPR ≤ 2) with an `OrthographicCamera(0, w, 0, -h, -1, 1)` and one `PlaneGeometry(1,1)` mesh per tile, scaling/positioning each mesh from its placeholder's `getBoundingClientRect()` every frame. Give each mesh the fragment shader from the spec above (UV zoom about centre, 14-row hash slice displacement scaled by `uGlitch`, `r` sampled at `uv + uShift`, `b` at `uv − uShift`). Track pointer velocity on the section (px/ms, decaying ×0.94 per frame) and, for the hovered tile, lerp `uShift` at 0.1/frame toward `direction × min(0.02 + speed × 0.012, 0.05)`. On `pointerenter`: `gsap.to(uZoom, { value: 1.08, duration: 0.8, ease: 'power3.out' })` and, if true is true, `gsap.fromTo(uGlitch, { value: 1 }, { value: 0, duration: 0.15, ease: 'steps(3)' })`; on `pointerleave` tween `uZoom` back to 1 over 0.8s. Use `#d4ff3a` as the caption hover accent (`--accent`). Load my real images with `TextureLoader` (`colorSpace = THREE.NoColorSpace`). Respect `prefers-reduced-motion` (static render, no tweens), pause the loop when hidden, dispose everything on unmount. Keep my captions, fonts, colours and layout. ### EFFECT (React / Next.js): RGB-Shift Image Hover
Build **RGB-Shift Image Hover** as a self-contained React component for a Next.js App Router project (also valid in Vite + React). Follow the specification below exactly; the vanilla reference implementation at the end is the source of truth for values and structure.
**Setup**
- `npm i gsap @gsap/react three`
- File: `components/RGBShiftImageHover.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import * as THREE from "three";
gsap.registerPlugin(useGSAP);
export default function RGBShiftImageHover() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(() => {
// 1. query elements inside `scope.current` (never document-wide)
// 2. wait for document.fonts.ready before any SplitText
// 3. build the timeline / ScrollTriggers exactly as in the specification
// 4. return nothing — useGSAP reverts everything automatically on unmount
}, { scope });
return (
<div ref={scope} className="rgb">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`RGBShiftImageHover.module.css`) or Tailwind; keep the class names so the spec maps 1:1.
- Respect `prefers-reduced-motion` with `gsap.matchMedia()` as in the spec.
- Three.js: create renderer/scene inside a \`useEffect\` with a cleanup that calls \`renderer.dispose()\` and disposes geometries/materials; cap DPR at \`Math.min(devicePixelRatio, 2)\`.
---
### 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.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```html
<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>
```
```css
.rgb { --accent: var(--knob-accent, var(--accent)); 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); background: var(--bg); color: var(--text); overflow: hidden; }
.rgb__header { position: relative; z-index: 2; display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 1rem 3rem; }
.rgb__eyebrow { grid-column: 1 / -1; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.rgb__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.4rem, 5.5vw, 5.5rem); line-height: .92; letter-spacing: -0.04em; }
.rgb__copy { max-width: 38ch; color: var(--muted); font-size: clamp(.85rem, 1vw, .95rem); line-height: 1.5; }
.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; } /* transparent target: the WebGL plane (on the canvas below) shows through here */
.rgb__caption { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding-top: .8rem; border-top: 1px solid var(--line); margin-top: .9rem; }
.rgb__name { font-family: var(--font-display); font-weight: 400; font-size: clamp(1.1rem, 1.6vw, 1.5rem); letter-spacing: -0.02em; transition: transform .5s cubic-bezier(.2,.7,.2,1); }
.rgb__tag { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); white-space: nowrap; transition: color .35s; }
.rgb__tile:hover .rgb__name { transform: translateX(6px); }
.rgb__tile:hover .rgb__tag { color: var(--accent); }
/* one canvas covering the whole section; planes are positioned to match each .rgb__media rect */
.rgb__canvas { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
.rgb__canvas canvas { width: 100%; height: 100%; display: block; }
@media (max-width: 767px) {
.rgb__header { grid-template-columns: 1fr; align-items: start; }
.rgb__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
.rgb__caption { flex-direction: column; gap: .25rem; }
}
@media (prefers-reduced-motion: reduce) {
.rgb__name, .rgb__tag { transition: none; }
.rgb__tile:hover .rgb__name { transform: none; }
}
```
```js
// RGB-shift image hover — one renderer, one scene, four planes synced to DOM rects; channel split follows pointer velocity.
import * as THREE from 'three';
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { hoverBase: 0.02, velGain: 0.012, zoomTo: 1.08, zoomDuration: 0.8, glitch: true, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const MAX_SHIFT = 0.05; // clamp for the total offset
const SHIFT_LERP = 0.1; // uShift → target per frame
const VEL_DECAY = 0.94; // smoothed speed decay per frame when the pointer rests
const GLITCH_DURATION = 0.15; // seconds of slice tearing on enter
// Knob-driven values (re-read in build())
let HOVER_BASE = DEFAULTS.hoverBase; // constant channel offset (UV units) while a tile is hovered
let VEL_GAIN = DEFAULTS.velGain; // extra offset per px/ms of pointer speed
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.rgb');
const mount = section.querySelector('.rgb__canvas');
const tiles = [...section.querySelectorAll('.rgb__tile')];
// ---------------------------------------------------------------- procedural images (crisp edges so the split reads)
function grainCanvas() {
const c = document.createElement('canvas'); c.width = c.height = 160;
const g = c.getContext('2d'); const d = g.createImageData(160, 160);
for (let i = 0; i < d.data.length; i += 4) { const v = 100 + Math.random() * 110; d.data[i] = d.data[i + 1] = d.data[i + 2] = v; d.data[i + 3] = 255; }
g.putImageData(d, 0, 0); return c;
}
const grain = grainCanvas();
const IMAGES = {
dune(g, W, H) {
const sky = g.createLinearGradient(0, 0, 0, H); sky.addColorStop(0, '#f1d9b0'); sky.addColorStop(0.55, '#d9a56a'); sky.addColorStop(1, '#5a2f1a');
g.fillStyle = sky; g.fillRect(0, 0, W, H);
g.fillStyle = '#fff3dc'; g.beginPath(); g.arc(W * 0.68, H * 0.3, 70, 0, Math.PI * 2); g.fill();
g.fillStyle = '#3a1c10'; g.beginPath(); g.moveTo(0, H * 0.62); g.bezierCurveTo(W * 0.3, H * 0.45, W * 0.55, H * 0.8, W, H * 0.58); g.lineTo(W, H); g.lineTo(0, H); g.fill();
g.fillStyle = '#7a3f22'; g.beginPath(); g.moveTo(0, H * 0.8); g.bezierCurveTo(W * 0.35, H * 0.68, W * 0.6, H * 0.95, W, H * 0.78); g.lineTo(W, H); g.lineTo(0, H); g.fill();
},
tide(g, W, H) {
const sea = g.createLinearGradient(0, 0, 0, H); sea.addColorStop(0, '#07152c'); sea.addColorStop(0.5, '#123a63'); sea.addColorStop(1, '#0a6b73');
g.fillStyle = sea; g.fillRect(0, 0, W, H);
g.strokeStyle = 'rgba(214,240,255,.85)'; g.lineWidth = 2;
for (let k = 0; k < 14; k++) {
const y = H * 0.38 + k * (H * 0.045); g.globalAlpha = 0.25 + (k / 14) * 0.7; g.beginPath();
for (let x = 0; x <= W; x += 6) g.lineTo(x, y + Math.sin(x * 0.018 + k * 0.9) * (6 + k * 1.2));
g.stroke();
}
g.globalAlpha = 1; g.fillStyle = '#e8f4ff'; g.fillRect(0, H * 0.36, W, 2);
},
ash(g, W, H) {
const base = g.createLinearGradient(0, 0, W, H); base.addColorStop(0, '#2a2a2e'); base.addColorStop(1, '#0c0c0e');
g.fillStyle = base; g.fillRect(0, 0, W, H);
g.fillStyle = '#d9d9dc'; g.beginPath(); g.arc(W * 0.42, H * 0.38, W * 0.26, 0, Math.PI * 2); g.fill();
g.strokeStyle = 'rgba(255,255,255,.55)'; g.lineWidth = 3; g.beginPath(); g.arc(W * 0.42, H * 0.38, W * 0.34, 0, Math.PI * 2); g.stroke();
g.fillStyle = '#0c0c0e'; g.fillRect(0, H * 0.72, W, 4); g.fillStyle = '#9a9aa0'; g.fillRect(W * 0.1, H * 0.84, W * 0.8, 1.5);
},
flare(g, W, H) {
const base = g.createLinearGradient(0, H, W, 0); base.addColorStop(0, '#2a0b1e'); base.addColorStop(1, '#6b1f14');
g.fillStyle = base; g.fillRect(0, 0, W, H);
const core = g.createRadialGradient(W * 0.62, H * 0.42, 8, W * 0.62, H * 0.42, W * 0.5); core.addColorStop(0, '#ffe9c4'); core.addColorStop(0.18, '#ff9f5a'); core.addColorStop(0.5, 'rgba(255,120,80,.25)'); core.addColorStop(1, 'transparent');
g.fillStyle = core; g.fillRect(0, 0, W, H);
g.strokeStyle = 'rgba(255,220,190,.7)'; for (let k = 1; k <= 4; k++) { g.lineWidth = 2; g.globalAlpha = 1 - k * 0.2; g.beginPath(); g.arc(W * 0.62, H * 0.42, 60 + k * 58, 0, Math.PI * 2); g.stroke(); }
g.globalAlpha = 1; g.strokeStyle = '#ffd9b8'; g.lineWidth = 2; g.beginPath(); g.moveTo(0, H * 0.9); g.lineTo(W, H * 0.1); g.stroke();
},
};
function makeTexture(kind) {
const W = 768, H = 1024;
const c = document.createElement('canvas'); c.width = W; c.height = H;
const g = c.getContext('2d');
IMAGES[kind](g, W, H);
g.globalCompositeOperation = 'overlay'; g.globalAlpha = 0.32; g.fillStyle = g.createPattern(grain, 'repeat'); g.fillRect(0, 0, W, H);
g.globalCompositeOperation = 'source-over'; g.globalAlpha = 1;
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.NoColorSpace; // raw sRGB in → raw out (ShaderMaterial does no conversion)
tex.minFilter = THREE.LinearFilter; tex.generateMipmaps = false;
tex.wrapS = tex.wrapT = THREE.ClampToEdgeWrapping;
return tex;
}
// ---------------------------------------------------------------- shader
const vertexShader = /* glsl */ `
varying vec2 vUv;
void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }
`;
const fragmentShader = /* glsl */ `
precision highp float;
uniform sampler2D uMap;
uniform vec2 uShift; // channel offset in UV units (direction = pointer travel)
uniform float uZoom; // 1.0 → 1.08
uniform float uGlitch; // 1 → 0 over 150ms on enter
uniform float uHover; // 0 → 1
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;
// horizontal slice displacement: 14 rows, ~45% of them torn, re-rolled 24× per second
float row = floor(uv.y * 14.0);
float seed = hash(row * 7.31 + floor(uTime * 24.0));
uv.x += (seed - 0.5) * 0.22 * uGlitch * step(0.55, seed);
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); // slight contrast lift while hovered
gl_FragColor = vec4(col, 1.0);
}
`;
// ---------------------------------------------------------------- renderer / scene (ONE for all tiles; persists across Playground rebuilds)
const dprCap = window.MotionLab?.preview ? 1 : 2;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, dprCap));
renderer.setClearColor(0x000000, 0);
mount.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(0, 1, 0, -1, -1, 1); // pixel space, y down
const geometry = new THREE.PlaneGeometry(1, 1);
const items = tiles.map((tile) => {
const media = tile.querySelector('.rgb__media');
const texture = makeTexture(media.dataset.image);
const uniforms = {
uMap: { value: texture },
uShift: { value: new THREE.Vector2(0, 0) },
uZoom: { value: 1 },
uGlitch: { value: 0 },
uHover: { value: 0 },
uTime: { value: 0 },
};
const material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, uniforms, transparent: false });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
return { tile, media, texture, material, mesh, uniforms, hovered: false, target: new THREE.Vector2(), dir: new THREE.Vector2(1, 0) };
});
// ---------------------------------------------------------------- pointer velocity
let lastX = 0, lastY = 0, lastT = 0, speedSmoothed = 0;
const onMove = (e) => {
const now = e.timeStamp || performance.now();
if (lastT) {
const dt = Math.max(now - lastT, 1);
const dx = e.clientX - lastX, dy = e.clientY - lastY;
const dist = Math.hypot(dx, dy);
if (dist > 0.5) {
const speed = dist / dt; // px per ms
speedSmoothed = Math.max(speedSmoothed, Math.min(speed, 4));
items.forEach((it) => { if (it.hovered) it.dir.set(dx / dist, -dy / dist); }); // flip y: UV space is y-up
}
}
lastX = e.clientX; lastY = e.clientY; lastT = now;
};
section.addEventListener('pointermove', onMove);
// ---------------------------------------------------------------- intro (plays once, not on Playground rebuilds)
const introCtx = gsap.context(() => {
if (reduced) return;
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 });
});
// ---------------------------------------------------------------- build: read knobs, (re)wire hover tweens
let ctx;
function build() {
ctx?.revert();
section.style.setProperty('--knob-accent', K('accent'));
HOVER_BASE = Math.min(K('hoverBase'), MAX_SHIFT);
VEL_GAIN = K('velGain');
const zoomTo = K('zoomTo');
const zoomDuration = K('zoomDuration');
const glitchOn = !!K('glitch');
ctx = gsap.context(() => {
if (reduced) return;
const ac = new AbortController();
const { signal } = ac;
items.forEach((it) => {
const u = it.uniforms;
it.tile.addEventListener('pointerenter', () => {
it.hovered = true;
gsap.to(u.uZoom, { value: zoomTo, duration: zoomDuration, ease: 'power3.out', overwrite: true });
gsap.to(u.uHover, { value: 1, duration: 0.4, ease: 'power2.out', overwrite: true });
if (glitchOn) gsap.fromTo(u.uGlitch, { value: 1 }, { value: 0, duration: GLITCH_DURATION, ease: 'steps(3)', overwrite: true });
}, { signal });
it.tile.addEventListener('pointerleave', () => {
it.hovered = false;
gsap.to(u.uZoom, { value: 1, duration: zoomDuration, ease: 'power3.out', overwrite: true });
gsap.to(u.uHover, { value: 0, duration: 0.5, ease: 'power2.out', overwrite: true });
gsap.set(u.uGlitch, { value: 0, overwrite: true });
}, { signal });
});
// reverting the previous context rewound its uniform tweens: reset glitch, and let a currently hovered tile pick up the new zoom
items.forEach((it) => {
it.uniforms.uGlitch.value = 0;
if (it.hovered) {
gsap.to(it.uniforms.uZoom, { value: zoomTo, duration: 0.4, ease: 'power3.out', overwrite: true });
gsap.to(it.uniforms.uHover, { value: 1, duration: 0.4, ease: 'power2.out', overwrite: true });
}
});
return () => ac.abort(); // remove the listeners when the context is reverted (no stacked handlers on rebuild)
});
}
build();
// ---------------------------------------------------------------- sync planes to DOM rects
function syncRects() {
const s = mount.getBoundingClientRect();
for (const it of items) {
const r = it.media.getBoundingClientRect();
const x = r.left - s.left, y = r.top - s.top;
it.mesh.scale.set(r.width, r.height, 1);
it.mesh.position.set(x + r.width / 2, -(y + r.height / 2), 0);
}
}
function resize() {
const w = mount.clientWidth, h = mount.clientHeight;
if (!w || !h) return;
renderer.setSize(w, h, false);
camera.right = w; camera.bottom = -h; camera.updateProjectionMatrix();
syncRects();
if (reduced) renderer.render(scene, camera);
}
const ro = new ResizeObserver(resize);
ro.observe(mount);
resize();
// ---------------------------------------------------------------- loop
const clock = new THREE.Clock();
let raf = 0, running = false;
function frame() {
raf = requestAnimationFrame(frame);
const dt = Math.min(clock.getDelta(), 0.05);
speedSmoothed *= VEL_DECAY;
syncRects();
for (const it of items) {
const u = it.uniforms;
u.uTime.value += dt;
const mag = it.hovered ? Math.min(HOVER_BASE + speedSmoothed * VEL_GAIN, MAX_SHIFT) : 0;
it.target.copy(it.dir).multiplyScalar(mag);
u.uShift.value.lerp(it.target, SHIFT_LERP);
}
renderer.render(scene, camera);
}
function start() { if (running || reduced) return; running = true; clock.getDelta(); raf = requestAnimationFrame(frame); }
function stop() { running = false; cancelAnimationFrame(raf); }
start();
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('ml:pause', stop);
window.addEventListener('ml:resume', start);
document.addEventListener('visibilitychange', onVisibility);
// reduced motion: static planes, re-rendered on resize only (handled in resize())
// Playground: rebuild hover wiring with the new values (renderer, textures and planes are kept)
window.addEventListener('ml:knobs', build);
// ---------------------------------------------------------------- cleanup
window.MotionLab?.onCleanup(() => {
stop();
ctx?.revert();
introCtx.revert();
ro.disconnect();
window.removeEventListener('ml:knobs', build);
section.removeEventListener('pointermove', onMove);
window.removeEventListener('ml:pause', stop);
window.removeEventListener('ml:resume', start);
document.removeEventListener('visibilitychange', onVisibility);
items.forEach((it) => { it.texture.dispose(); it.material.dispose(); });
geometry.dispose();
renderer.dispose();
renderer.domElement.remove();
});
```
### EFFECT (single HTML file): RGB-Shift Image Hover
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **RGB-Shift Image Hover** exactly as specified below. Libraries load from jsDelivr:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RGB-Shift Image Hover</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.186/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.186/examples/jsm/"}}</script>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### 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.
---
**Reference implementation (use these exact values and class names):**
```html
<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>
```
```css
.rgb { --accent: var(--knob-accent, var(--accent)); 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); background: var(--bg); color: var(--text); overflow: hidden; }
.rgb__header { position: relative; z-index: 2; display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 1rem 3rem; }
.rgb__eyebrow { grid-column: 1 / -1; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.rgb__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.4rem, 5.5vw, 5.5rem); line-height: .92; letter-spacing: -0.04em; }
.rgb__copy { max-width: 38ch; color: var(--muted); font-size: clamp(.85rem, 1vw, .95rem); line-height: 1.5; }
.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; } /* transparent target: the WebGL plane (on the canvas below) shows through here */
.rgb__caption { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding-top: .8rem; border-top: 1px solid var(--line); margin-top: .9rem; }
.rgb__name { font-family: var(--font-display); font-weight: 400; font-size: clamp(1.1rem, 1.6vw, 1.5rem); letter-spacing: -0.02em; transition: transform .5s cubic-bezier(.2,.7,.2,1); }
.rgb__tag { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); white-space: nowrap; transition: color .35s; }
.rgb__tile:hover .rgb__name { transform: translateX(6px); }
.rgb__tile:hover .rgb__tag { color: var(--accent); }
/* one canvas covering the whole section; planes are positioned to match each .rgb__media rect */
.rgb__canvas { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
.rgb__canvas canvas { width: 100%; height: 100%; display: block; }
@media (max-width: 767px) {
.rgb__header { grid-template-columns: 1fr; align-items: start; }
.rgb__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
.rgb__caption { flex-direction: column; gap: .25rem; }
}
@media (prefers-reduced-motion: reduce) {
.rgb__name, .rgb__tag { transition: none; }
.rgb__tile:hover .rgb__name { transform: none; }
}
```
```js
// RGB-shift image hover — one renderer, one scene, four planes synced to DOM rects; channel split follows pointer velocity.
import * as THREE from 'three';
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { hoverBase: 0.02, velGain: 0.012, zoomTo: 1.08, zoomDuration: 0.8, glitch: true, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const MAX_SHIFT = 0.05; // clamp for the total offset
const SHIFT_LERP = 0.1; // uShift → target per frame
const VEL_DECAY = 0.94; // smoothed speed decay per frame when the pointer rests
const GLITCH_DURATION = 0.15; // seconds of slice tearing on enter
// Knob-driven values (re-read in build())
let HOVER_BASE = DEFAULTS.hoverBase; // constant channel offset (UV units) while a tile is hovered
let VEL_GAIN = DEFAULTS.velGain; // extra offset per px/ms of pointer speed
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.rgb');
const mount = section.querySelector('.rgb__canvas');
const tiles = [...section.querySelectorAll('.rgb__tile')];
// ---------------------------------------------------------------- procedural images (crisp edges so the split reads)
function grainCanvas() {
const c = document.createElement('canvas'); c.width = c.height = 160;
const g = c.getContext('2d'); const d = g.createImageData(160, 160);
for (let i = 0; i < d.data.length; i += 4) { const v = 100 + Math.random() * 110; d.data[i] = d.data[i + 1] = d.data[i + 2] = v; d.data[i + 3] = 255; }
g.putImageData(d, 0, 0); return c;
}
const grain = grainCanvas();
const IMAGES = {
dune(g, W, H) {
const sky = g.createLinearGradient(0, 0, 0, H); sky.addColorStop(0, '#f1d9b0'); sky.addColorStop(0.55, '#d9a56a'); sky.addColorStop(1, '#5a2f1a');
g.fillStyle = sky; g.fillRect(0, 0, W, H);
g.fillStyle = '#fff3dc'; g.beginPath(); g.arc(W * 0.68, H * 0.3, 70, 0, Math.PI * 2); g.fill();
g.fillStyle = '#3a1c10'; g.beginPath(); g.moveTo(0, H * 0.62); g.bezierCurveTo(W * 0.3, H * 0.45, W * 0.55, H * 0.8, W, H * 0.58); g.lineTo(W, H); g.lineTo(0, H); g.fill();
g.fillStyle = '#7a3f22'; g.beginPath(); g.moveTo(0, H * 0.8); g.bezierCurveTo(W * 0.35, H * 0.68, W * 0.6, H * 0.95, W, H * 0.78); g.lineTo(W, H); g.lineTo(0, H); g.fill();
},
tide(g, W, H) {
const sea = g.createLinearGradient(0, 0, 0, H); sea.addColorStop(0, '#07152c'); sea.addColorStop(0.5, '#123a63'); sea.addColorStop(1, '#0a6b73');
g.fillStyle = sea; g.fillRect(0, 0, W, H);
g.strokeStyle = 'rgba(214,240,255,.85)'; g.lineWidth = 2;
for (let k = 0; k < 14; k++) {
const y = H * 0.38 + k * (H * 0.045); g.globalAlpha = 0.25 + (k / 14) * 0.7; g.beginPath();
for (let x = 0; x <= W; x += 6) g.lineTo(x, y + Math.sin(x * 0.018 + k * 0.9) * (6 + k * 1.2));
g.stroke();
}
g.globalAlpha = 1; g.fillStyle = '#e8f4ff'; g.fillRect(0, H * 0.36, W, 2);
},
ash(g, W, H) {
const base = g.createLinearGradient(0, 0, W, H); base.addColorStop(0, '#2a2a2e'); base.addColorStop(1, '#0c0c0e');
g.fillStyle = base; g.fillRect(0, 0, W, H);
g.fillStyle = '#d9d9dc'; g.beginPath(); g.arc(W * 0.42, H * 0.38, W * 0.26, 0, Math.PI * 2); g.fill();
g.strokeStyle = 'rgba(255,255,255,.55)'; g.lineWidth = 3; g.beginPath(); g.arc(W * 0.42, H * 0.38, W * 0.34, 0, Math.PI * 2); g.stroke();
g.fillStyle = '#0c0c0e'; g.fillRect(0, H * 0.72, W, 4); g.fillStyle = '#9a9aa0'; g.fillRect(W * 0.1, H * 0.84, W * 0.8, 1.5);
},
flare(g, W, H) {
const base = g.createLinearGradient(0, H, W, 0); base.addColorStop(0, '#2a0b1e'); base.addColorStop(1, '#6b1f14');
g.fillStyle = base; g.fillRect(0, 0, W, H);
const core = g.createRadialGradient(W * 0.62, H * 0.42, 8, W * 0.62, H * 0.42, W * 0.5); core.addColorStop(0, '#ffe9c4'); core.addColorStop(0.18, '#ff9f5a'); core.addColorStop(0.5, 'rgba(255,120,80,.25)'); core.addColorStop(1, 'transparent');
g.fillStyle = core; g.fillRect(0, 0, W, H);
g.strokeStyle = 'rgba(255,220,190,.7)'; for (let k = 1; k <= 4; k++) { g.lineWidth = 2; g.globalAlpha = 1 - k * 0.2; g.beginPath(); g.arc(W * 0.62, H * 0.42, 60 + k * 58, 0, Math.PI * 2); g.stroke(); }
g.globalAlpha = 1; g.strokeStyle = '#ffd9b8'; g.lineWidth = 2; g.beginPath(); g.moveTo(0, H * 0.9); g.lineTo(W, H * 0.1); g.stroke();
},
};
function makeTexture(kind) {
const W = 768, H = 1024;
const c = document.createElement('canvas'); c.width = W; c.height = H;
const g = c.getContext('2d');
IMAGES[kind](g, W, H);
g.globalCompositeOperation = 'overlay'; g.globalAlpha = 0.32; g.fillStyle = g.createPattern(grain, 'repeat'); g.fillRect(0, 0, W, H);
g.globalCompositeOperation = 'source-over'; g.globalAlpha = 1;
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.NoColorSpace; // raw sRGB in → raw out (ShaderMaterial does no conversion)
tex.minFilter = THREE.LinearFilter; tex.generateMipmaps = false;
tex.wrapS = tex.wrapT = THREE.ClampToEdgeWrapping;
return tex;
}
// ---------------------------------------------------------------- shader
const vertexShader = /* glsl */ `
varying vec2 vUv;
void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }
`;
const fragmentShader = /* glsl */ `
precision highp float;
uniform sampler2D uMap;
uniform vec2 uShift; // channel offset in UV units (direction = pointer travel)
uniform float uZoom; // 1.0 → 1.08
uniform float uGlitch; // 1 → 0 over 150ms on enter
uniform float uHover; // 0 → 1
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;
// horizontal slice displacement: 14 rows, ~45% of them torn, re-rolled 24× per second
float row = floor(uv.y * 14.0);
float seed = hash(row * 7.31 + floor(uTime * 24.0));
uv.x += (seed - 0.5) * 0.22 * uGlitch * step(0.55, seed);
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); // slight contrast lift while hovered
gl_FragColor = vec4(col, 1.0);
}
`;
// ---------------------------------------------------------------- renderer / scene (ONE for all tiles; persists across Playground rebuilds)
const dprCap = window.MotionLab?.preview ? 1 : 2;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, dprCap));
renderer.setClearColor(0x000000, 0);
mount.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(0, 1, 0, -1, -1, 1); // pixel space, y down
const geometry = new THREE.PlaneGeometry(1, 1);
const items = tiles.map((tile) => {
const media = tile.querySelector('.rgb__media');
const texture = makeTexture(media.dataset.image);
const uniforms = {
uMap: { value: texture },
uShift: { value: new THREE.Vector2(0, 0) },
uZoom: { value: 1 },
uGlitch: { value: 0 },
uHover: { value: 0 },
uTime: { value: 0 },
};
const material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, uniforms, transparent: false });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
return { tile, media, texture, material, mesh, uniforms, hovered: false, target: new THREE.Vector2(), dir: new THREE.Vector2(1, 0) };
});
// ---------------------------------------------------------------- pointer velocity
let lastX = 0, lastY = 0, lastT = 0, speedSmoothed = 0;
const onMove = (e) => {
const now = e.timeStamp || performance.now();
if (lastT) {
const dt = Math.max(now - lastT, 1);
const dx = e.clientX - lastX, dy = e.clientY - lastY;
const dist = Math.hypot(dx, dy);
if (dist > 0.5) {
const speed = dist / dt; // px per ms
speedSmoothed = Math.max(speedSmoothed, Math.min(speed, 4));
items.forEach((it) => { if (it.hovered) it.dir.set(dx / dist, -dy / dist); }); // flip y: UV space is y-up
}
}
lastX = e.clientX; lastY = e.clientY; lastT = now;
};
section.addEventListener('pointermove', onMove);
// ---------------------------------------------------------------- intro (plays once, not on Playground rebuilds)
const introCtx = gsap.context(() => {
if (reduced) return;
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 });
});
// ---------------------------------------------------------------- build: read knobs, (re)wire hover tweens
let ctx;
function build() {
ctx?.revert();
section.style.setProperty('--knob-accent', K('accent'));
HOVER_BASE = Math.min(K('hoverBase'), MAX_SHIFT);
VEL_GAIN = K('velGain');
const zoomTo = K('zoomTo');
const zoomDuration = K('zoomDuration');
const glitchOn = !!K('glitch');
ctx = gsap.context(() => {
if (reduced) return;
const ac = new AbortController();
const { signal } = ac;
items.forEach((it) => {
const u = it.uniforms;
it.tile.addEventListener('pointerenter', () => {
it.hovered = true;
gsap.to(u.uZoom, { value: zoomTo, duration: zoomDuration, ease: 'power3.out', overwrite: true });
gsap.to(u.uHover, { value: 1, duration: 0.4, ease: 'power2.out', overwrite: true });
if (glitchOn) gsap.fromTo(u.uGlitch, { value: 1 }, { value: 0, duration: GLITCH_DURATION, ease: 'steps(3)', overwrite: true });
}, { signal });
it.tile.addEventListener('pointerleave', () => {
it.hovered = false;
gsap.to(u.uZoom, { value: 1, duration: zoomDuration, ease: 'power3.out', overwrite: true });
gsap.to(u.uHover, { value: 0, duration: 0.5, ease: 'power2.out', overwrite: true });
gsap.set(u.uGlitch, { value: 0, overwrite: true });
}, { signal });
});
// reverting the previous context rewound its uniform tweens: reset glitch, and let a currently hovered tile pick up the new zoom
items.forEach((it) => {
it.uniforms.uGlitch.value = 0;
if (it.hovered) {
gsap.to(it.uniforms.uZoom, { value: zoomTo, duration: 0.4, ease: 'power3.out', overwrite: true });
gsap.to(it.uniforms.uHover, { value: 1, duration: 0.4, ease: 'power2.out', overwrite: true });
}
});
return () => ac.abort(); // remove the listeners when the context is reverted (no stacked handlers on rebuild)
});
}
build();
// ---------------------------------------------------------------- sync planes to DOM rects
function syncRects() {
const s = mount.getBoundingClientRect();
for (const it of items) {
const r = it.media.getBoundingClientRect();
const x = r.left - s.left, y = r.top - s.top;
it.mesh.scale.set(r.width, r.height, 1);
it.mesh.position.set(x + r.width / 2, -(y + r.height / 2), 0);
}
}
function resize() {
const w = mount.clientWidth, h = mount.clientHeight;
if (!w || !h) return;
renderer.setSize(w, h, false);
camera.right = w; camera.bottom = -h; camera.updateProjectionMatrix();
syncRects();
if (reduced) renderer.render(scene, camera);
}
const ro = new ResizeObserver(resize);
ro.observe(mount);
resize();
// ---------------------------------------------------------------- loop
const clock = new THREE.Clock();
let raf = 0, running = false;
function frame() {
raf = requestAnimationFrame(frame);
const dt = Math.min(clock.getDelta(), 0.05);
speedSmoothed *= VEL_DECAY;
syncRects();
for (const it of items) {
const u = it.uniforms;
u.uTime.value += dt;
const mag = it.hovered ? Math.min(HOVER_BASE + speedSmoothed * VEL_GAIN, MAX_SHIFT) : 0;
it.target.copy(it.dir).multiplyScalar(mag);
u.uShift.value.lerp(it.target, SHIFT_LERP);
}
renderer.render(scene, camera);
}
function start() { if (running || reduced) return; running = true; clock.getDelta(); raf = requestAnimationFrame(frame); }
function stop() { running = false; cancelAnimationFrame(raf); }
start();
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('ml:pause', stop);
window.addEventListener('ml:resume', start);
document.addEventListener('visibilitychange', onVisibility);
// reduced motion: static planes, re-rendered on resize only (handled in resize())
// Playground: rebuild hover wiring with the new values (renderer, textures and planes are kept)
window.addEventListener('ml:knobs', build);
// ---------------------------------------------------------------- cleanup
window.MotionLab?.onCleanup(() => {
stop();
ctx?.revert();
introCtx.revert();
ro.disconnect();
window.removeEventListener('ml:knobs', build);
section.removeEventListener('pointermove', onMove);
window.removeEventListener('ml:pause', stop);
window.removeEventListener('ml:resume', start);
document.removeEventListener('visibilitychange', onVisibility);
items.forEach((it) => { it.texture.dispose(); it.material.dispose(); });
geometry.dispose();
renderer.dispose();
renderer.domElement.remove();
});
```
### EFFECT (Framer & Webflow notes): RGB-Shift Image Hover
**Framer**
- Implement as a **Code Component** (Assets → Code → New component) when the effect owns its own markup (hero, gallery, section), or as a **Code Override** when it should animate existing Framer layers.
- Code Component: paste the React version (`"use client"` is not needed in Framer; keep `useGSAP` with a `scope` ref). Install `gsap` and `@gsap/react` from the package panel. Expose the customization knobs as `addPropertyControls` (numbers/colors/enums) so they are editable on the canvas.
- Code Override: `export function withRGBShiftImageHover(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
- Three.js in Framer: fine inside a Code Component; dispose the renderer in the effect cleanup.
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `rgb`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the timeline sequencing in code — IX2 cannot do stagger/ease control at this precision.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### 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>
.rgb { --accent: var(--knob-accent, var(--accent)); 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); background: var(--bg); color: var(--text); overflow: hidden; }
.rgb__header { position: relative; z-index: 2; display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 1rem 3rem; }
.rgb__eyebrow { grid-column: 1 / -1; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.rgb__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.4rem, 5.5vw, 5.5rem); line-height: .92; letter-spacing: -0.04em; }
.rgb__copy { max-width: 38ch; color: var(--muted); font-size: clamp(.85rem, 1vw, .95rem); line-height: 1.5; }
.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; } /* transparent target: the WebGL plane (on the canvas below) shows through here */
.rgb__caption { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding-top: .8rem; border-top: 1px solid var(--line); margin-top: .9rem; }
.rgb__name { font-family: var(--font-display); font-weight: 400; font-size: clamp(1.1rem, 1.6vw, 1.5rem); letter-spacing: -0.02em; transition: transform .5s cubic-bezier(.2,.7,.2,1); }
.rgb__tag { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); white-space: nowrap; transition: color .35s; }
.rgb__tile:hover .rgb__name { transform: translateX(6px); }
.rgb__tile:hover .rgb__tag { color: var(--accent); }
/* one canvas covering the whole section; planes are positioned to match each .rgb__media rect */
.rgb__canvas { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
.rgb__canvas canvas { width: 100%; height: 100%; display: block; }
@media (max-width: 767px) {
.rgb__header { grid-template-columns: 1fr; align-items: start; }
.rgb__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
.rgb__caption { flex-direction: column; gap: .25rem; }
}
@media (prefers-reduced-motion: reduce) {
.rgb__name, .rgb__tag { transition: none; }
.rgb__tile:hover .rgb__name { transform: none; }
}
// RGB-shift image hover — one renderer, one scene, four planes synced to DOM rects; channel split follows pointer velocity.
import * as THREE from 'three';
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { hoverBase: 0.02, velGain: 0.012, zoomTo: 1.08, zoomDuration: 0.8, glitch: true, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const MAX_SHIFT = 0.05; // clamp for the total offset
const SHIFT_LERP = 0.1; // uShift → target per frame
const VEL_DECAY = 0.94; // smoothed speed decay per frame when the pointer rests
const GLITCH_DURATION = 0.15; // seconds of slice tearing on enter
// Knob-driven values (re-read in build())
let HOVER_BASE = DEFAULTS.hoverBase; // constant channel offset (UV units) while a tile is hovered
let VEL_GAIN = DEFAULTS.velGain; // extra offset per px/ms of pointer speed
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.rgb');
const mount = section.querySelector('.rgb__canvas');
const tiles = [...section.querySelectorAll('.rgb__tile')];
// ---------------------------------------------------------------- procedural images (crisp edges so the split reads)
function grainCanvas() {
const c = document.createElement('canvas'); c.width = c.height = 160;
const g = c.getContext('2d'); const d = g.createImageData(160, 160);
for (let i = 0; i < d.data.length; i += 4) { const v = 100 + Math.random() * 110; d.data[i] = d.data[i + 1] = d.data[i + 2] = v; d.data[i + 3] = 255; }
g.putImageData(d, 0, 0); return c;
}
const grain = grainCanvas();
const IMAGES = {
dune(g, W, H) {
const sky = g.createLinearGradient(0, 0, 0, H); sky.addColorStop(0, '#f1d9b0'); sky.addColorStop(0.55, '#d9a56a'); sky.addColorStop(1, '#5a2f1a');
g.fillStyle = sky; g.fillRect(0, 0, W, H);
g.fillStyle = '#fff3dc'; g.beginPath(); g.arc(W * 0.68, H * 0.3, 70, 0, Math.PI * 2); g.fill();
g.fillStyle = '#3a1c10'; g.beginPath(); g.moveTo(0, H * 0.62); g.bezierCurveTo(W * 0.3, H * 0.45, W * 0.55, H * 0.8, W, H * 0.58); g.lineTo(W, H); g.lineTo(0, H); g.fill();
g.fillStyle = '#7a3f22'; g.beginPath(); g.moveTo(0, H * 0.8); g.bezierCurveTo(W * 0.35, H * 0.68, W * 0.6, H * 0.95, W, H * 0.78); g.lineTo(W, H); g.lineTo(0, H); g.fill();
},
tide(g, W, H) {
const sea = g.createLinearGradient(0, 0, 0, H); sea.addColorStop(0, '#07152c'); sea.addColorStop(0.5, '#123a63'); sea.addColorStop(1, '#0a6b73');
g.fillStyle = sea; g.fillRect(0, 0, W, H);
g.strokeStyle = 'rgba(214,240,255,.85)'; g.lineWidth = 2;
for (let k = 0; k < 14; k++) {
const y = H * 0.38 + k * (H * 0.045); g.globalAlpha = 0.25 + (k / 14) * 0.7; g.beginPath();
for (let x = 0; x <= W; x += 6) g.lineTo(x, y + Math.sin(x * 0.018 + k * 0.9) * (6 + k * 1.2));
g.stroke();
}
g.globalAlpha = 1; g.fillStyle = '#e8f4ff'; g.fillRect(0, H * 0.36, W, 2);
},
ash(g, W, H) {
const base = g.createLinearGradient(0, 0, W, H); base.addColorStop(0, '#2a2a2e'); base.addColorStop(1, '#0c0c0e');
g.fillStyle = base; g.fillRect(0, 0, W, H);
g.fillStyle = '#d9d9dc'; g.beginPath(); g.arc(W * 0.42, H * 0.38, W * 0.26, 0, Math.PI * 2); g.fill();
g.strokeStyle = 'rgba(255,255,255,.55)'; g.lineWidth = 3; g.beginPath(); g.arc(W * 0.42, H * 0.38, W * 0.34, 0, Math.PI * 2); g.stroke();
g.fillStyle = '#0c0c0e'; g.fillRect(0, H * 0.72, W, 4); g.fillStyle = '#9a9aa0'; g.fillRect(W * 0.1, H * 0.84, W * 0.8, 1.5);
},
flare(g, W, H) {
const base = g.createLinearGradient(0, H, W, 0); base.addColorStop(0, '#2a0b1e'); base.addColorStop(1, '#6b1f14');
g.fillStyle = base; g.fillRect(0, 0, W, H);
const core = g.createRadialGradient(W * 0.62, H * 0.42, 8, W * 0.62, H * 0.42, W * 0.5); core.addColorStop(0, '#ffe9c4'); core.addColorStop(0.18, '#ff9f5a'); core.addColorStop(0.5, 'rgba(255,120,80,.25)'); core.addColorStop(1, 'transparent');
g.fillStyle = core; g.fillRect(0, 0, W, H);
g.strokeStyle = 'rgba(255,220,190,.7)'; for (let k = 1; k <= 4; k++) { g.lineWidth = 2; g.globalAlpha = 1 - k * 0.2; g.beginPath(); g.arc(W * 0.62, H * 0.42, 60 + k * 58, 0, Math.PI * 2); g.stroke(); }
g.globalAlpha = 1; g.strokeStyle = '#ffd9b8'; g.lineWidth = 2; g.beginPath(); g.moveTo(0, H * 0.9); g.lineTo(W, H * 0.1); g.stroke();
},
};
function makeTexture(kind) {
const W = 768, H = 1024;
const c = document.createElement('canvas'); c.width = W; c.height = H;
const g = c.getContext('2d');
IMAGES[kind](g, W, H);
g.globalCompositeOperation = 'overlay'; g.globalAlpha = 0.32; g.fillStyle = g.createPattern(grain, 'repeat'); g.fillRect(0, 0, W, H);
g.globalCompositeOperation = 'source-over'; g.globalAlpha = 1;
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.NoColorSpace; // raw sRGB in → raw out (ShaderMaterial does no conversion)
tex.minFilter = THREE.LinearFilter; tex.generateMipmaps = false;
tex.wrapS = tex.wrapT = THREE.ClampToEdgeWrapping;
return tex;
}
// ---------------------------------------------------------------- shader
const vertexShader = /* glsl */ `
varying vec2 vUv;
void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }
`;
const fragmentShader = /* glsl */ `
precision highp float;
uniform sampler2D uMap;
uniform vec2 uShift; // channel offset in UV units (direction = pointer travel)
uniform float uZoom; // 1.0 → 1.08
uniform float uGlitch; // 1 → 0 over 150ms on enter
uniform float uHover; // 0 → 1
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;
// horizontal slice displacement: 14 rows, ~45% of them torn, re-rolled 24× per second
float row = floor(uv.y * 14.0);
float seed = hash(row * 7.31 + floor(uTime * 24.0));
uv.x += (seed - 0.5) * 0.22 * uGlitch * step(0.55, seed);
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); // slight contrast lift while hovered
gl_FragColor = vec4(col, 1.0);
}
`;
// ---------------------------------------------------------------- renderer / scene (ONE for all tiles; persists across Playground rebuilds)
const dprCap = window.MotionLab?.preview ? 1 : 2;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, dprCap));
renderer.setClearColor(0x000000, 0);
mount.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(0, 1, 0, -1, -1, 1); // pixel space, y down
const geometry = new THREE.PlaneGeometry(1, 1);
const items = tiles.map((tile) => {
const media = tile.querySelector('.rgb__media');
const texture = makeTexture(media.dataset.image);
const uniforms = {
uMap: { value: texture },
uShift: { value: new THREE.Vector2(0, 0) },
uZoom: { value: 1 },
uGlitch: { value: 0 },
uHover: { value: 0 },
uTime: { value: 0 },
};
const material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, uniforms, transparent: false });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
return { tile, media, texture, material, mesh, uniforms, hovered: false, target: new THREE.Vector2(), dir: new THREE.Vector2(1, 0) };
});
// ---------------------------------------------------------------- pointer velocity
let lastX = 0, lastY = 0, lastT = 0, speedSmoothed = 0;
const onMove = (e) => {
const now = e.timeStamp || performance.now();
if (lastT) {
const dt = Math.max(now - lastT, 1);
const dx = e.clientX - lastX, dy = e.clientY - lastY;
const dist = Math.hypot(dx, dy);
if (dist > 0.5) {
const speed = dist / dt; // px per ms
speedSmoothed = Math.max(speedSmoothed, Math.min(speed, 4));
items.forEach((it) => { if (it.hovered) it.dir.set(dx / dist, -dy / dist); }); // flip y: UV space is y-up
}
}
lastX = e.clientX; lastY = e.clientY; lastT = now;
};
section.addEventListener('pointermove', onMove);
// ---------------------------------------------------------------- intro (plays once, not on Playground rebuilds)
const introCtx = gsap.context(() => {
if (reduced) return;
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 });
});
// ---------------------------------------------------------------- build: read knobs, (re)wire hover tweens
let ctx;
function build() {
ctx?.revert();
section.style.setProperty('--knob-accent', K('accent'));
HOVER_BASE = Math.min(K('hoverBase'), MAX_SHIFT);
VEL_GAIN = K('velGain');
const zoomTo = K('zoomTo');
const zoomDuration = K('zoomDuration');
const glitchOn = !!K('glitch');
ctx = gsap.context(() => {
if (reduced) return;
const ac = new AbortController();
const { signal } = ac;
items.forEach((it) => {
const u = it.uniforms;
it.tile.addEventListener('pointerenter', () => {
it.hovered = true;
gsap.to(u.uZoom, { value: zoomTo, duration: zoomDuration, ease: 'power3.out', overwrite: true });
gsap.to(u.uHover, { value: 1, duration: 0.4, ease: 'power2.out', overwrite: true });
if (glitchOn) gsap.fromTo(u.uGlitch, { value: 1 }, { value: 0, duration: GLITCH_DURATION, ease: 'steps(3)', overwrite: true });
}, { signal });
it.tile.addEventListener('pointerleave', () => {
it.hovered = false;
gsap.to(u.uZoom, { value: 1, duration: zoomDuration, ease: 'power3.out', overwrite: true });
gsap.to(u.uHover, { value: 0, duration: 0.5, ease: 'power2.out', overwrite: true });
gsap.set(u.uGlitch, { value: 0, overwrite: true });
}, { signal });
});
// reverting the previous context rewound its uniform tweens: reset glitch, and let a currently hovered tile pick up the new zoom
items.forEach((it) => {
it.uniforms.uGlitch.value = 0;
if (it.hovered) {
gsap.to(it.uniforms.uZoom, { value: zoomTo, duration: 0.4, ease: 'power3.out', overwrite: true });
gsap.to(it.uniforms.uHover, { value: 1, duration: 0.4, ease: 'power2.out', overwrite: true });
}
});
return () => ac.abort(); // remove the listeners when the context is reverted (no stacked handlers on rebuild)
});
}
build();
// ---------------------------------------------------------------- sync planes to DOM rects
function syncRects() {
const s = mount.getBoundingClientRect();
for (const it of items) {
const r = it.media.getBoundingClientRect();
const x = r.left - s.left, y = r.top - s.top;
it.mesh.scale.set(r.width, r.height, 1);
it.mesh.position.set(x + r.width / 2, -(y + r.height / 2), 0);
}
}
function resize() {
const w = mount.clientWidth, h = mount.clientHeight;
if (!w || !h) return;
renderer.setSize(w, h, false);
camera.right = w; camera.bottom = -h; camera.updateProjectionMatrix();
syncRects();
if (reduced) renderer.render(scene, camera);
}
const ro = new ResizeObserver(resize);
ro.observe(mount);
resize();
// ---------------------------------------------------------------- loop
const clock = new THREE.Clock();
let raf = 0, running = false;
function frame() {
raf = requestAnimationFrame(frame);
const dt = Math.min(clock.getDelta(), 0.05);
speedSmoothed *= VEL_DECAY;
syncRects();
for (const it of items) {
const u = it.uniforms;
u.uTime.value += dt;
const mag = it.hovered ? Math.min(HOVER_BASE + speedSmoothed * VEL_GAIN, MAX_SHIFT) : 0;
it.target.copy(it.dir).multiplyScalar(mag);
u.uShift.value.lerp(it.target, SHIFT_LERP);
}
renderer.render(scene, camera);
}
function start() { if (running || reduced) return; running = true; clock.getDelta(); raf = requestAnimationFrame(frame); }
function stop() { running = false; cancelAnimationFrame(raf); }
start();
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('ml:pause', stop);
window.addEventListener('ml:resume', start);
document.addEventListener('visibilitychange', onVisibility);
// reduced motion: static planes, re-rendered on resize only (handled in resize())
// Playground: rebuild hover wiring with the new values (renderer, textures and planes are kept)
window.addEventListener('ml:knobs', build);
// ---------------------------------------------------------------- cleanup
window.MotionLab?.onCleanup(() => {
stop();
ctx?.revert();
introCtx.revert();
ro.disconnect();
window.removeEventListener('ml:knobs', build);
section.removeEventListener('pointermove', onMove);
window.removeEventListener('ml:pause', stop);
window.removeEventListener('ml:resume', start);
document.removeEventListener('visibilitychange', onVisibility);
items.forEach((it) => { it.texture.dispose(); it.material.dispose(); });
geometry.dispose();
renderer.dispose();
renderer.domElement.remove();
});