Motion Proof Lab

8 self-contained interaction references -- craft infrastructure for thomaspeng.ca

Cursor System

Hover each target -- the global follower changes state.

defaultno attribute -- resting ring
hoverring expands to 56px
viewaccent ring 72px + label
dragdot becomes 20px square
hidefollower opacity 0

Move within ~80px -- element translates toward cursor by distance * strength, springs back with elastic.out(1, 0.3). Touch devices: static.

radius 80pxease elastic.out(1, 0.3)duration 1spointer:coarse disabled

  • Fixed follower: outer ring lerps at k=0.08 (soft trail), inner dot at k=0.2 (snappy) -- two layers give depth without a second DOM element.
  • State delegation: one mouseover listener on document walks .closest("[data-cursor]") -- zero per-component wiring required.
  • Native cursor is never hidden -- the custom layer is purely additive (dbushell a11y guidance, 2025).
  • prefers-reduced-motion: reduce -- follower hidden via CSS (display:none !important), magnetic rAF skipped in JS.
  • pointer:coarse -- entire magnetic + cursor effect tree is gated off; targets remain focusable and static.
How it works

Multi-state custom cursor follower (unseen.co pattern). A fixed DOM ring + dot, lerped via rAF (k=0.2 dot, k=0.08 ring). State machine driven by data-cursor attributes on hovered elements: default / hover / view / drag / hide. Additive -- native cursor stays visible. Disabled on pointer:coarse and prefers-reduced-motion.

Text Reveal

I build AI agents. Then I find out if they actually work.

Every project is a live experiment. I ship things fast, measure what breaks, and iterate until the output is genuinely useful -- not just technically impressive.

How it works

Clip-mask + translateY pattern (stefanvitasovic.dev / femmefatale.paris). Each line wrapped in overflow:hidden; inner span animates translateY(100% -> 0%) with expo.out easing. Uses GSAP SplitText if available (free in 3.13+), otherwise manual per-line split. aria-label set on parent so screen readers get whole text.

Scroll Choreography

scroll-choreographyscrub:1 • clamp:20deg • lerp:power3.out
01 / 04

Scrub

scrollbar as easing

02 / 04

Pin

freeze while animating

03 / 04

Skew

velocity reactive

04 / 04

Snap

playhead gravity

01 pin + scrub:102 progress bar03 velocity skew
How it works

GSAP ScrollTrigger with scrub:1 (1-second catch-up lerp). Scrubbed tweens use ease:'none' -- the scrollbar position IS the easing. Lenis lerp:0.1 is wired to gsap.ticker for smooth physics-school scroll feel. Pin + anticipatePin:1 for pinned sections.

Cross-Document View Transition

cross-document view transition

Click a card to navigate. The thumbnail morphs into the full hero with no JS -- the browser interpolates position and size.

  • opt-in@view-transition { navigation: auto; }
  • easingcubic-bezier(0.22, 1, 0.36, 1) / 0.5s
  • JS needednone -- plain <a href> only
  • reduced-motionbrowser skips morph / CSS collapses to 0.01s
  • supportChromium 126+ (progressive enhancement)
open /work to see the morph
How it works

Both pages opt in with @view-transition { navigation: auto; } in CSS. A plain <a href> (not next/link) triggers a real browser navigation so cross-document VT fires. The shared element -- work card thumbnail / detail hero -- carries matching view-transition-name: work-hero on both pages. The browser FLIPs its position and size automatically. Progressive enhancement: instant navigation in Firefox where cross-doc VT is behind a flag.

WebGL Hover Distortion

How it works

Three.js plane with displacement shader. Uniforms: uProgress (hover 0->1), uTime, uMouseOverPos (normalized cursor position), uScrollVelocity (from Lenis). gsap.to(uniforms.uProgress, {value:1, ease:'power2.out'}) on mouseenter. DPR capped at 2. Reduced-motion: uTime frozen at 0, uProgress stays at 0.

Preloader

Assets loaded. Ready to paint.

This content painted beneath the overlay so the browser registers LCP before the curtain lifts -- the loader never delays the largest paint.

typeslot-machineeaseexpo.outtotal~2.0scurtainclip-translateY
How it works

Self-contained preloader with replay button. Counter or letter-stagger approach -- must foreshadow the site's motion language (expo.out easing, same OKLCH palette). Content paints underneath the overlay so LCP is not blocked. Timeline uses position-parameter overlaps (40-70%) so the sequence reads as one gesture.

Magnetic Button

Move within the radius -- the button springs toward the cursor. Inner label leads the background for a depth illusion. Release springs it back. Disabled on touch + reduced-motion.

radius
80px
strength
0.28
k
180
b
22
m
1
radius
100px
strength
0.42
k
140
b
18
m
1
radius
120px
strength
0.58
k
100
b
14
m
1
How it works

On mousemove: offset from element center applied at fraction 0.3-0.5 via gsap.quickTo(el, 'x', {duration:1, ease:'elastic.out(1,0.3)'}). On mouseleave: spring back to 0 -- the elastic release is the signature feel. Disabled on pointer:coarse. Multiple buttons at different strengths show the parameter space.

Scroll Velocity Field

How it works

ScrollTrigger.getVelocity() fed into gsap.quickSetter(el, 'skewY', 'deg') via a proxy object. Velocity divided by ~-300, clamped to +-20deg, tweened back to 0 on decay. Same velocity value can feed a WebGL uScrollVelocity uniform for shader-on-scroll effects (Codrops canon, §2.4).