DEV Community

Cover image for Learning shader effect with Three.js TSL
Gen
Gen

Posted on

Learning shader effect with Three.js TSL

After years of building web applications I’d drifted away from artistically driven code; as a former Flash engineer I missed making visuals that move. This project was a quick, focused way to relearn shader math while exploring modern WebGPU tooling.

taotajima.jp

The first time I stumbled onto taotajima.jp I remember sitting there, utterly awestruck — the transition effect felt like a magical, tactile trick on the web. That moment stuck with me, and I knew I wanted to rebuild it with TSL as a learning exercise.

taotajima.jp hover animation

taotajima.jp hover animation

The Anatomy of the Effect

After doing some research I found this excellent write-up, Taotajima.jp WebGL deconstruction. Huge thanks to the author — the breakdown gave me the roadmap I needed to recreate this effect.

Diagonal delay

// enterUniform tweens from 0 1 (vice versa)
const delayValue = clamp(
    enterUniform.mul(6.5).sub(uv().y.mul(2.3)).add(uv().x.mul(1.2)).sub(1.8),
    0.0,
    1.0
  );
Enter fullscreen mode Exit fullscreen mode

This produces a diagonal sweep across the image (bottom-right → top-left). It's used in both texture translation and wavy distortion below.

Texture Translation with Acceleration

const translateValue = enterUniform.add(delayValue.mul(accelUniform));
const imageTranslate = vec2(-0.4, 1.2).mul(translateValue);
const videoTranslate = vec2(-0.4, 1.2).mul(
  translateValue.sub(1.0).sub(accelUniform)
);
Enter fullscreen mode Exit fullscreen mode

Both textures slide in opposite directions with acceleration, which gives the fold a satisfying 'lift and slide' feel.

Wavy Distortion

const waveTimeSpeed = uniform(config.wave.timeSpeed);
const waveAmplitude = uniform(
  new THREE.Vector2(config.wave.amplitude.x, config.wave.amplitude.y)
);
const waveUvScale = uniform(
  new THREE.Vector2(config.wave.uvScale.x, config.wave.uvScale.y)
);
const waveStrength = uniform(
  new THREE.Vector2(config.wave.strength.x, config.wave.strength.y)
);
const distortion = sin(
  sin(time.mul(waveTimeSpeed))
    .mul(waveAmplitude)
    .add(uv().yx.mul(waveUvScale))
).mul(waveStrength);
const xy = distortion.mul(
  pingpong(enterUniform).mul(0.6).add(pingpong(delayValue).mul(0.4))
);
Enter fullscreen mode Exit fullscreen mode

The distortion blends a wobble effect with a position-scaled wave so the fold looks soft instead of a flat translation.

Ripple Effect

// Circle SDF
const Circle = Fn(
  ([position, radius]: [THREE.ConstNode<THREE.Vector2>, number]) => {
    return length(position).sub(radius);
  }
);

const dist = Circle(
  vec2(posFromMouse.x.mul(aspectRatioUniform), posFromMouse.y),
  circleRadius
);
const wave = sin(dist.mul(rippleFreq).sub(time.mul(rippleSpeed)));
const ripple = wave.mul(boundary).mul(fade).mul(rippleAmp).mul(enterUniform);

material.positionNode = positionLocal.add(vec3(0, 0, ripple));
Enter fullscreen mode Exit fullscreen mode

This applies z-axis displacement ripples centered on the hover point.

When you combine all of the above, it actually works! That said, the original still looks so much better — huge respect to the original author.

It works!

It works!

The Scroll Sync

The second challenge was syncing smooth scroll with the canvas. Since the canvas needs to scroll in sync with DOM content, using native scroll creates a janky experience, especially on mobile devices.

Janky scroll on mobile

Janky scroll on mobile

Lenis to the rescue

Lenis gives a reliable, eased scroll value that’s easy to pipe straight into Three.js camera transforms. Using lenis.scroll avoids jankiness and lets the canvas follow the page precisely while keeping smooth motion.

const lenis = new Lenis({
  wrapper: scrollWrapper,  // Scrollable container
  content: scrollContent,  // Content element
  lerp: 0.1,              // Smoothing factor
  syncTouch: true         // Mobile support
});

const animate = (time: number) => {
  lenis.raf(time);
  // Sync camera position with scroll
  camera.position.y = -lenis.scroll;
  renderer.render(scene, camera);
};

renderer.setAnimationLoop(animate);
Enter fullscreen mode Exit fullscreen mode

Smooth(ish) scroll

Smooth(ish) scroll

Closing Thoughts

All in all, this was exactly the kind of satisfying project I needed. A weekend of tinkering with shaders, recreating something that genuinely impressed me, and getting my hands dirty with TSL.

The result isn't perfect, and I'm sure there are plenty of subtle techniques in the original that I haven't uncovered yet, but the learning process was worth every minute.

Thanks for reading through! Happy coding! ✨


Live Demo: taotajima-tsl.pages.dev
Source Code: github.com/zenoplex/taotajima-tsl
Images: pexels
Videos: grok imagine

Top comments (0)