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 オリジナル · SCSS と React は zip に同梱 · 音楽 プレイヤー デザイン 9種 — 計算で動くカスタムUI
このデモが属する記事
音楽 プレイヤー デザイン 9種 — 計算で動くカスタムUI音楽 プレイヤー デザイン 9種のうち、1つは複数のサイン波を重ねてバーの高さを先に計算しイコライザーのように揺らし、1つは角速度の値を徐々に落としてレコード盤を慣性で止め、1つはタイムライン値で歌詞のハイライト行を探して中央へスクロールし この記事のデモをすべて見る


