09블러 배경과 링이 한 화면에서 맞물리는 플레이어 히어로
앨범 아트 색상 값을 뽑아 흐릿하게 확대한 배경 위에 턴테이블·파형 막대·원형 링 세 계산 레이어를 한 좌표계로 겹쳐, 하나의 재생 진행률 값이 세 레이어를 동시에 움직이는 히어로 장면을 완성한다.
쓰임랜딩 히어로·앨범 발매 페이지
bandValuedrawRingRING_POINTS

<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="hero is-demo is-playing" id="hero">
<p class="hero__hint"><span>재생하면 저·중·고음 값이 배경 블러·링·막대에 동시에 맞물려 번집니다</span></p>
<div class="hero__panel" id="panel">
<div class="hero__backdrop" aria-hidden="true">
<div class="hero__blob hero__blob--a" id="blobA"></div>
<div class="hero__blob hero__blob--b" id="blobB"></div>
<div class="hero__blob hero__blob--c" id="blobC"></div>
</div>
<button class="hero__play" id="play" type="button" aria-label="재생">
<svg class="hero__ico hero__ico--play" aria-hidden="true"><use href="#ico-play"></use></svg>
<svg class="hero__ico hero__ico--pause" aria-hidden="true"><use href="#ico-pause"></use></svg>
</button>
<div class="hero__core">
<div class="hero__ring-wrap">
<canvas class="hero__ring" id="ring" width="128" height="128" aria-hidden="true"></canvas>
<div class="hero__art" id="art"></div>
<span class="hero__colorref" aria-hidden="true"></span>
</div>
</div>
<div class="hero__bars" id="bars" aria-hidden="true"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(대역 값 → 블러·링·막대 동시 반응)은 index.html 의 render()/drawRing() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-light;
@include stage($stage-ink);
.hero {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(420px, 94vw);
}
.hero__hint {
margin: 0;
color: rgba($subject-cream, .78);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.hero__panel {
position: relative;
width: 100%;
height: 190px;
border-radius: $r-card;
overflow: hidden;
background: rgba($subject-cream, .04);
box-shadow: 0 0 0 1px rgba($subject-cream, .14);
}
.hero__backdrop {
position: absolute;
inset: 0;
overflow: hidden;
}
.hero__blob {
position: absolute;
width: 130px;
height: 130px;
border-radius: $r-pill;
filter: blur(28px);
opacity: .3;
}
.hero__blob--a { left: 8%; top: 6%; background: $subject-mint; }
.hero__blob--b { right: 6%; top: 2%; background: $subject-pink; }
.hero__blob--c { left: 30%; bottom: -12%; background: $subject-sky; }
.hero__play {
position: absolute;
right: $sp-3;
top: $sp-3;
z-index: 3;
width: $sp-8;
height: $sp-8;
border-radius: $r-pill;
border: none;
background: $subject-cream;
color: $stage-ink;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform $duration $easing;
}
.hero__play:hover { transform: scale(1.06); }
.hero__play:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.hero__play:active { transform: scale(.96); }
.hero__ico { width: 15px; height: 15px; }
.hero__ico--pause { display: none; }
.is-playing .hero__ico--play { display: none; }
.is-playing .hero__ico--pause { display: block; }
.hero__core {
position: absolute;
inset: 0 0 34px 0;
display: grid;
place-items: center;
z-index: 1;
}
.hero__ring-wrap {
position: relative;
width: 128px;
height: 128px;
display: grid;
place-items: center;
}
.hero__ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.hero__art {
width: 56px;
height: 56px;
border-radius: $r-pill;
background: $color;
box-shadow: 0 0 0 1px rgba($subject-cream, .2);
}
.hero__colorref {
display: none;
color: $color;
}
.hero__bars {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 28px;
z-index: 1;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
padding: 0 $sp-4 $sp-2;
}
.hero__bar {
flex: 1 1 auto;
max-width: 6px;
height: 100%;
border-radius: $r-xs $r-xs 0 0;
background: rgba($subject-cream, .55);
transform: scaleY(.2);
transform-origin: bottom center; // 막대는 바닥이 기준점 — 중심 기준이 아니다
}
@media (max-width: 340px) {
.hero__panel { height: 108px; }
.hero__ring-wrap { width: 76px; height: 76px; }
.hero__art { width: 32px; height: 32px; }
.hero__bars { height: 20px; }
.hero__play { width: 28px; height: 28px; }
.hero__blob { width: 88px; height: 88px; }
}
@media (prefers-reduced-motion: reduce) {
.hero__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hero {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(420px, 94vw);
}
.hero__hint {
margin: 0;
color: rgba(255, 247, 230, 0.78);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.hero__panel {
position: relative;
width: 100%;
height: 190px;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 247, 230, 0.04);
box-shadow: 0 0 0 1px rgba(255, 247, 230, 0.14);
}
.hero__backdrop {
position: absolute;
inset: 0;
overflow: hidden;
}
.hero__blob {
position: absolute;
width: 130px;
height: 130px;
border-radius: 999px;
filter: blur(28px);
opacity: 0.3;
}
.hero__blob--a {
left: 8%;
top: 6%;
background: #4cd4a6;
}
.hero__blob--b {
right: 6%;
top: 2%;
background: #ff9ebb;
}
.hero__blob--c {
left: 30%;
bottom: -12%;
background: #6ec8ff;
}
.hero__play {
position: absolute;
right: 12px;
top: 12px;
z-index: 3;
width: 32px;
height: 32px;
border-radius: 999px;
border: none;
background: #fff7e6;
color: #17141a;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 300ms ease-out;
}
.hero__play:hover {
transform: scale(1.06);
}
.hero__play:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.hero__play:active {
transform: scale(0.96);
}
.hero__ico {
width: 15px;
height: 15px;
}
.hero__ico--pause {
display: none;
}
.is-playing .hero__ico--play {
display: none;
}
.is-playing .hero__ico--pause {
display: block;
}
.hero__core {
position: absolute;
inset: 0 0 34px 0;
display: grid;
place-items: center;
z-index: 1;
}
.hero__ring-wrap {
position: relative;
width: 128px;
height: 128px;
display: grid;
place-items: center;
}
.hero__ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.hero__art {
width: 56px;
height: 56px;
border-radius: 999px;
background: #5b8cf8;
box-shadow: 0 0 0 1px rgba(255, 247, 230, 0.2);
}
.hero__colorref {
display: none;
color: #5b8cf8;
}
.hero__bars {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 28px;
z-index: 1;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
padding: 0 16px 8px;
}
.hero__bar {
flex: 1 1 auto;
max-width: 6px;
height: 100%;
border-radius: 4px 4px 0 0;
background: rgba(255, 247, 230, 0.55);
transform: scaleY(0.2);
transform-origin: bottom center;
}
@media (max-width: 340px) {
.hero__panel {
height: 108px;
}
.hero__ring-wrap {
width: 76px;
height: 76px;
}
.hero__art {
width: 32px;
height: 32px;
}
.hero__bars {
height: 20px;
}
.hero__play {
width: 28px;
height: 28px;
}
.hero__blob {
width: 88px;
height: 88px;
}
}
@media (prefers-reduced-motion: reduce) {
.hero__play {
transition: none;
}
}(function () {
var root = document.getElementById('hero');
var playBtn = document.getElementById('play');
var canvas = document.getElementById('ring');
var ctx = canvas.getContext('2d');
var art = document.getElementById('art');
var colorRef = document.querySelector('.hero__colorref');
var blobA = document.getElementById('blobA');
var blobB = document.getElementById('blobB');
var blobC = document.getElementById('blobC');
var barsEl = document.getElementById('bars');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
// 결정론 난수 — Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 대역·막대·링 점의 위상을 만든다.
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rng = makeLcg(5527);
// 배경 블러·링·막대가 같은 저·중·고음 대역 값에 맞물리도록, 대역마다 사인 두 겹을 겹쳐 값을 하나씩만 계산한다.
var bandDefs = {
bass: { f1: 0.28 + rng() * 0.08, p1: rng() * Math.PI * 2, f2: 0.7 + rng() * 0.2, p2: rng() * Math.PI * 2, w2: 0.3 },
mid: { f1: 0.75 + rng() * 0.15, p1: rng() * Math.PI * 2, f2: 1.6 + rng() * 0.3, p2: rng() * Math.PI * 2, w2: 0.32 },
treble: { f1: 1.6 + rng() * 0.3, p1: rng() * Math.PI * 2, f2: 2.6 + rng() * 0.4, p2: rng() * Math.PI * 2, w2: 0.34 }
};
function bandValue(def, tSec) {
var s1 = Math.sin(tSec * def.f1 * Math.PI * 2 + def.p1);
var s2 = Math.sin(tSec * def.f2 * Math.PI * 2 + def.p2) * def.w2;
var v = (s1 + s2) / (1 + def.w2); // -1..1
return 0.5 + v * 0.5; // 0..1
}
var RING_POINTS = 32;
var ringPts = [];
for (var i = 0; i < RING_POINTS; i++) {
ringPts.push({ f: 2 + rng() * 1.4, p: rng() * Math.PI * 2, w: 0.35 + rng() * 0.3 });
}
var BAR_COUNT = 14;
var bars = [];
for (var i = 0; i < BAR_COUNT; i++) {
var el = document.createElement('div');
el.className = 'hero__bar';
barsEl.appendChild(el);
bars.push({ el: el, f: 3 + rng() * 1.6, p: rng() * Math.PI * 2, base: 0.18 + rng() * 0.1 });
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
var playing = true, t0 = 0;
var ringColor = '#3a3a3a';
function drawRing(tSec, treb) {
var w = canvas.width, h = canvas.height, cx = w / 2, cy = h / 2;
var innerR = 34, rangeR = 16;
ctx.clearRect(0, 0, w, h);
ctx.lineCap = 'round';
ctx.strokeStyle = ringColor;
for (var i = 0; i < RING_POINTS; i++) {
var pt = ringPts[i];
var s = Math.sin(tSec * pt.f + pt.p) * 0.5 + 0.5;
var v = 0.15 + s * pt.w * (0.4 + treb * 0.9);
var angle = (i / RING_POINTS) * Math.PI * 2 - Math.PI / 2;
var cos = Math.cos(angle), sin = Math.sin(angle);
var r2 = innerR + rangeR * Math.max(0.05, v);
ctx.globalAlpha = 0.3 + v * 0.6;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx + cos * innerR, cy + sin * innerR);
ctx.lineTo(cx + cos * r2, cy + sin * r2);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
function render(now, playingNow) {
var tSec = (now - t0) / 1000;
var bass = playingNow ? bandValue(bandDefs.bass, tSec) : 0.2;
var mid = playingNow ? bandValue(bandDefs.mid, tSec) : 0.2;
var treb = playingNow ? bandValue(bandDefs.treble, tSec) : 0.2;
// 배경 블러 세 덩이는 서로 다른 대역 값을 따라 각자 다른 방향으로 흐른다 — transform/opacity 만 사용
blobA.style.transform = 'translate(' + (Math.cos(tSec * 0.6) * 18 * (0.4 + bass)).toFixed(1) + 'px,' + (Math.sin(tSec * 0.5) * 14 * (0.4 + bass)).toFixed(1) + 'px)';
blobA.style.opacity = String(0.35 + bass * 0.35);
blobB.style.transform = 'translate(' + (Math.cos(tSec * 0.4 + 2) * 16 * (0.4 + mid)).toFixed(1) + 'px,' + (Math.sin(tSec * 0.7 + 1) * 18 * (0.4 + mid)).toFixed(1) + 'px)';
blobB.style.opacity = String(0.3 + mid * 0.35);
blobC.style.transform = 'translate(' + (Math.cos(tSec * 0.9 + 4) * 14 * (0.4 + treb)).toFixed(1) + 'px,' + (Math.sin(tSec * 0.8 + 3) * 12 * (0.4 + treb)).toFixed(1) + 'px)';
blobC.style.opacity = String(0.28 + treb * 0.32);
// 앨범 아트는 저음 값에 맞춰 살짝 커졌다 줄어든다 — 킥 펄스
art.style.transform = 'scale(' + (1 + bass * 0.08).toFixed(3) + ')';
drawRing(tSec, treb);
for (var i = 0; i < bars.length; i++) {
var bar = bars[i];
var s = Math.sin(tSec * bar.f + bar.p) * 0.5 + 0.5;
var h = playingNow ? Math.max(0.08, bar.base + s * treb * 0.9) : bar.base;
bar.el.style.transform = 'scaleY(' + h.toFixed(3) + ')';
}
}
function setPlaying(next) { playing = next; root.classList.toggle('is-playing', next); }
function onPlayClick() { human(); setPlaying(!playing); }
function onPlayKeydown(e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); setPlaying(!playing); }
}
function frame(now) {
if (!t0) t0 = now;
render(now, isDemo ? true : playing);
requestAnimationFrame(frame);
}
ringColor = getComputedStyle(colorRef).color || ringColor;
if (reduce) {
root.classList.add('is-still');
t0 = performance.now();
render(t0, false);
playBtn.addEventListener('click', function () { human(); setPlaying(!playing); render(performance.now(), playing); });
playBtn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); setPlaying(!playing); render(performance.now(), playing); }
});
} else {
playBtn.addEventListener('click', onPlayClick);
playBtn.addEventListener('keydown', onPlayKeydown);
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from "react";
import styles from "./HeroScene.module.scss";
type RingPoint = { f: number; p: number; w: number };
type Bar = { el: HTMLDivElement | null; f: number; p: number; base: number };
type BandDef = { f1: number; p1: number; f2: number; p2: number; w2: number };
export default function HeroScene() {
const rootRef = useRef<HTMLDivElement | null>(null);
const playRef = useRef<HTMLButtonElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const artRef = useRef<HTMLDivElement | null>(null);
const colorRefEl = useRef<HTMLSpanElement | null>(null);
const blobARef = useRef<HTMLDivElement | null>(null);
const blobBRef = useRef<HTMLDivElement | null>(null);
const blobCRef = useRef<HTMLDivElement | null>(null);
const barsElRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const playBtn = playRef.current;
const canvas = canvasRef.current;
const art = artRef.current;
const colorRef = colorRefEl.current;
const blobA = blobARef.current;
const blobB = blobBRef.current;
const blobC = blobCRef.current;
const barsEl = barsElRef.current;
if (!root || !playBtn || !canvas || !art || !colorRef || !blobA || !blobB || !blobC || !barsEl) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
// 결정론 난수 — Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 대역·막대·링 점의 위상을 만든다.
function makeLcg(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rng = makeLcg(5527);
// 배경 블러·링·막대가 같은 저·중·고음 대역 값에 맞물리도록, 대역마다 사인 두 겹을 겹쳐 값을 하나씩만 계산한다.
const bandDefs: { bass: BandDef; mid: BandDef; treble: BandDef } = {
bass: { f1: 0.28 + rng() * 0.08, p1: rng() * Math.PI * 2, f2: 0.7 + rng() * 0.2, p2: rng() * Math.PI * 2, w2: 0.3 },
mid: { f1: 0.75 + rng() * 0.15, p1: rng() * Math.PI * 2, f2: 1.6 + rng() * 0.3, p2: rng() * Math.PI * 2, w2: 0.32 },
treble: { f1: 1.6 + rng() * 0.3, p1: rng() * Math.PI * 2, f2: 2.6 + rng() * 0.4, p2: rng() * Math.PI * 2, w2: 0.34 },
};
function bandValue(def: BandDef, tSec: number) {
const s1 = Math.sin(tSec * def.f1 * Math.PI * 2 + def.p1);
const s2 = Math.sin(tSec * def.f2 * Math.PI * 2 + def.p2) * def.w2;
const v = (s1 + s2) / (1 + def.w2); // -1..1
return 0.5 + v * 0.5; // 0..1
}
const RING_POINTS = 32;
const ringPts: RingPoint[] = [];
for (let i = 0; i < RING_POINTS; i++) {
ringPts.push({ f: 2 + rng() * 1.4, p: rng() * Math.PI * 2, w: 0.35 + rng() * 0.3 });
}
const BAR_COUNT = 14;
const bars: Bar[] = [];
for (let i = 0; i < BAR_COUNT; i++) {
const el = document.createElement("div");
el.className = styles.bar;
barsEl.appendChild(el);
bars.push({ el, f: 3 + rng() * 1.6, p: rng() * Math.PI * 2, base: 0.18 + rng() * 0.1 });
}
function human() { if (isDemo) { root!.classList.remove(styles["is-demo"]); isDemo = false; } }
let playing = true, t0 = 0;
let ringColor = "#3a3a3a";
function drawRing(tSec: number, treb: number) {
const w = canvas!.width, h = canvas!.height, cx = w / 2, cy = h / 2;
const innerR = 34, rangeR = 16;
ctx!.clearRect(0, 0, w, h);
ctx!.lineCap = "round";
ctx!.strokeStyle = ringColor;
for (let i = 0; i < RING_POINTS; i++) {
const pt = ringPts[i];
const s = Math.sin(tSec * pt.f + pt.p) * 0.5 + 0.5;
const v = 0.15 + s * pt.w * (0.4 + treb * 0.9);
const angle = (i / RING_POINTS) * Math.PI * 2 - Math.PI / 2;
const cos = Math.cos(angle), sin = Math.sin(angle);
const r2 = innerR + rangeR * Math.max(0.05, v);
ctx!.globalAlpha = 0.3 + v * 0.6;
ctx!.lineWidth = 2;
ctx!.beginPath();
ctx!.moveTo(cx + cos * innerR, cy + sin * innerR);
ctx!.lineTo(cx + cos * r2, cy + sin * r2);
ctx!.stroke();
}
ctx!.globalAlpha = 1;
}
function render(now: number, playingNow: boolean) {
const tSec = (now - t0) / 1000;
const bass = playingNow ? bandValue(bandDefs.bass, tSec) : 0.2;
const mid = playingNow ? bandValue(bandDefs.mid, tSec) : 0.2;
const treb = playingNow ? bandValue(bandDefs.treble, tSec) : 0.2;
// 배경 블러 세 덩이는 서로 다른 대역 값을 따라 각자 다른 방향으로 흐른다 — transform/opacity 만 사용
blobA!.style.transform = "translate(" + (Math.cos(tSec * 0.6) * 18 * (0.4 + bass)).toFixed(1) + "px," + (Math.sin(tSec * 0.5) * 14 * (0.4 + bass)).toFixed(1) + "px)";
blobA!.style.opacity = String(0.35 + bass * 0.35);
blobB!.style.transform = "translate(" + (Math.cos(tSec * 0.4 + 2) * 16 * (0.4 + mid)).toFixed(1) + "px," + (Math.sin(tSec * 0.7 + 1) * 18 * (0.4 + mid)).toFixed(1) + "px)";
blobB!.style.opacity = String(0.3 + mid * 0.35);
blobC!.style.transform = "translate(" + (Math.cos(tSec * 0.9 + 4) * 14 * (0.4 + treb)).toFixed(1) + "px," + (Math.sin(tSec * 0.8 + 3) * 12 * (0.4 + treb)).toFixed(1) + "px)";
blobC!.style.opacity = String(0.28 + treb * 0.32);
// 앨범 아트는 저음 값에 맞춰 살짝 커졌다 줄어든다 — 킥 펄스
art!.style.transform = "scale(" + (1 + bass * 0.08).toFixed(3) + ")";
drawRing(tSec, treb);
for (let i = 0; i < bars.length; i++) {
const bar = bars[i];
const s = Math.sin(tSec * bar.f + bar.p) * 0.5 + 0.5;
const h = playingNow ? Math.max(0.08, bar.base + s * treb * 0.9) : bar.base;
bar.el!.style.transform = "scaleY(" + h.toFixed(3) + ")";
}
}
function setPlaying(next: boolean) { playing = next; root!.classList.toggle(styles["is-playing"], next); }
function onPlayClick() { human(); setPlaying(!playing); }
function onPlayKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); human(); setPlaying(!playing); }
}
let raf = 0;
function frame(now: number) {
if (!t0) t0 = now;
render(now, isDemo ? true : playing);
raf = requestAnimationFrame(frame);
}
ringColor = getComputedStyle(colorRef).color || ringColor;
function onReduceClick() { human(); setPlaying(!playing); render(performance.now(), playing); }
function onReduceKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); human(); setPlaying(!playing);
render(performance.now(), playing);
}
}
if (reduce) {
root.classList.add(styles["is-still"]);
t0 = performance.now();
render(t0, false);
playBtn.addEventListener("click", onReduceClick);
playBtn.addEventListener("keydown", onReduceKeydown);
} else {
playBtn.addEventListener("click", onPlayClick);
playBtn.addEventListener("keydown", onPlayKeydown);
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
if (reduce) {
playBtn.removeEventListener("click", onReduceClick);
playBtn.removeEventListener("keydown", onReduceKeydown);
} else {
playBtn.removeEventListener("click", onPlayClick);
playBtn.removeEventListener("keydown", onPlayKeydown);
}
barsEl.innerHTML = "";
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.hero + " " + styles["is-demo"] + " " + styles["is-playing"]} ref={rootRef}>
<p className={styles.hint}><span>재생하면 저·중·고음 값이 배경 블러·링·막대에 동시에 맞물려 번집니다</span></p>
<div className={styles.panel}>
<div className={styles.backdrop} aria-hidden="true">
<div className={styles.blob + " " + styles.blobA} ref={blobARef} />
<div className={styles.blob + " " + styles.blobB} ref={blobBRef} />
<div className={styles.blob + " " + styles.blobC} ref={blobCRef} />
</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 className={styles.core}>
<div className={styles.ringWrap}>
<canvas className={styles.ring} ref={canvasRef} width={128} height={128} aria-hidden="true" />
<div className={styles.art} ref={artRef} />
<span className={styles.colorref} ref={colorRefEl} aria-hidden="true" />
</div>
</div>
<div className={styles.bars} ref={barsElRef} aria-hidden="true" />
</div>
</div>
</main>
);
}09 블러 배경과 링이 한 화면에서 맞물리는 플레이어 히어로 — 측정 누적면적 39.284% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 오디오 플레이어 디자인 9종 — 계산으로 움직이는 플레이어 UI
이 데모가 속한 글
css 오디오 플레이어 디자인 9종 — 계산으로 움직이는 플레이어 UIcss 오디오 플레이어 디자인 아홉 가지 가운데 하나는 여러 사인 곡선을 겹쳐 막대 높이를 미리 계산해 이퀄라이저처럼 흔들고, 하나는 각속도 값을 서서히 줄여 앨범판이 관성으로 멈추게 하며, 하나는 타임라인 값으로 이 글의 데모 전부 보기


