go home
Interaction Study

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.

Type - Interaction ExplorationMay 10, 2026Stack - React Router, React, motion.dev, Web Audio API, Spotify Web API, Tailwind CSS, web-haptics/react
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?

Interactive
Passionfruit
Passionfruit
Drake
Get It Together
Get It Together
Drake, Black Coffee, Jorja Smith
Losing
Losing
H.E.R.
Too Good
Too Good
Drake, Rihanna
One Dance
One Dance
Drake, Wizkid, Kyla
All To Me
All To Me
GIVĒON
Nothing to Me
Nothing to Me
Snoh Aalegra
I Am
I Am
Jorja Smith
Drama (feat. Drake)
Drama (feat. Drake)
Roy Woods, Drake
Be Honest (feat. Burna Boy)
Be Honest (feat. Burna Boy)
Jorja Smith, Burna Boy
Where Did I Go?
Where Did I Go?
Jorja Smith
Sure Thing
Sure Thing
Miguel
Pretty Little Fears (feat. J. Cole)
Pretty Little Fears (feat. J. Cole)
6LACK, J. Cole
Teenage Fever
Teenage Fever
Drake
Still Your Best
Still Your Best
GIVĒON
Blue Lights
Blue Lights
Jorja Smith
From Time
From Time
Drake, Jhené Aiko
Lovely
Lovely
Brent Faiyaz
LOVE. FEAT. ZACARI.
LOVE. FEAT. ZACARI.
Kendrick Lamar, Zacari
True Colors
True Colors
The Weeknd
Session 32
Session 32
Summer Walker
Marvins Room
Marvins Room
Drake
Losin Control
Losin Control
Russ
Every Kind Of Way
Every Kind Of Way
H.E.R.
Thinkin Bout You
Thinkin Bout You
Frank Ocean
Nobody But You
Nobody But You
Sonder, Jorja Smith
Broken Clocks
Broken Clocks
SZA
Can I
Can I
Kehlani
Favorite Mistake
Favorite Mistake
GIVĒON
Drunk Texting (feat. Jhené Aiko)
Drunk Texting (feat. Jhené Aiko)
Chris Brown, Jhené Aiko
Girls Need Love (with Drake) - Remix
Girls Need Love (with Drake) - Remix
Summer Walker, Drake
Big Girls
Big Girls
Masego
Shea Butter Baby (with J. Cole)
Shea Butter Baby (with J. Cole)
Ari Lennox, J. Cole
Hate the Club (feat. Masego)
Hate the Club (feat. Masego)
Kehlani, Masego
235 (2:35 I Want You)
235 (2:35 I Want You)
Ashanti
Blue Dream
Blue Dream
Jhené Aiko
Coaster
Coaster
Khalid
Get You (feat. Kali Uchis)
Get You (feat. Kali Uchis)
Daniel Caesar, Kali Uchis
Exchange
Exchange
Bryson Tiller
Jaded
Jaded
Drake
Jungle
Jungle
Drake
Outta Time (feat. Drake)
Outta Time (feat. Drake)
Bryson Tiller, Drake
Doing It Wrong
Doing It Wrong
Drake
Location
Location
Khalid
Gettin' Old
Gettin' Old
6LACK
Free Mind
Free Mind
Tems
Damages
Damages
Tems
Crew (feat. Brent Faiyaz & Shy Glizzy)
Crew (feat. Brent Faiyaz & Shy Glizzy)
GoldLink, Brent Faiyaz, Shy Glizzy
Sinner
Sinner
Adekunle Gold, Lucky Daye
understand
understand
OMAH LAY
Come Over
Come Over
melvitto, Gabzy
Addicted
Addicted
Jorja Smith
working
working
Tate McRae, Khalid
Love Yourz
Love Yourz
J. Cole
Energy
Energy
BNXN
Malibu (feat. KinKai)
Malibu (feat. KinKai)
ENNY, KinKai
None Of Your Concern (feat. Big Sean)
None Of Your Concern (feat. Big Sean)
Jhené Aiko, Big Sean
He's Not Into You
He's Not Into You
ENNY
Fire & Desire
Fire & Desire
Drake
Time
Time
Snoh Aalegra
Come and See Me (feat. Drake)
Come and See Me (feat. Drake)
PARTYNEXTDOOR, Drake
While We're Young
While We're Young
Jhené Aiko
Impatient
Impatient
Jeremih, Ty Dolla $ign
Fountains (with Tems)
Fountains (with Tems)
Drake, Tems
Karma
Karma
Ayra Starr
In The Morning
In The Morning
J. Cole, Drake
One Second (feat. H.E.R.)
One Second (feat. H.E.R.)
Stormzy, H.E.R.
Good Girls And Snapchat Hoes
Good Girls And Snapchat Hoes
Nasty C
Yo Love - From "Queen & Slim: The Soundtrack"
Yo Love - From "Queen & Slim: The Soundtrack"
Vince Staples, 6LACK, Mereba
Survivor's Guilt
Survivor's Guilt
Dave
Replay
Replay
Tems
Mine (feat. Drake)
Mine (feat. Drake)
Beyoncé, Drake
For Tonight
For Tonight
GIVĒON
Interference
Interference
Tems
Wrongs
Wrongs
Krept & Konan, Jhené Aiko
Higher
Higher
Tems
Girls Love Beyoncé (feat. James Fauntleroy)
Girls Love Beyoncé (feat. James Fauntleroy)
Drake, James Fauntleroy
Nowhere to Run (feat. Bryson Tiller)
Nowhere to Run (feat. Bryson Tiller)
Ryan Trey, Bryson Tiller
Feel No Ways
Feel No Ways
Drake
Love Galore (feat. Travis Scott)
Love Galore (feat. Travis Scott)
SZA, Travis Scott
CYANIDE
CYANIDE
Daniel Caesar
Could've Been (feat. Bryson Tiller)
Could've Been (feat. Bryson Tiller)
H.E.R., Bryson Tiller
The Need to Know (feat. SZA)
The Need to Know (feat. SZA)
Wale, SZA
What You Heard
What You Heard
Sonder
Do Not Disturb
Do Not Disturb
OMAH LAY
Over Here (feat. Drake)
Over Here (feat. Drake)
PARTYNEXTDOOR, Drake
Gonna Love Me
Gonna Love Me
Teyana Taylor
Over
Over
Drake
Falling
Falling
LADIPOE, Tems
Remember Me
Remember Me
Jeremih
Love All (with JAY-Z)
Love All (with JAY-Z)
Drake, JAŸ-Z
Gradually
Gradually
ASTN
No Joke
No Joke
Benji Mikel, BigTreeSteve
Do Not Disturb
Do Not Disturb
Drake
Law Of Attraction (feat. Snoh Aalegra)
Law Of Attraction (feat. Snoh Aalegra)
Dave, Snoh Aalegra
Trust
Trust
Brent Faiyaz
4422
4422
Drake, Sampha
Get Along Better
Get Along Better
Drake, Ty Dolla $ign
Deep
Deep
Wizkid
Chill Pitch
Chill Pitch
99 songs curated by Harry
Scroll to browse
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.

Next up
0x6 — VHS Shelf Interaction