Documentation Index
Fetch the complete documentation index at: https://mintlify.com/hetari/creative-coding/llms.txt
Use this file to discover all available pages before exploring further.
Three fullscreen images are layered behind three invisible hover zones that span the viewport. When the cursor enters a zone, a GPU-accelerated transition fires: a custom PixiJS Filter backed by a GLSL fragment shader slices the canvas into a rotating grid, displaces each slice’s UV coordinates in opposite directions for the outgoing and incoming images, then smoothly mix()-blends between the two textures as GSAP advances the uProgress uniform from 0 to 1. Mid-transition direction reversals are handled cleanly by swapping textures and mirroring progress, so hovering rapidly between sections never causes a visual snap.
Registry Entry
This sketch is registered in src/config/sketches.ts as:
{
id: 'images-swiper',
title: 'Swiper',
category: 'images',
path: '/images/swiper',
order: 10,
previewMode: 'iframe',
resources: ['https://www.youtube.com/@codingmath/videos'],
}
Tech Stack
| Layer | Tool |
|---|
| Renderer | usePixiApp composable, PixiJS v8 |
| Shader | Custom Filter with GlProgram, UniformGroup |
| Assets | PixiJS Assets.addBundle / Assets.loadBundle |
| Animation | GSAP gsap.to() on the UniformGroup uniforms |
| Reactivity / events | VueUse useWindowSize, useEventListener, useTemplateRefsList |
| GUI | LilGui component (transition duration slider) |
| Performance overlay | StatsPanel component |
| External links | ResourcesList component |
Shader Uniforms
The GLSL filter receives the following uniforms, set through PixiJS UniformGroup and direct texture binding:
| Uniform | Type | Description |
|---|
uTextureOne | sampler2D | The current (outgoing) image texture |
uTextureTow | sampler2D | The target (incoming) image texture. Note: the name Tow matches the JavaScript property intentionally |
uvAspect | vec2 | UV scale correction vector for cover-fit cropping. Recomputed whenever the window resizes |
uProgress | float | Transition progress from 0.0 (fully showing uTextureOne) to 1.0 (fully showing uTextureTow), animated by GSAP |
uGridDivs | float | Number of vertical grid slices. 10 on mobile / 25 on tablet / 50 on desktop |
uAngle | float | Rotation angle applied to the displacement vector. π/10 on mobile / π/6 on tablet / π/4 on desktop |
Responsive Layout Adaptation
updatePixiJSLayout() runs on mount and whenever the window size changes. It resizes the renderer, recalculates cover-fit UV correction, and updates the grid uniforms based on breakpoints:
function updatePixiJSLayout() {
if (!app.value || !sprite || !customFilter)
return
const width = windowWidth.value
const height = windowHeight.value
// 1. Resize the PixiJS renderer to match current window size.
app.value.renderer.resize(width, height)
// 2. Scale the sprite to cover the entire canvas/renderer.
sprite.width = width
sprite.height = height
// 3. Recalculate aspect ratio correction (cover behavior).
const windowAspect = width / height
const targetUniforms = customFilter.resources.aspectUniforms.uniforms
// Window is wider than the image: scale down Y to crop top/bottom.
if (windowAspect > imageAspect) {
targetUniforms.uvAspect = { x: 1.0, y: imageAspect / windowAspect }
}
// Window is taller than the image: scale down X to crop left/right.
else {
targetUniforms.uvAspect = { x: windowAspect / imageAspect, y: 1.0 }
}
// 4. Calculate dynamic grid divisions and angle based on window width.
let gridDivs = 50.0
let angle = Math.PI / 4.0
if (width < 640) {
// Small screens (phone) -> 10 slices, smaller angle (Math.PI / 10)
gridDivs = 10.0
angle = Math.PI / 10.0
}
else if (width < 1024) {
// Medium screens (tablet) -> 25 slices, medium angle (Math.PI / 6)
gridDivs = 25.0
angle = Math.PI / 6.0
}
targetUniforms.uGridDivs = gridDivs
targetUniforms.uAngle = angle
}
Asset Loading
Images are registered as a named PixiJS bundle on first run. The guard Assets.resolver.hasBundle('gallery') prevents double-registration if the component hot-reloads:
if (!Assets.resolver.hasBundle('gallery')) {
Assets.addBundle('gallery', {
img1: '/images/1.webp',
img2: '/images/2.webp',
img3: '/images/3.webp',
})
}
const assets = await Assets.loadBundle('gallery')
After loading, imageAspect is derived from the first image’s natural dimensions to drive all cover-fit calculations.
Transition Logic
Each hover section maps to an image by index. When a new section is entered:
- If
uProgress >= 0.5, the transition is more than halfway through — the textures are swapped and progress is mirrored (1 - currentProgress) so the incoming image becomes the new base, avoiding a backwards flash.
- The target texture is updated to the hovered section’s image.
- Any in-flight GSAP tween is killed with
gsap.killTweensOf(targetUniforms).
- A new tween animates
uProgress → 1 with power3.out easing.
secRefs.value.forEach((sec, index) => {
useEventListener(sec, 'mouseenter', () => {
if (activeIndex === index)
return
activeIndex = index
if (customFilter) {
const currentProgress = targetUniforms.uProgress
if (currentProgress >= 0.5) {
customFilter.resources.uTextureOne = customFilter.resources.uTextureTow
targetUniforms.uProgress = 1 - currentProgress
}
customFilter.resources.uTextureTow = assets[`img${index + 1}`].source
gsap.killTweensOf(targetUniforms)
gsap.to(targetUniforms, {
uProgress: 1,
duration: duration.value,
ease: 'power3.out',
})
}
})
})
Fragment Shader — Grid Displacement
The full swiper.frag shader implements the visual transition in six steps:
precision highp float;
in vec2 vTextureCoord;
uniform mat3 mappedMatrix;
uniform sampler2D uTextureOne;
uniform sampler2D uTextureTow;
uniform vec2 uvAspect;
uniform float uProgress;
uniform float uGridDivs;
uniform float uAngle;
mat2 rotate(float a) {
float s = sin(a);
float c = cos(a);
return mat2(c, -s, s, c);
}
void main() {
// 1. Homogeneous coordinate mapping
vec3 map = vec3(vTextureCoord.xy, 1.0);
// 2. Aspect ratio correction (centered cover crop)
vec2 uv = (map.xy - 0.5) * uvAspect + 0.5;
// 3. UV division — creates uGridDivs repeating [0,1] gradients horizontally
vec2 uvDivided = fract(uv * vec2(uGridDivs, 1.0));
// 4. Progress clamp
float progress = clamp(uProgress, 0.0, 1.0);
// 5. Rotated displacement — outgoing image displaced by progress,
// incoming image displaced by (1 - progress)
vec2 uvDisplaced1 = uv + rotate(uAngle) * uvDivided * progress * 0.1;
vec2 uvDisplaced2 = uv + rotate(uAngle) * uvDivided * (1.0 - progress) * 0.1;
// 6. Sample and blend
vec4 img1 = texture2D(uTextureOne, uvDisplaced1);
vec4 img2 = texture2D(uTextureTow, uvDisplaced2);
gl_FragColor = mix(img1, img2, progress);
}
The key insight is that the grid slices — created by fract(uv * uGridDivs) — give each vertical column its own local displacement vector. As uProgress rises, img1’s columns shear away while img2’s columns converge into place, rotated diagonally by uAngle.
Vertex Shader
The swiper.vert shader maps PixiJS aPosition (range [0, 1]) to WebGL clip space ([-1, 1]) and flips the Y axis to match Pixi’s top-down coordinate system:
in vec2 aPosition;
out vec2 vTextureCoord;
void main(void) {
gl_Position = vec4(aPosition * 2.0 - 1.0, 0.0, 1.0);
gl_Position.y = -gl_Position.y;
vTextureCoord = aPosition;
}
The GLSL shader files (swiper.frag / swiper.vert) are imported as raw strings using Vite’s ?raw suffix and passed directly to GlProgram.from({ vertex, fragment }). This avoids any shader compilation step at build time — the strings are compiled by the GPU driver at runtime when PixiJS first creates the filter.