№ 010
Living Sleeve
A record sleeve that is the player — press play and it glides aside to reveal a spinning record.
- Version
- 1.0.0
- Download
- 1.1 MB · 5 files
- Added
- Oct 26, 2026
- Built with
- HTML, CSS, JS
- Needs
- Google Fonts
- Tags
- music player, vinyl, web audio
Usage
Copy living-sleeve.css and living-sleeve.js (and assets/, if there is one) into your project, then:
new LivingSleeve(element, {
tracks: [{ artist, title: ['AFTER', 'HOURS'], side, duration, src? }], // duration in seconds; src is optional audio
vinyl: 'assets/vinyl.png',
paper: 'assets/paper.jpg',
onChange(track, index) {},
})
A record sleeve that is the player: press play and the sleeve glides left to reveal a spinning record;
pause and, once the record has stopped, it slides back into the sleeve.
Without `src`, each track plays a small generative lo-fi groove (Web Audio) with vinyl crackle.Source
/* Living Sleeve — a printed record sleeve that slides open to reveal a spinning record.
* --ls-s (the sleeve's side length) is set from JS; everything else is sized from it. */
.ls {
--ls-paper: #f2ede3;
--ls-ink: #20201e;
--ls-vermilion: #ef5037;
--ls-grey: #a49e94;
--ls-s: 280px;
position: relative;
width: 100%;
max-width: 560px;
height: calc(var(--ls-s) + var(--ls-s) * 0.17);
color: var(--ls-ink);
-webkit-tap-highlight-color: transparent;
}
/* the square the record and sleeve share: centred when closed; when open the record slides right
while the sleeve slides left, so the opened object stays centred too */
.ls-stage {
position: absolute;
left: 50%;
top: 0;
width: var(--ls-s);
height: var(--ls-s);
transform: translateX(-50%);
transition: transform 450ms cubic-bezier(0.16, 1, 0.3, 1) 70ms;
}
.ls.is-open .ls-stage { transform: translateX(calc(-50% + var(--ls-s) * 0.25)); }
/* ── the record ── */
.ls-disc {
position: absolute;
left: 4.5%;
top: 4.5%;
width: 91%;
height: 91%;
border-radius: 50%;
box-shadow: 10px 16px 24px rgba(40, 32, 22, 0.28), 2px 3px 5px rgba(40, 32, 22, 0.25);
will-change: transform;
}
.ls-vinyl {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
pointer-events: none;
}
/* printed label text on the vermilion centre (36.75% of the disc), so the rotation reads */
.ls-label {
position: absolute;
left: 50%;
top: 50%;
width: 36.75%;
height: 36.75%;
transform: translate(-50%, -50%);
pointer-events: none;
}
.ls-label span {
position: absolute;
left: 0;
right: 0;
text-align: center;
color: rgba(32, 32, 30, 0.82);
white-space: nowrap;
}
.ls-label-artist {
top: 22%;
font: 600 calc(var(--ls-s) * 0.022) / 1 "Inter", system-ui, sans-serif;
letter-spacing: 0.2em;
}
.ls-label-title {
bottom: 22%;
font: 400 calc(var(--ls-s) * 0.02) / 1 "IBM Plex Mono", ui-monospace, monospace;
letter-spacing: 0.08em;
}
/* ── the sleeve ── */
.ls-sleeve {
position: absolute;
inset: 0;
border-radius: 2.5px;
background-color: var(--ls-paper);
background-size: cover;
overflow: hidden;
/* one decisive shadow from the upper-left light, plus a thin printed edge */
box-shadow:
0 0 0 0.5px rgba(32, 32, 30, 0.14),
inset -2px 0 0 rgba(32, 32, 30, 0.06),
inset 0 -1px 0 rgba(32, 32, 30, 0.06),
14px 20px 30px -6px rgba(40, 32, 22, 0.34),
3px 4px 6px rgba(40, 32, 22, 0.18);
transition: transform 450ms cubic-bezier(0.16, 1, 0.3, 1) 70ms;
will-change: transform;
}
/* the open edge: a hint of card thickness where the record slides out */
.ls-sleeve::after {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 4px;
background: linear-gradient(90deg, rgba(32, 32, 30, 0), rgba(32, 32, 30, 0.08));
pointer-events: none;
}
.ls.is-open .ls-sleeve { transform: translateX(calc(var(--ls-s) * -0.5)); }
.ls-type {
position: absolute;
inset: 0;
}
.ls-type-layer {
position: absolute;
inset: 0;
padding: calc(var(--ls-s) * 0.065);
}
.ls-top {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.ls-artist {
font: 500 calc(var(--ls-s) * 0.032) / 1 "Inter", system-ui, sans-serif;
letter-spacing: 0.24em;
}
.ls-side {
color: var(--ls-grey);
font: 400 calc(var(--ls-s) * 0.026) / 1 "IBM Plex Mono", ui-monospace, monospace;
letter-spacing: 0.06em;
}
.ls-title {
position: absolute;
left: calc(var(--ls-s) * 0.058);
top: calc(var(--ls-s) * 0.25);
margin: 0;
display: flex;
flex-direction: column;
font: 400 calc(var(--ls-s) * 0.255) / 0.92 "Anton", "Impact", sans-serif;
letter-spacing: -0.005em;
text-transform: uppercase;
}
.ls-title em {
color: var(--ls-vermilion);
font-style: normal;
}
.ls-meta {
position: absolute;
left: calc(var(--ls-s) * 0.065);
bottom: calc(var(--ls-s) * 0.075);
display: flex;
flex-direction: column;
gap: 0.45em;
color: var(--ls-grey);
font: 400 calc(var(--ls-s) * 0.024) / 1 "IBM Plex Mono", ui-monospace, monospace;
letter-spacing: 0.08em;
}
/* ── controls ── */
.ls-play {
position: absolute;
right: calc(var(--ls-s) * 0.06);
bottom: calc(var(--ls-s) * 0.065);
width: calc(var(--ls-s) * 0.13);
height: calc(var(--ls-s) * 0.13);
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 50%;
background: var(--ls-ink);
color: var(--ls-paper);
cursor: pointer;
transition: transform 90ms ease-out, background-color 0.2s;
}
.ls-play svg { width: 46%; height: 46%; fill: currentColor; }
.ls-play:hover { background: #3a3935; }
.ls-play.is-press, .ls-play:active { transform: scale(0.9); }
.ls-play:focus-visible { outline: 2px solid var(--ls-vermilion); outline-offset: 3px; }
/* thin progress track along the sleeve's bottom edge; the tall hit area keeps it touchable */
.ls-seek {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: calc(var(--ls-s) * 0.045);
cursor: pointer;
touch-action: none;
}
.ls-seek::before,
.ls-seek-fill {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
transition: height 0.15s;
}
.ls-seek::before { background: rgba(164, 158, 148, 0.35); }
.ls-seek-fill { background: var(--ls-vermilion); transform-origin: left; transform: scaleX(0); }
.ls-seek:hover::before, .ls-seek:hover .ls-seek-fill, .ls-seek.is-seeking::before, .ls-seek.is-seeking .ls-seek-fill { height: 4px; }
.ls-seek:focus-visible { outline: none; }
.ls-seek:focus-visible::before { background: rgba(239, 80, 55, 0.35); }
/* strip revealed under the exposed record: previous, elapsed time, next */
.ls-strip {
position: absolute;
right: 0;
top: calc(var(--ls-s) * 1.05);
width: calc(var(--ls-s) * 0.62);
height: calc(var(--ls-s) * 0.1);
display: flex;
align-items: center;
justify-content: space-between;
opacity: 0;
transform: translateY(-6px);
pointer-events: none;
transition: opacity 0.3s ease 0.25s, transform 0.45s cubic-bezier(0.16, 1, 0.3, 1) 0.25s;
}
.ls.is-open .ls-strip { opacity: 1; transform: none; pointer-events: auto; }
.ls-btn {
width: calc(var(--ls-s) * 0.1);
height: calc(var(--ls-s) * 0.1);
min-width: 36px;
min-height: 36px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 50%;
background: none;
color: var(--ls-ink);
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
}
.ls-btn svg { width: 46%; height: 46%; fill: currentColor; }
.ls-btn:hover { background: rgba(32, 32, 30, 0.06); }
.ls-btn:active { background: rgba(32, 32, 30, 0.12); }
.ls-btn:focus-visible { outline: 2px solid var(--ls-vermilion); outline-offset: 1px; }
.ls-time {
color: #6e6a62;
font: 400 calc(var(--ls-s) * 0.032) / 1 "IBM Plex Mono", ui-monospace, monospace;
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
}
@media (prefers-reduced-motion: reduce) {
.ls-stage, .ls-sleeve, .ls-strip, .ls-play { transition: none; }
} /* Living Sleeve — usage:
* new LivingSleeve(element, {
* tracks: [{ artist, title: ['AFTER', 'HOURS'], side, duration, src? }], // duration in seconds; src is optional audio
* vinyl: 'assets/vinyl.png',
* paper: 'assets/paper.jpg',
* onChange(track, index) {},
* })
*
* A record sleeve that is the player: press play and the sleeve glides left to reveal a spinning record;
* pause and, once the record has stopped, it slides back into the sleeve.
* Without `src`, each track plays a small generative lo-fi groove (Web Audio) with vinyl crackle.
*/
(function () {
const SHIFT = 0.5; // how far the sleeve and record part, as a share of the sleeve width
// (0.5 exposes the right half of the vermilion label)
const RPM = 33 + 1 / 3;
const SPIN = (RPM / 60) * 360; // degrees per second at full speed
const SPIN_UP = 0.24; // time constant (s) for accelerating
const SPIN_DOWN = 0.45; // time constant (s) for slowing to a stop
const DISC_DELAY = 180; // ms after the press before the record starts turning
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const mmss = (s) => {
s = Math.max(0, Math.floor(s));
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
};
const ICON = {
play: '<svg viewBox="0 0 24 24"><path d="M8.5 6.2v11.6L18 12z"/></svg>',
pause: '<svg viewBox="0 0 24 24"><path d="M8 6h2.6v12H8zM13.4 6H16v12h-2.6z"/></svg>',
prev: '<svg viewBox="0 0 24 24"><path d="M7 6h1.8v12H7zM18 6.4v11.2L9.8 12z"/></svg>',
next: '<svg viewBox="0 0 24 24"><path d="M15.2 6H17v12h-1.8zM6 6.4v11.2L14.2 12z"/></svg>',
};
/* ── generative lo-fi groove: drums, bass, electric-piano chords and vinyl crackle ── */
const STYLES = [
{ bpm: 90, house: false, chords: [[53, 57, 60, 64], [52, 55, 59, 62], [50, 53, 57, 60], [48, 52, 55, 59]] }, // After Hours
{ bpm: 116, house: true, chords: [[57, 60, 64, 67, 71], [53, 57, 60, 64], [48, 52, 55, 59], [55, 59, 62, 65]] }, // Night Shift
{ bpm: 76, house: false, chords: [[50, 53, 57, 60, 64], [55, 59, 64, 65], [48, 52, 55, 59, 62], [57, 61, 64, 67]] }, // Low Light
];
const hz = (m) => 440 * 2 ** ((m - 69) / 12);
class Groove {
constructor(ctx = new (window.AudioContext || window.webkitAudioContext)()) {
this.ctx = ctx; // an OfflineAudioContext works too (used to bounce the music for the reel)
this.master = ctx.createGain();
this.master.gain.value = 0;
this.tone = ctx.createBiquadFilter(); // warm low-pass; closes when the record slows
this.tone.type = 'lowpass';
this.tone.frequency.value = 5200;
const comp = ctx.createDynamicsCompressor();
this.tone.connect(this.master).connect(comp).connect(ctx.destination);
this.noise = ctx.createBuffer(1, ctx.sampleRate * 2, ctx.sampleRate);
const d = this.noise.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
this.style = STYLES[0];
this.timer = null;
}
setStyle(i) {
this.style = STYLES[i % STYLES.length];
}
get stepDur() {
return 60 / this.style.bpm / 4; // sixteenth notes
}
start(elapsed) {
const { ctx } = this;
ctx.resume();
const t = ctx.currentTime;
this.master.gain.cancelScheduledValues(t);
this.master.gain.setTargetAtTime(0.55, t, 0.08);
this.tone.frequency.cancelScheduledValues(t);
this.tone.frequency.setTargetAtTime(5200, t, 0.1);
this.seek(elapsed);
this.startCrackle();
clearInterval(this.timer);
this.timer = setInterval(() => this.schedule(), 25);
}
// the record slowing: the sound darkens and fades with it
stop() {
const { ctx } = this;
const t = ctx.currentTime;
clearInterval(this.timer);
this.timer = null;
this.tone.frequency.setTargetAtTime(280, t, SPIN_DOWN * 0.6);
this.master.gain.setTargetAtTime(0, t, SPIN_DOWN * 0.7);
this.crackle?.stop(t + SPIN_DOWN * 4);
this.crackle = null;
}
// brief muffle while the record dips during a track change
dip() {
const t = this.ctx.currentTime;
this.tone.frequency.cancelScheduledValues(t);
this.tone.frequency.setValueAtTime(this.tone.frequency.value, t);
this.tone.frequency.linearRampToValueAtTime(700, t + 0.12);
this.tone.frequency.setTargetAtTime(5200, t + 0.25, 0.18);
}
seek(elapsed) {
this.step = Math.ceil(elapsed / this.stepDur);
this.next = this.ctx.currentTime + 0.05;
}
startCrackle() {
if (this.crackle) return;
const { ctx } = this;
const src = ctx.createBufferSource();
src.buffer = this.noise;
src.loop = true;
const hp = ctx.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = 2400;
const g = ctx.createGain();
g.gain.value = 0.018;
src.connect(hp).connect(g).connect(this.master);
src.start();
this.crackle = src;
}
schedule() {
while (this.next < this.ctx.currentTime + 0.12) {
this.playStep(this.step, this.next);
this.step++;
this.next += this.stepDur;
}
}
playStep(step, t) {
const s = step % 16, bar = Math.floor(step / 16);
const { house, chords } = this.style;
const chord = chords[bar % chords.length];
const swing = s % 2 ? this.stepDur * (house ? 0.06 : 0.18) : 0;
t += swing;
if (s === 0 || s === 8 || (house && (s === 4 || s === 12)) || (!house && s === 10 && bar % 2)) this.kick(t);
if (s === 4 || s === 12) this.snare(t);
if (house ? s % 4 === 2 : s % 2 === 0) this.hat(t, s % 4 === 2 ? 0.07 : 0.035);
if (s === 0 || s === 7 || s === 10) this.bass(hz(chord[0] - 24), t, s === 0 ? 0.42 : 0.3);
if (s === 0 || (house && s === 8) || (!house && s === 11 && bar % 2 === 0)) this.keys(chord, t, s === 0 ? 1 : 0.6);
if (Math.random() < 0.05) this.pop(t);
}
env(g, t, peak, attack, decay) {
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(peak, t + attack);
g.gain.exponentialRampToValueAtTime(0.0001, t + attack + decay);
}
kick(t) {
const { ctx } = this;
const o = ctx.createOscillator(), g = ctx.createGain();
o.frequency.setValueAtTime(130, t);
o.frequency.exponentialRampToValueAtTime(42, t + 0.12);
this.env(g, t, 0.9, 0.004, 0.32);
o.connect(g).connect(this.tone);
o.start(t);
o.stop(t + 0.4);
}
noiseHit(t, freq, q, peak, decay, type = 'bandpass') {
const { ctx } = this;
const src = ctx.createBufferSource(), f = ctx.createBiquadFilter(), g = ctx.createGain();
src.buffer = this.noise;
f.type = type;
f.frequency.value = freq;
f.Q.value = q;
this.env(g, t, peak, 0.002, decay);
src.connect(f).connect(g).connect(this.tone);
src.start(t, Math.random());
src.stop(t + decay + 0.05);
}
snare(t) {
this.noiseHit(t, 1800, 0.8, 0.35, 0.18);
const o = this.ctx.createOscillator(), g = this.ctx.createGain();
o.frequency.value = 190;
this.env(g, t, 0.18, 0.002, 0.09);
o.connect(g).connect(this.tone);
o.start(t);
o.stop(t + 0.12);
}
hat(t, peak) {
this.noiseHit(t, 8000, 1, peak, 0.05, 'highpass');
}
pop(t) {
this.noiseHit(t, 3000, 2, 0.05, 0.012);
}
bass(f, t, peak) {
const { ctx } = this;
const o = ctx.createOscillator(), g = ctx.createGain(), lp = ctx.createBiquadFilter();
o.type = 'triangle';
o.frequency.value = f;
lp.type = 'lowpass';
lp.frequency.value = 420;
this.env(g, t, peak, 0.01, 0.38);
o.connect(lp).connect(g).connect(this.tone);
o.start(t);
o.stop(t + 0.45);
}
// soft electric-piano chord: sine fundamentals with a quiet bell partial
keys(notes, t, level) {
const { ctx } = this;
notes.forEach((m, i) => {
const g = ctx.createGain();
this.env(g, t + i * 0.012, 0.06 * level, 0.012, 1.6);
g.connect(this.tone);
const a = ctx.createOscillator();
a.frequency.value = hz(m);
a.detune.value = (Math.random() - 0.5) * 8;
const b = ctx.createOscillator(), bg = ctx.createGain();
b.frequency.value = hz(m) * 3;
bg.gain.value = 0.12;
a.connect(g);
b.connect(bg).connect(g);
[a, b].forEach((o) => {
o.start(t + i * 0.012);
o.stop(t + 1.8);
});
});
}
}
class LivingSleeve {
constructor(root, { tracks, vinyl, paper, onChange } = {}) {
this.root = root;
this.tracks = tracks;
this.index = 0;
this.onChange = onChange;
this.playing = false;
this.open = false;
this.elapsed = 0;
this.angle = 0;
this.speed = 0; // 0..1 of full rotation speed
this.dip = 1; // brief slow-down multiplier during track changes
this.spinFrom = 0; // timestamp after which the record may accelerate
this.running = false;
this.build(vinyl, paper);
this.renderType(0, 0);
this.updateTime();
new ResizeObserver(() => this.layout()).observe(root);
this.layout();
}
get track() {
return this.tracks[this.index];
}
build(vinyl, paper) {
this.root.classList.add('ls');
this.root.innerHTML = `
<div class="ls-stage">
<div class="ls-disc" aria-hidden="true">
<img class="ls-vinyl" alt="" draggable="false">
<div class="ls-label"><span class="ls-label-artist"></span><span class="ls-label-title"></span></div>
</div>
<div class="ls-sleeve">
<div class="ls-type"></div>
<div class="ls-meta"><span>33⅓ RPM</span><span>STEREO RECORDINGS</span></div>
<button class="ls-play" type="button" aria-label="Play">${ICON.play}</button>
<div class="ls-seek" role="slider" tabindex="0" aria-label="Seek" aria-valuemin="0">
<div class="ls-seek-fill"></div>
</div>
</div>
</div>
<div class="ls-strip">
<button class="ls-btn ls-prev" type="button" aria-label="Previous track" tabindex="-1">${ICON.prev}</button>
<span class="ls-time" aria-live="off"></span>
<button class="ls-btn ls-next" type="button" aria-label="Next track" tabindex="-1">${ICON.next}</button>
</div>`;
const q = (s) => this.root.querySelector(s);
this.stage = q('.ls-stage');
this.disc = q('.ls-disc');
this.sleeve = q('.ls-sleeve');
this.type = q('.ls-type');
this.playBtn = q('.ls-play');
this.seek = q('.ls-seek');
this.fill = q('.ls-seek-fill');
this.strip = q('.ls-strip');
this.time = q('.ls-time');
this.labelArtist = q('.ls-label-artist');
this.labelTitle = q('.ls-label-title');
if (vinyl) q('.ls-vinyl').src = vinyl;
if (paper) this.sleeve.style.backgroundImage = `url("${paper}")`;
this.playBtn.addEventListener('click', () => this.toggle());
q('.ls-prev').addEventListener('click', () => this.go(-1));
q('.ls-next').addEventListener('click', () => this.go(1));
// seeking: press/drag along the sleeve's bottom edge, or arrow keys
const seekTo = (e) => {
const r = this.seek.getBoundingClientRect();
this.setElapsed(clamp((e.clientX - r.left) / r.width, 0, 1) * this.track.duration);
};
this.seek.addEventListener('pointerdown', (e) => {
this.seek.setPointerCapture(e.pointerId);
this.seek.classList.add('is-seeking');
seekTo(e);
});
this.seek.addEventListener('pointermove', (e) => {
if (this.seek.hasPointerCapture(e.pointerId)) seekTo(e);
});
const end = () => this.seek.classList.remove('is-seeking');
this.seek.addEventListener('pointerup', end);
this.seek.addEventListener('pointercancel', end);
this.seek.addEventListener('keydown', (e) => {
const step = { ArrowRight: 5, ArrowLeft: -5, Home: -Infinity, End: Infinity }[e.key];
if (step === undefined) return;
e.preventDefault();
this.setElapsed(clamp(this.elapsed + step, 0, this.track.duration - 0.5));
});
}
layout() {
const w = this.root.clientWidth;
if (!w) return;
const s = w / (1 + SHIFT); // the root reserves room for the open sleeve
this.root.style.setProperty('--ls-s', `${s}px`);
}
/* ── typography: artist, side, two-line title; changes with a short vertical slide ── */
typeMarkup(i) {
const t = this.tracks[i];
const [a, b] = t.title;
return `
<div class="ls-type-layer">
<div class="ls-top"><span class="ls-artist">${t.artist}</span><span class="ls-side">${t.side}</span></div>
<h2 class="ls-title"><span>${a}</span><span>${b}<em>.</em></span></h2>
</div>`;
}
renderType(i, dir) {
const t = this.tracks[i];
this.labelArtist.textContent = t.artist;
this.labelTitle.textContent = t.title.join(' ');
this.seek.setAttribute('aria-valuemax', String(t.duration));
// layers still sliding out from a previous change go immediately; every current layer slides out
this.type.querySelectorAll('.ls-type-layer.is-leaving').forEach((el) => el.remove());
const current = [...this.type.querySelectorAll('.ls-type-layer')];
this.type.insertAdjacentHTML('beforeend', this.typeMarkup(i));
const fresh = this.type.lastElementChild;
if (!dir || reducedMotion.matches) {
current.forEach((el) => el.remove());
return;
}
const opts = { duration: 300, easing: 'cubic-bezier(0.22, 1, 0.36, 1)', fill: 'both' };
current.forEach((el) => {
el.classList.add('is-leaving');
el.getAnimations().forEach((a) => a.cancel());
el.animate([{ transform: 'translateY(0)', opacity: 1 }, { transform: `translateY(${-dir * 26}%)`, opacity: 0 }], opts)
.finished.then(() => el.remove(), () => {});
setTimeout(() => el.remove(), opts.duration + 60); // backstop if the animation never reports finishing
});
fresh.animate([{ transform: `translateY(${dir * 26}%)`, opacity: 0 }, { transform: 'translateY(0)', opacity: 1 }], opts);
}
/* ── playback ── */
toggle() {
// 0–90 ms: the control compresses, then swaps icon
this.playBtn.classList.add('is-press');
setTimeout(() => this.playBtn.classList.remove('is-press'), 90);
if (this.playing) this.pause();
else this.play();
}
play() {
this.playing = true;
this.spinFrom = performance.now() + (this.open ? 60 : DISC_DELAY);
this.closeOnStop = false;
this.setOpen(true);
setTimeout(() => this.setIcon(), reducedMotion.matches ? 0 : 90);
if (this.audio) this.audio.play().catch(() => {});
else {
this.groove ??= new Groove();
this.groove.setStyle(this.index);
this.groove.start(this.elapsed);
}
this.loop();
}
pause() {
this.playing = false;
this.closeOnStop = true; // once the record stops, it goes back in the sleeve
setTimeout(() => this.setIcon(), reducedMotion.matches ? 0 : 90);
this.audio?.pause();
this.groove?.stop();
this.loop(); // keep animating while the record slows down
}
setIcon() {
this.playBtn.innerHTML = this.playing ? ICON.pause : ICON.play;
this.playBtn.setAttribute('aria-label', this.playing ? 'Pause' : 'Play');
}
setOpen(open) {
this.open = open;
this.root.classList.toggle('is-open', open);
this.strip.querySelectorAll('button').forEach((b) => (b.tabIndex = open ? 0 : -1));
}
// Closes the sleeve over the record (e.g. after the last track ends).
close() {
this.pause();
this.setOpen(false);
}
go(step) {
const i = (this.index + step + this.tracks.length) % this.tracks.length;
this.select(i, step);
}
select(i, dir = i > this.index ? 1 : -1) {
if (i === this.index) return;
this.index = i;
this.elapsed = 0;
this.dip = 0.3; // the record slows momentarily
this.renderType(i, dir);
this.setAudio();
if (this.groove && !this.audio) {
this.groove.setStyle(i);
if (this.playing) {
this.groove.dip();
this.groove.seek(0);
}
}
this.updateTime();
this.onChange?.(this.track, i);
this.loop();
}
setAudio() {
const src = this.track.src;
if (!src) {
this.audio?.pause();
this.audio = null;
if (this.playing) { // switching from a file back to the generated groove
this.groove ??= new Groove();
this.groove.setStyle(this.index);
this.groove.start(0);
}
return;
}
this.groove?.stop();
if (!this.audio) {
this.audio = new Audio();
this.audio.addEventListener('ended', () => this.ended());
}
this.audio.src = src;
if (this.playing) this.audio.play().catch(() => {});
}
setElapsed(s) {
this.elapsed = clamp(s, 0, this.track.duration);
if (this.audio) this.audio.currentTime = this.elapsed;
else if (this.playing) this.groove?.seek(this.elapsed);
this.updateTime();
}
ended() {
if (this.index < this.tracks.length - 1) this.go(1);
else {
this.select(0, 1);
this.close();
}
}
updateTime() {
const d = this.track.duration;
const p = clamp(this.elapsed / d, 0, 1);
this.fill.style.transform = `scaleX(${p})`;
this.time.textContent = `${mmss(this.elapsed)} / ${mmss(d)}`;
this.seek.setAttribute('aria-valuenow', String(Math.round(this.elapsed)));
this.seek.setAttribute('aria-valuetext', `${mmss(this.elapsed)} of ${mmss(d)}`);
}
/* ── animation: only runs while something is moving ── */
loop() {
if (this.running) return;
this.running = true;
let last = performance.now();
const tick = (now) => {
const dt = Math.min((now - last) / 1000, 0.1);
last = now;
// playback clock
if (this.playing) {
if (this.audio) this.elapsed = this.audio.currentTime || 0;
else this.elapsed += dt;
if (!this.audio && this.elapsed >= this.track.duration) this.ended();
this.updateTime();
}
// record: smooth spin-up, slower spin-down, brief dip on track changes
const target = this.playing && now >= this.spinFrom ? 1 : 0;
const tau = target > this.speed ? SPIN_UP : SPIN_DOWN;
this.speed += (target - this.speed) * (1 - Math.exp(-dt / tau));
this.dip += (1 - this.dip) * (1 - Math.exp(-dt / 0.35));
if (!reducedMotion.matches) {
this.angle = (this.angle + SPIN * this.speed * this.dip * dt) % 360;
this.disc.style.transform = `rotate(${this.angle.toFixed(2)}deg)`;
}
if (!this.playing && this.speed < 0.01) {
this.speed = 0;
this.running = false;
if (this.closeOnStop) {
this.closeOnStop = false;
this.setOpen(false);
}
return;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
}
LivingSleeve.Groove = Groove;
LivingSleeve.STYLES = STYLES;
window.LivingSleeve = LivingSleeve;
})(); <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Living Sleeve</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Anton&family=IBM+Plex+Mono:wght@400;500&family=Inter:wght@500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="living-sleeve.css">
<style>
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
display: grid;
place-items: center;
padding: 16px;
overflow: hidden;
/* warm stone-grey, nearly flat */
background: #d9d4cb;
font-family: "IBM Plex Mono", ui-monospace, monospace;
color: #20201e;
}
.page {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 560px;
gap: 44px;
}
.eyebrow,
.footer {
margin: 0;
font-size: 11px;
letter-spacing: 0.24em;
color: #6e6a62;
}
</style>
</head>
<body>
<main class="page">
<p class="eyebrow">THE LIVING SLEEVE</p>
<div id="player"></div>
<p class="footer">A STUDY IN MOTION & MUSIC</p>
</main>
<script src="living-sleeve.js"></script>
<script>
new LivingSleeve(document.getElementById('player'), {
vinyl: 'assets/vinyl.png',
paper: 'assets/paper.jpg',
tracks: [
{ artist: 'MONO CLUB', title: ['After', 'Hours'], side: 'SIDE A / 01', duration: 222 },
{ artist: 'MONO CLUB', title: ['Night', 'Shift'], side: 'SIDE A / 02', duration: 245 },
{ artist: 'MONO CLUB', title: ['Low', 'Light'], side: 'SIDE B / 01', duration: 198 },
],
});
</script>
</body>
</html> All 5 files in the zip
-
assets/paper.jpg -
assets/vinyl.png -
index.html -
living-sleeve.css -
living-sleeve.js