Motion Proof Lab
8 self-contained interaction references -- craft infrastructure for thomaspeng.ca
Cursor System
data-cursor state machine
Hover each target -- the global follower changes state.
Magnetic elements
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
How the system works
- 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
Scrub
scrollbar as easing
Pin
freeze while animating
Skew
velocity reactive
Snap
playhead gravity
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; } - easing
cubic-bezier(0.22, 1, 0.36, 1) / 0.5s - JS needed
none -- plain <a href> only - reduced-motion
browser skips morph / CSS collapses to 0.01s - support
Chromium 126+ (progressive enhancement)
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.
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
Magnetic button micro-interaction
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.
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).