Vinyl Scroll Exploration
I wanted digital crate-digging to feel less like a grid. Scroll through records, feel the stack shift, then open one up like an object.
This one gets technical because the small decisions make the feeling: motion values, generated audio, touch handling, haptics.
Origin
The spark came from UNVEIL. Their site makes browsing feel object-like. I wanted to study that feeling without going straight to a Three.js scene.
The constraint became the experiment: can 2D transforms, springs, blur, sound, and haptics fake enough depth to make covers feel handled?
Interaction Model
Scroll stays in charge. I did not want a drag engine pretending to be scroll.
Each record compares its index with scroll progress. That difference becomes its x/y offset, scale, and blur.
// Each record measures itself against the scroll timeline.
const activeOffset = useTransform(
progress,
(value) => index - value * Math.max(total - 1, 1),
);
// That offset becomes the fake depth system.
const x = useTransform(activeOffset, (value) => value * 80);
const y = useTransform(activeOffset, (value) => value * 52);
const scale = useTransform(activeOffset, (value) =>
Math.max(0.78, 1 - Math.abs(value) * 0.075),
);I liked how little machinery that needed. One scroll value, interpreted by every object in the stack.
Motion
The first version felt glued to the wheel. A spring on scrollYProgress gave the records a little patience.
// Native scroll stays in charge.
const { scrollYProgress } = useScroll({
container: scrollContainerRef,
});
// The spring makes the stack feel less glued to the wheel.
const smoothProgress = useSpring(scrollYProgress, {
damping: 34,
mass: 0.32,
stiffness: 150,
});I used layoutId on selection so the cover moves into focus instead of being swapped out. That transition does a lot.
On hover, the selected record splits into cover and disc. It should open up, not only grow.
Data & Tools
The records come from Spotify. The route loads a playlist, then each cover, title, artist, and URL becomes material for the stack.
I also pull color from the active cover so the background wash changes as you browse.
// Let the active cover tint the whole environment.
getImagePalette(track.cover).then((colors) => {
if (cancelled) return;
setBackground(colors.darkVibrant ?? colors.muted ?? "#111111");
});I used React, React Router, motion.dev, Tailwind CSS, Spotify, the Web Audio API, and web-haptics/react. I usually reach for GSAP when motion gets expressive, so this was a good excuse to push MotionValue primitives instead.
Audio Implementation
I did not use audio files. Every tick is generated in the browser. When the active record changes, a triangle oscillator goes through a low-pass filter and a short gain envelope.
// One tiny generated tick per record change.
oscillator.type = "triangle";
oscillator.frequency.setValueAtTime(180 + cardIndex * 18, now);
oscillator.frequency.exponentialRampToValueAtTime(
Math.max(90, 150 + cardIndex * 14 + direction * 24),
now + 0.08,
);
// Roll the top end off quickly so it feels physical, not alarm-y.
filter.type = "lowpass";
filter.frequency.setValueAtTime(950, now);
filter.frequency.exponentialRampToValueAtTime(380, now + 0.12);
// A short envelope keeps the sound tactile and out of the way.
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(0.045, now + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.16);It changes slightly with card index and direction. It is not music. It is contact.
Browsers need a user gesture before audio starts, so pointer, touch, wheel, and keyboard all use the same unlock path.
Mobile Input
Desktop worked first. Mobile needed another pass because touches hit the visible records before they reached the hidden scroll layer.
I bridged swipes into scrollBy() calls and suppressed the post-swipe click so browsing would not accidentally select a record.
// Mobile swipes become scroll movement inside the hidden container.
const deltaY = drag.lastY - touch.clientY;
event.preventDefault();
event.stopPropagation();
drag.lastY = touch.clientY;
drag.moved = true;
// Prevent the post-swipe click from selecting a record.
suppressCardSelectRef.current = true;
scrollContainerRef.current?.scrollBy({
top: deltaY,
behavior: "auto",
});What I Was Testing
The question was not whether album covers can move. It was whether scrolling could feel more intentional without getting heavy.
I care about that for collections where mood matters: albums, fashion archives, references, saved objects. Grids are fast. Sometimes they also make everything feel disposable.
I would not paste this pattern everywhere. I just like how it makes browsing feel handled.