Vinyl
import { useRef } from 'react';
const css = `
@keyframes card-vinyl-spin { to { transform: rotate(360deg); } }
.card-vinyl {
position: relative;
width: 250px;
height: 150px;
cursor: pointer;
}
.card-vinyl:focus-visible { outline: none; }
.card-vinyl__record {
position: absolute;
left: 14px;
top: 9px;
width: 128px;
height: 128px;
border-radius: 50%;
background:
radial-gradient(circle, light-dark(#50535b, #c6c8cd) 0 7%, light-dark(#ebedf0, #232529) 7.5% 24%, transparent 24.5%),
repeating-radial-gradient(circle, light-dark(#fbfcfd, #121417) 0 1.5px, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 6%, light-dark(#fbfcfd, #121417)) 1.5px 3px);
box-shadow: 0 6px 16px -6px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6));
transition: transform 0.7s cubic-bezier(0.34, 1.2, 0.64, 1);
}
/* A sheen that stays put while the record turns under it. */
.card-vinyl__record::after {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: conic-gradient(from 30deg, transparent 0 10%, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 14%, transparent) 15%, transparent 22% 60%, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 10%, transparent) 65%, transparent 72%);
}
.card-vinyl__grooves { position: absolute; inset: 0; border-radius: 50%; animation: card-vinyl-spin 1.8s linear infinite; animation-play-state: paused; }
.card-vinyl__grooves::before { content: ''; position: absolute; left: 50%; top: 18%; width: 2px; height: 10%; background: light-dark(#6d7179, #7d8087); }
.card-vinyl__sleeve {
position: absolute;
z-index: 1;
left: 0;
top: 0;
display: grid;
align-content: end;
width: 146px;
height: 146px;
padding: 12px;
border-radius: 10px;
background: linear-gradient(135deg, light-dark(#cbced3, #393c41), light-dark(#ebedf0, #232529) 60%, light-dark(#f3f4f7, #1a1c1f));
box-shadow: inset 0 0 0 1px light-dark(#cbced3, #393c41), 0 10px 22px -10px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7));
}
.card-vinyl__sleeve b { color: light-dark(#373a40, #f1f2f3); font-size: 14px; font-weight: 600; line-height: 1.1; }
.card-vinyl__sleeve span { color: light-dark(#50535b, #c6c8cd); font-size: 11px; }
.card-vinyl__sleeve::before {
content: '';
position: absolute;
left: 18px;
top: 18px;
width: 52px;
height: 52px;
border-radius: 50%;
border: 2px solid color-mix(in oklab, light-dark(#373a40, #f1f2f3) 50%, transparent);
}
.card-vinyl__arm {
position: absolute;
z-index: 2;
right: 4px;
top: 4px;
width: 8px;
height: 96px;
transform-origin: 4px 4px;
transform: rotate(4deg);
transition: transform 0.6s cubic-bezier(0.34, 1.3, 0.64, 1) 0.35s;
}
.card-vinyl__arm::before { content: ''; position: absolute; left: 0; top: 0; width: 8px; height: 8px; border-radius: 50%; background: light-dark(#5f626a, #a7abb2); }
.card-vinyl__arm::after { content: ''; position: absolute; left: 3px; top: 6px; width: 2px; height: 86px; border-radius: 1px; background: light-dark(#50535b, #c6c8cd); box-shadow: -2px 86px 0 2px light-dark(#42454c, #e5e6e9); }
.card-vinyl:hover .card-vinyl__record, .card-vinyl:focus-visible .card-vinyl__record { transform: translateX(86px); }
.card-vinyl:hover .card-vinyl__arm, .card-vinyl:focus-visible .card-vinyl__arm { transform: rotate(30deg); }
@media (prefers-reduced-motion: reduce) {
.card-vinyl__record, .card-vinyl__arm { transition-duration: 0.01s; }
.card-vinyl__grooves { animation: none; }
}
`;
export function VinylCard() {
const grooves = useRef<HTMLSpanElement>(null);
// Spin only while it is out of the sleeve.
const play = (on: boolean) => grooves.current?.style.setProperty('animation-play-state', on ? 'running' : 'paused');
return (
<>
<style>
{css}
</style>
<div
className="card-vinyl"
tabIndex={0}
role="group"
aria-label="Low Light by Neon Coast"
onPointerEnter={() => play(true)}
onPointerLeave={() => play(false)}
onFocus={() => play(true)}
onBlur={() => play(false)}
>
<div className="card-vinyl__record" aria-hidden="true">
<span ref={grooves} className="card-vinyl__grooves" />
</div>
<div className="card-vinyl__sleeve">
<b>Low Light</b>
<span>Neon Coast · LP</span>
</div>
<span className="card-vinyl__arm" aria-hidden="true" />
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardVinyl(el) on the mounted element instead.
<div class="card-vinyl" tabindex="0" role="group" aria-label="Low Light by Neon Coast">
<div class="card-vinyl__record" aria-hidden="true">
<span class="card-vinyl__grooves"></span>
</div>
<div class="card-vinyl__sleeve">
<b>Low Light</b>
<span>Neon Coast · LP</span>
</div>
<span class="card-vinyl__arm" aria-hidden="true"></span>
</div>@keyframes card-vinyl-spin { to { transform: rotate(360deg); } }
.card-vinyl {
position: relative;
width: 250px;
height: 150px;
cursor: pointer;
}
.card-vinyl:focus-visible { outline: none; }
.card-vinyl__record {
position: absolute;
left: 14px;
top: 9px;
width: 128px;
height: 128px;
border-radius: 50%;
background:
radial-gradient(circle, light-dark(#50535b, #c6c8cd) 0 7%, light-dark(#ebedf0, #232529) 7.5% 24%, transparent 24.5%),
repeating-radial-gradient(circle, light-dark(#fbfcfd, #121417) 0 1.5px, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 6%, light-dark(#fbfcfd, #121417)) 1.5px 3px);
box-shadow: 0 6px 16px -6px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6));
transition: transform 0.7s cubic-bezier(0.34, 1.2, 0.64, 1);
}
/* A sheen that stays put while the record turns under it. */
.card-vinyl__record::after {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: conic-gradient(from 30deg, transparent 0 10%, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 14%, transparent) 15%, transparent 22% 60%, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 10%, transparent) 65%, transparent 72%);
}
.card-vinyl__grooves { position: absolute; inset: 0; border-radius: 50%; animation: card-vinyl-spin 1.8s linear infinite; animation-play-state: paused; }
.card-vinyl__grooves::before { content: ''; position: absolute; left: 50%; top: 18%; width: 2px; height: 10%; background: light-dark(#6d7179, #7d8087); }
.card-vinyl__sleeve {
position: absolute;
z-index: 1;
left: 0;
top: 0;
display: grid;
align-content: end;
width: 146px;
height: 146px;
padding: 12px;
border-radius: 10px;
background: linear-gradient(135deg, light-dark(#cbced3, #393c41), light-dark(#ebedf0, #232529) 60%, light-dark(#f3f4f7, #1a1c1f));
box-shadow: inset 0 0 0 1px light-dark(#cbced3, #393c41), 0 10px 22px -10px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7));
}
.card-vinyl__sleeve b { color: light-dark(#373a40, #f1f2f3); font-size: 14px; font-weight: 600; line-height: 1.1; }
.card-vinyl__sleeve span { color: light-dark(#50535b, #c6c8cd); font-size: 11px; }
.card-vinyl__sleeve::before {
content: '';
position: absolute;
left: 18px;
top: 18px;
width: 52px;
height: 52px;
border-radius: 50%;
border: 2px solid color-mix(in oklab, light-dark(#373a40, #f1f2f3) 50%, transparent);
}
.card-vinyl__arm {
position: absolute;
z-index: 2;
right: 4px;
top: 4px;
width: 8px;
height: 96px;
transform-origin: 4px 4px;
transform: rotate(4deg);
transition: transform 0.6s cubic-bezier(0.34, 1.3, 0.64, 1) 0.35s;
}
.card-vinyl__arm::before { content: ''; position: absolute; left: 0; top: 0; width: 8px; height: 8px; border-radius: 50%; background: light-dark(#5f626a, #a7abb2); }
.card-vinyl__arm::after { content: ''; position: absolute; left: 3px; top: 6px; width: 2px; height: 86px; border-radius: 1px; background: light-dark(#50535b, #c6c8cd); box-shadow: -2px 86px 0 2px light-dark(#42454c, #e5e6e9); }
.card-vinyl:hover .card-vinyl__record, .card-vinyl:focus-visible .card-vinyl__record { transform: translateX(86px); }
.card-vinyl:hover .card-vinyl__arm, .card-vinyl:focus-visible .card-vinyl__arm { transform: rotate(30deg); }
@media (prefers-reduced-motion: reduce) {
.card-vinyl__record, .card-vinyl__arm { transition-duration: 0.01s; }
.card-vinyl__grooves { animation: none; }
}function initCardVinyl(root) {
var grooves = root.querySelector('.card-vinyl__grooves');
if (!grooves) return;
// Spin only while it is out of the sleeve.
function play() { grooves.style.animationPlayState = 'running'; }
function stop() { grooves.style.animationPlayState = 'paused'; }
root.addEventListener('pointerenter', play);
root.addEventListener('pointerleave', stop);
root.addEventListener('focus', play);
root.addEventListener('blur', stop);
return function () {
root.removeEventListener('pointerenter', play);
root.removeEventListener('pointerleave', stop);
root.removeEventListener('focus', play);
root.removeEventListener('blur', stop);
};
}
document.querySelectorAll('.card-vinyl').forEach(initCardVinyl);Goal: Create a "Vinyl" card.
Visual behavior: A record in its sleeve: on hover the vinyl slides out of the sleeve and spins up, its grooves catching a fixed sheen, while the tonearm swings in over it a beat later.
Technique:
- The record is a repeating radial gradient for the grooves with a label in the middle; a conic-gradient ::after on top stays still, so it reads as light on a turning disc.
- A marker on an inner element spins on a loop that is paused at rest and set running while hovered, so it only turns once it is out.
- Hover slides the record 86px right from behind the sleeve, and the tonearm (a line, a pivot and a head drawn with box-shadow) rotates in after a 0.35s delay.
Accessibility: The card is focusable (tabIndex 0) with role="group" and an aria-label that sums it up; keyboard focus plays the same reveal as hover. Under prefers-reduced-motion the record slides out without spinning.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Vinyl" card.
Visual behavior: A record in its sleeve: on hover the vinyl slides out of the sleeve and spins up, its grooves catching a fixed sheen, while the tonearm swings in over it a beat later.
Technique:
- The record is a repeating radial gradient for the grooves with a label in the middle; a conic-gradient ::after on top stays still, so it reads as light on a turning disc.
- A marker on an inner element spins on a loop that is paused at rest and set running while hovered, so it only turns once it is out.
- Hover slides the record 86px right from behind the sleeve, and the tonearm (a line, a pivot and a head drawn with box-shadow) rotates in after a 0.35s delay.
Accessibility: The card is focusable (tabIndex 0) with role="group" and an aria-label that sums it up; keyboard focus plays the same reveal as hover. Under prefers-reduced-motion the record slides out without spinning.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A record in its sleeve: on hover the vinyl slides out of the sleeve and spins up, its grooves catching a fixed sheen, while the tonearm swings in over it a beat later.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- hover, continuous, card
- Techniques
- radial gradient, keyframes, css transition, conic gradient
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The card is focusable (tabIndex 0) with role="group" and an aria-label that sums it up; keyboard focus plays the same reveal as hover. Under prefers-reduced-motion the record slides out without spinning.