03각속도 값으로 도는 턴테이블 앨범 아트
재생 상태에 따라 각속도 값을 계산해 앨범 아트 원판과 톤암 각도를 매 프레임 회전시키고, 일시정지하면 그 값을 서서히 줄여 관성으로 천천히 멈추는 것처럼 보이게 한다.
쓰임레트로 플레이어·앨범 상세 페이지
SPIN_DECAYARM_ANGLE_STARTstep

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-play" viewBox="0 0 256 256"><g fill="currentColor"><path d="M228.23 134.69L84.15 222.81A8 8 0 0 1 72 216.12V39.88a8 8 0 0 1 12.15-6.69l144.08 88.12a7.82 7.82 0 0 1 0 13.38" opacity=".2"/><path d="M232.4 114.49L88.32 26.35a16 16 0 0 0-16.2-.3A15.86 15.86 0 0 0 64 39.87v176.26A15.94 15.94 0 0 0 80 232a16.07 16.07 0 0 0 8.36-2.35l144.04-88.14a15.81 15.81 0 0 0 0-27ZM80 215.94V40l143.83 88Z"/></g></symbol>
<symbol id="ico-pause" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 48v160a8 8 0 0 1-8 8h-40a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a8 8 0 0 1 8 8M96 40H56a8 8 0 0 0-8 8v160a8 8 0 0 0 8 8h40a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" opacity=".2"/><path d="M200 32h-40a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176h-40V48h40ZM96 32H56a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H56V48h40Z"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="tt is-demo is-playing" id="tt">
<p class="tt__hint"><span>재생을 누르면 각속도 값으로 앨범판이 돌고, 일시정지하면 관성으로 서서히 멈춥니다</span></p>
<div class="tt__panel">
<div class="tt__deck">
<div class="tt__disc" id="disc"></div>
<div class="tt__arm" id="arm"></div>
</div>
<button class="tt__play" id="play" type="button" aria-label="재생">
<svg class="tt__ico tt__ico--play" aria-hidden="true"><use href="#ico-play"></use></svg>
<svg class="tt__ico tt__ico--pause" aria-hidden="true"><use href="#ico-pause"></use></svg>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(각속도 감쇠·톤암 진행률)은 index.html 의 step() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-orange);
.tt {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(400px, 92vw);
}
.tt__hint {
margin: 0;
color: rgba($subject-ink, .8);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.tt__panel {
width: 100%;
border-radius: $r-card;
background: rgba($subject-ink, .07);
box-shadow: 0 0 0 1px rgba($subject-ink, .16);
padding: $sp-6 $sp-5;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-6;
}
.tt__deck {
position: relative;
width: 120px;
height: 120px;
flex: 0 0 auto;
}
// 광택 sweep — 반사 하이라이트는 고정, 판만 그 밑에서 회전한다
.tt__deck::after {
content: "";
position: absolute;
inset: 0;
border-radius: $r-pill;
background: linear-gradient(115deg, rgba(#fff, .32) 0%, transparent 16%, transparent 82%, rgba(#fff, .16) 100%);
pointer-events: none;
}
.tt__disc {
position: absolute;
inset: 0;
border-radius: $r-pill;
background:
radial-gradient(circle at center, $subject-cream 0 18%, rgba($subject-ink, .18) 19% 21%, $subject-cream 22%),
repeating-radial-gradient(circle at center, rgba($subject-ink, .12) 0 2px, transparent 2px 6px);
box-shadow: 0 0 0 1px rgba($subject-ink, .18);
transform: rotate(0deg);
transform-origin: center center; // 원판은 자기 중심으로 돈다 — 중심 기준
}
// 회전 비대칭 홈 하이라이트 — conic 밴드가 원판과 함께 돌아 화면에서 픽셀이 바뀐다
.tt__disc::before {
content: "";
position: absolute;
inset: 0;
border-radius: $r-pill;
background: repeating-conic-gradient(
rgba(#fff, .18) 0deg 3deg,
transparent 3deg 20deg,
rgba($subject-ink, .22) 20deg 24deg,
transparent 24deg 40deg
);
}
// 라벨의 비대칭 색 분할 — 경계가 회전하며 화면 위치를 옮긴다
.tt__disc::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 40%;
height: 40%;
transform: translate(-50%, -50%);
border-radius: $r-pill;
background: conic-gradient(
from 15deg,
$stage-orange 0deg 150deg,
$subject-ink 150deg 172deg,
$subject-cream 172deg 330deg,
$stage-orange 330deg 360deg
);
box-shadow: 0 0 0 1px rgba($subject-ink, .2);
}
.tt__arm {
position: absolute;
top: -6px;
right: 2px;
width: 76px;
height: 6px;
border-radius: $r-xs;
background: $subject-ink;
transform: rotate(-18deg);
transform-origin: top right; // 톤암은 오른쪽 위 받침이 실제 회전축 — 중심 기준이 아니다
}
.tt__arm::after {
content: "";
position: absolute;
left: 0;
top: -3px;
width: 10px;
height: 12px;
border-radius: $r-xs;
background: $subject-cream;
}
.tt__play {
flex: 0 0 auto;
width: $sp-10;
height: $sp-10;
border-radius: $r-pill;
border: none;
background: $color;
color: $stage-orange;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform $duration $easing;
}
.tt__play:hover { transform: scale(1.06); }
.tt__play:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.tt__play:active { transform: scale(.96); }
.tt__ico { width: 18px; height: 18px; }
.tt__ico--pause { display: none; }
.is-playing .tt__ico--play { display: none; }
.is-playing .tt__ico--pause { display: block; }
@media (max-width: 340px) {
.tt__panel { padding: $sp-4 $sp-3; gap: $sp-4; }
.tt__deck { width: 90px; height: 90px; }
.tt__arm { width: 58px; }
.tt__play { width: $sp-8; height: $sp-8; }
.tt__ico { width: 15px; height: 15px; }
}
@media (prefers-reduced-motion: reduce) {
.tt__play { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tt {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(400px, 92vw);
}
.tt__hint {
margin: 0;
color: rgba(23, 20, 26, 0.8);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.tt__panel {
width: 100%;
border-radius: 16px;
background: rgba(23, 20, 26, 0.07);
box-shadow: 0 0 0 1px rgba(23, 20, 26, 0.16);
padding: 24px 20px;
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
}
.tt__deck {
position: relative;
width: 120px;
height: 120px;
flex: 0 0 auto;
}
.tt__deck::after {
content: "";
position: absolute;
inset: 0;
border-radius: 999px;
background: linear-gradient(115deg, rgba(255, 255, 255, 0.32) 0%, transparent 16%, transparent 82%, rgba(255, 255, 255, 0.16) 100%);
pointer-events: none;
}
.tt__disc {
position: absolute;
inset: 0;
border-radius: 999px;
background: radial-gradient(circle at center, #fff7e6 0 18%, rgba(23, 20, 26, 0.18) 19% 21%, #fff7e6 22%), repeating-radial-gradient(circle at center, rgba(23, 20, 26, 0.12) 0 2px, transparent 2px 6px);
box-shadow: 0 0 0 1px rgba(23, 20, 26, 0.18);
transform: rotate(0deg);
transform-origin: center center;
}
.tt__disc::before {
content: "";
position: absolute;
inset: 0;
border-radius: 999px;
background: repeating-conic-gradient(rgba(255, 255, 255, 0.18) 0deg 3deg, transparent 3deg 20deg, rgba(23, 20, 26, 0.22) 20deg 24deg, transparent 24deg 40deg);
}
.tt__disc::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 40%;
height: 40%;
transform: translate(-50%, -50%);
border-radius: 999px;
background: conic-gradient(from 15deg, #ff4d1f 0deg 150deg, #17141a 150deg 172deg, #fff7e6 172deg 330deg, #ff4d1f 330deg 360deg);
box-shadow: 0 0 0 1px rgba(23, 20, 26, 0.2);
}
.tt__arm {
position: absolute;
top: -6px;
right: 2px;
width: 76px;
height: 6px;
border-radius: 4px;
background: #17141a;
transform: rotate(-18deg);
transform-origin: top right;
}
.tt__arm::after {
content: "";
position: absolute;
left: 0;
top: -3px;
width: 10px;
height: 12px;
border-radius: 4px;
background: #fff7e6;
}
.tt__play {
flex: 0 0 auto;
width: 40px;
height: 40px;
border-radius: 999px;
border: none;
background: #17141a;
color: #ff4d1f;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 300ms ease-out;
}
.tt__play:hover {
transform: scale(1.06);
}
.tt__play:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.tt__play:active {
transform: scale(0.96);
}
.tt__ico {
width: 18px;
height: 18px;
}
.tt__ico--pause {
display: none;
}
.is-playing .tt__ico--play {
display: none;
}
.is-playing .tt__ico--pause {
display: block;
}
@media (max-width: 340px) {
.tt__panel {
padding: 16px 12px;
gap: 16px;
}
.tt__deck {
width: 90px;
height: 90px;
}
.tt__arm {
width: 58px;
}
.tt__play {
width: 32px;
height: 32px;
}
.tt__ico {
width: 15px;
height: 15px;
}
}
@media (prefers-reduced-motion: reduce) {
.tt__play {
transition: none;
}
}(function () {
var root = document.getElementById('tt');
var playBtn = document.getElementById('play');
var disc = document.getElementById('disc');
var arm = document.getElementById('arm');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
var PLAY_LENGTH_S = 214; // 합성 트랙 길이 — 톤암이 바깥에서 안쪽까지 이동하는 기준 시간
var TARGET_VELOCITY = 200; // 33+1/3 rpm 을 각속도(도/초)로 환산한 값
var SPIN_UP_RATE = 0.06; // 정지 상태에서 재생 각속도까지 붙는 속도(프레임당 비율)
var SPIN_DECAY = 0.965; // 관성 감쇠 값 — 변형: 키우면 일시정지 후 멈추기까지 시간이 길어진다
var ARM_ANGLE_START = -18; // 바깥쪽 홈(재생 시작 위치)
var ARM_ANGLE_END = -34; // 안쪽 홈(재생 끝 위치)
var angle = 0, velocity = 0, progressS = 0, playing = true, demoPlaying = true, last = 0, driveStart = 0;
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
function setPlaying(next) {
playing = next;
root.classList.toggle('is-playing', next);
}
function step(effPlaying, dt) {
if (effPlaying) {
velocity += (TARGET_VELOCITY - velocity) * SPIN_UP_RATE;
progressS = Math.min(PLAY_LENGTH_S, progressS + dt);
} else {
velocity *= SPIN_DECAY; // 매 프레임 각속도를 줄여 관성으로 멎는 것처럼 보이게 한다
}
angle = (angle + velocity * dt) % 360;
disc.style.transform = 'rotate(' + angle.toFixed(2) + 'deg)';
var t = progressS / PLAY_LENGTH_S;
var armAngle = ARM_ANGLE_START + (ARM_ANGLE_END - ARM_ANGLE_START) * t;
arm.style.transform = 'rotate(' + armAngle.toFixed(2) + 'deg)';
}
function driveTick(now) {
if (!driveStart) driveStart = now;
if (isDemo) {
var DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
var frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
demoPlaying = frac < 0.6; // 앞 60% 구간 재생, 뒤 40% 구간 정지 → 감쇠 구간을 눈에 보이게 한다
}
requestAnimationFrame(driveTick);
}
function frame(now) {
if (!last) last = now;
var dt = Math.min(0.05, (now - last) / 1000);
last = now;
step(isDemo ? demoPlaying : playing, dt);
requestAnimationFrame(frame);
}
function onPlayClick() { human(); setPlaying(!playing); }
function onPlayKeydown(e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); setPlaying(!playing); }
}
if (reduce) {
root.classList.add('is-still');
disc.style.transform = 'rotate(0deg)';
arm.style.transform = 'rotate(' + ARM_ANGLE_START + 'deg)';
playBtn.addEventListener('click', function () {
human();
setPlaying(!playing);
arm.style.transform = 'rotate(' + (playing ? ARM_ANGLE_END : ARM_ANGLE_START) + 'deg)';
});
playBtn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); human(); setPlaying(!playing);
arm.style.transform = 'rotate(' + (playing ? ARM_ANGLE_END : ARM_ANGLE_START) + 'deg)';
}
});
} else {
playBtn.addEventListener('click', onPlayClick);
playBtn.addEventListener('keydown', onPlayKeydown);
requestAnimationFrame(frame);
requestAnimationFrame(driveTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./VinylSpin.module.scss";
export default function VinylSpin() {
const rootRef = useRef<HTMLDivElement | null>(null);
const playRef = useRef<HTMLButtonElement | null>(null);
const discRef = useRef<HTMLDivElement | null>(null);
const armRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const playBtn = playRef.current;
const disc = discRef.current;
const arm = armRef.current;
if (!root || !playBtn || !disc || !arm) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
const PLAY_LENGTH_S = 214; // 합성 트랙 길이 — 톤암이 바깥에서 안쪽까지 이동하는 기준 시간
const TARGET_VELOCITY = 200; // 33+1/3 rpm 을 각속도(도/초)로 환산한 값
const SPIN_UP_RATE = 0.06; // 정지 상태에서 재생 각속도까지 붙는 속도(프레임당 비율)
const SPIN_DECAY = 0.965; // 관성 감쇠 값 — 변형: 키우면 일시정지 후 멈추기까지 시간이 길어진다
const ARM_ANGLE_START = -18; // 바깥쪽 홈(재생 시작 위치)
const ARM_ANGLE_END = -34; // 안쪽 홈(재생 끝 위치)
let angle = 0, velocity = 0, progressS = 0, playing = true, demoPlaying = true, last = 0, driveStart = 0;
function human() { if (isDemo) { root!.classList.remove(styles["is-demo"]); isDemo = false; } }
function setPlaying(next: boolean) {
playing = next;
root!.classList.toggle(styles["is-playing"], next);
}
function step(effPlaying: boolean, dt: number) {
if (effPlaying) {
velocity += (TARGET_VELOCITY - velocity) * SPIN_UP_RATE;
progressS = Math.min(PLAY_LENGTH_S, progressS + dt);
} else {
velocity *= SPIN_DECAY; // 매 프레임 각속도를 줄여 관성으로 멎는 것처럼 보이게 한다
}
angle = (angle + velocity * dt) % 360;
disc!.style.transform = "rotate(" + angle.toFixed(2) + "deg)";
const t = progressS / PLAY_LENGTH_S;
const armAngle = ARM_ANGLE_START + (ARM_ANGLE_END - ARM_ANGLE_START) * t;
arm!.style.transform = "rotate(" + armAngle.toFixed(2) + "deg)";
}
let rafDrive = 0;
function driveTick(now: number) {
if (!driveStart) driveStart = now;
if (isDemo) {
const DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
const frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
demoPlaying = frac < 0.6; // 앞 60% 구간 재생, 뒤 40% 구간 정지 → 감쇠 구간을 눈에 보이게 한다
}
rafDrive = requestAnimationFrame(driveTick);
}
let rafFrame = 0;
function frame(now: number) {
if (!last) last = now;
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
step(isDemo ? demoPlaying : playing, dt);
rafFrame = requestAnimationFrame(frame);
}
function onPlayClick() { human(); setPlaying(!playing); }
function onPlayKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); human(); setPlaying(!playing); }
}
function onReduceClick() {
human();
setPlaying(!playing);
arm!.style.transform = "rotate(" + (playing ? ARM_ANGLE_END : ARM_ANGLE_START) + "deg)";
}
function onReduceKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); human(); setPlaying(!playing);
arm!.style.transform = "rotate(" + (playing ? ARM_ANGLE_END : ARM_ANGLE_START) + "deg)";
}
}
if (reduce) {
root.classList.add(styles["is-still"]);
disc.style.transform = "rotate(0deg)";
arm.style.transform = "rotate(" + ARM_ANGLE_START + "deg)";
playBtn.addEventListener("click", onReduceClick);
playBtn.addEventListener("keydown", onReduceKeydown);
} else {
playBtn.addEventListener("click", onPlayClick);
playBtn.addEventListener("keydown", onPlayKeydown);
rafFrame = requestAnimationFrame(frame);
rafDrive = requestAnimationFrame(driveTick);
}
return () => {
cancelAnimationFrame(rafFrame);
cancelAnimationFrame(rafDrive);
if (reduce) {
playBtn.removeEventListener("click", onReduceClick);
playBtn.removeEventListener("keydown", onReduceKeydown);
} else {
playBtn.removeEventListener("click", onPlayClick);
playBtn.removeEventListener("keydown", onPlayKeydown);
}
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.tt + " " + styles["is-demo"] + " " + styles["is-playing"]} ref={rootRef}>
<p className={styles.hint}><span>재생을 누르면 각속도 값으로 앨범판이 돌고, 일시정지하면 관성으로 서서히 멈춥니다</span></p>
<div className={styles.panel}>
<div className={styles.deck}>
<div className={styles.disc} ref={discRef} />
<div className={styles.arm} ref={armRef} />
</div>
<button className={styles.play} ref={playRef} type="button" aria-label="재생">
<svg className={`${styles.ico} ${styles.icoPlay}`} aria-hidden="true"><use href="#ico-play" /></svg>
<svg className={`${styles.ico} ${styles.icoPause}`} aria-hidden="true"><use href="#ico-pause" /></svg>
</button>
</div>
</div>
</main>
);
}03 각속도 값으로 도는 턴테이블 앨범 아트 — 측정 누적면적 7.679% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 오디오 플레이어 디자인 9종 — 계산으로 움직이는 플레이어 UI
이 데모가 속한 글
css 오디오 플레이어 디자인 9종 — 계산으로 움직이는 플레이어 UIcss 오디오 플레이어 디자인 아홉 가지 가운데 하나는 여러 사인 곡선을 겹쳐 막대 높이를 미리 계산해 이퀄라이저처럼 흔들고, 하나는 각속도 값을 서서히 줄여 앨범판이 관성으로 멈추게 하며, 하나는 타임라인 값으로 이 글의 데모 전부 보기


