09ぼかし背景とリングが一画面で噛み合うプレイヤーヒーロー
アルバムアートから抽出した色の値をぼかして拡大した背景の上に、ターンテーブル・波形バー・円形リングという3つの計算レイヤーを同じ座標系で重ね、1つの再生進行率の値が3レイヤーを同時に動かすヒーローシーンを完成させる。
使いどころランディングヒーロー・アルバムリリースページ
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 オリジナル · SCSS と React は zip に同梱 · 音楽 プレイヤー デザイン 9種 — 計算で動くカスタムUI
このデモが属する記事
音楽 プレイヤー デザイン 9種 — 計算で動くカスタムUI音楽 プレイヤー デザイン 9種のうち、1つは複数のサイン波を重ねてバーの高さを先に計算しイコライザーのように揺らし、1つは角速度の値を徐々に落としてレコード盤を慣性で止め、1つはタイムライン値で歌詞のハイライト行を探して中央へスクロールし この記事のデモをすべて見る


