07슬라이더 값이 막대 반응폭을 정하는 프리셋 이퀄라이저
저음·중음·고음 세 슬라이더 값을 각각 계산해 대응하는 막대 그룹의 최대 흔들림 폭을 정하고, 슬라이더를 옮기면 그 그룹의 막대들만 새로 계산된 폭으로 다시 흔들린다.
쓰임음질 설정 패널·팟캐스트 앱
BAND_RESPONSE_RANGEsynthmakeLcg

<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="peq is-demo is-playing" id="peq">
<p class="peq__hint"><span>슬라이더 값을 올리면 그 대역 막대 그룹이 흔들리는 반응폭이 커집니다</span></p>
<div class="peq__panel">
<button class="peq__play" id="play" type="button" aria-label="재생">
<svg class="peq__ico peq__ico--play" aria-hidden="true"><use href="#ico-play"></use></svg>
<svg class="peq__ico peq__ico--pause" aria-hidden="true"><use href="#ico-pause"></use></svg>
</button>
<div class="peq__bands" id="bands">
<div class="peq__band" data-band="0">
<div class="peq__bars" data-bars="0" aria-hidden="true"></div>
<input class="peq__slider" type="range" min="0" max="100" value="30" id="slider-0" aria-label="저음 반응폭">
<span class="peq__label">저음</span>
</div>
<div class="peq__band" data-band="1">
<div class="peq__bars" data-bars="1" aria-hidden="true"></div>
<input class="peq__slider" type="range" min="0" max="100" value="30" id="slider-1" aria-label="중음 반응폭">
<span class="peq__label">중음</span>
</div>
<div class="peq__band" data-band="2">
<div class="peq__bars" data-bars="2" aria-hidden="true"></div>
<input class="peq__slider" type="range" min="0" max="100" value="30" id="slider-2" aria-label="고음 반응폭">
<span class="peq__label">고음</span>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(슬라이더 값 → 막대 반응폭)은 index.html 의 render() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-orange);
.peq {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(400px, 92vw);
}
.peq__hint {
margin: 0;
color: $subject-ink;
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.peq__panel {
width: 100%;
border-radius: $r-card;
background: rgba($subject-ink, .07);
box-shadow: 0 0 0 1px rgba($subject-ink, .16);
padding: $sp-3;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
}
.peq__play {
flex: 0 0 auto;
width: $sp-8;
height: $sp-8;
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;
}
.peq__play:hover { transform: scale(1.06); }
.peq__play:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.peq__play:active { transform: scale(.96); }
.peq__ico { width: 16px; height: 16px; }
.peq__ico--pause { display: none; }
.is-playing .peq__ico--play { display: none; }
.is-playing .peq__ico--pause { display: block; }
.peq__bands {
display: flex;
align-items: flex-start;
justify-content: center;
gap: $sp-4;
width: 100%;
}
.peq__band {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
flex: 1 1 0;
min-width: 0;
}
.peq__bars {
height: 80px;
width: 100%;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
}
.peq__bar {
flex: 1 1 auto;
max-width: 8px;
height: 100%;
border-radius: $r-xs $r-xs 0 0;
background: $color;
transform: scaleY(.3);
transform-origin: bottom center; // 막대는 바닥이 기준점 — 중심 기준이 아니다
}
.peq__slider {
width: 100%;
accent-color: $color;
}
.peq__label {
font-size: 11px;
font-weight: 600;
color: rgba($subject-ink, .78);
}
@media (max-width: 340px) {
.peq__panel { padding: 4px; gap: 4px; }
.peq__play { width: 28px; height: 28px; }
.peq__bands { gap: 4px; }
.peq__bars { height: 40px; }
.peq__label { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.peq__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);
}
.peq {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(400px, 92vw);
}
.peq__hint {
margin: 0;
color: #17141a;
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.peq__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: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.peq__play {
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: 999px;
border: none;
background: #17141a;
color: #ff4d1f;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 300ms ease-out;
}
.peq__play:hover {
transform: scale(1.06);
}
.peq__play:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.peq__play:active {
transform: scale(0.96);
}
.peq__ico {
width: 16px;
height: 16px;
}
.peq__ico--pause {
display: none;
}
.is-playing .peq__ico--play {
display: none;
}
.is-playing .peq__ico--pause {
display: block;
}
.peq__bands {
display: flex;
align-items: flex-start;
justify-content: center;
gap: 16px;
width: 100%;
}
.peq__band {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 1 1 0;
min-width: 0;
}
.peq__bars {
height: 80px;
width: 100%;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
}
.peq__bar {
flex: 1 1 auto;
max-width: 8px;
height: 100%;
border-radius: 4px 4px 0 0;
background: #17141a;
transform: scaleY(0.3);
transform-origin: bottom center;
}
.peq__slider {
width: 100%;
accent-color: #17141a;
}
.peq__label {
font-size: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.78);
}
@media (max-width: 340px) {
.peq__panel {
padding: 4px;
gap: 4px;
}
.peq__play {
width: 28px;
height: 28px;
}
.peq__bands {
gap: 4px;
}
.peq__bars {
height: 40px;
}
.peq__label {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.peq__play {
transition: none;
}
}(function () {
var root = document.getElementById('peq');
var playBtn = document.getElementById('play');
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 BAND_COUNT = 3;
var BARS_PER_BAND = 6;
var BAND_RESPONSE_RANGE = 1.6; // 슬라이더 반응폭 배율 상한 — 변형: 키우면 슬라이더를 움직였을 때 막대 그룹이 흔들리는 높이 차이가 더 커진다
var rng = makeLcg(3307);
var sliders = [];
var bands = [];
for (var b = 0; b < BAND_COUNT; b++) {
var barsEl = document.querySelector('[data-bars="' + b + '"]');
var bars = [];
for (var i = 0; i < BARS_PER_BAND; i++) {
var el = document.createElement('div');
el.className = 'peq__bar';
barsEl.appendChild(el);
bars.push({
el: el,
f1: 0.5 + rng() * 0.35,
p1: rng() * Math.PI * 2,
f2: 1.3 + rng() * 0.6,
p2: rng() * Math.PI * 2,
w2: 0.2 + rng() * 0.2,
base: 0.32 + rng() * 0.14
});
}
bands.push(bars);
sliders.push(document.getElementById('slider-' + b));
}
function synth(bar, tSec) {
var s1 = Math.sin(tSec * bar.f1 * Math.PI * 2 + bar.p1);
var s2 = Math.sin(tSec * bar.f2 * Math.PI * 2 + bar.p2) * bar.w2;
return (s1 + s2) / (1 + bar.w2); // -1..1
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
var playing = true, t0 = 0;
function render(now) {
var tSec = (now - t0) / 1000;
var effPlaying = isDemo ? true : playing;
for (var b = 0; b < BAND_COUNT; b++) {
var norm = Number(sliders[b].value) / 100; // 0..1
var amp = norm * BAND_RESPONSE_RANGE;
var bars = bands[b];
for (var i = 0; i < bars.length; i++) {
var bar = bars[i];
var sway = effPlaying ? synth(bar, tSec) : 0;
var h = bar.base + sway * amp * 0.5;
h = Math.max(0.06, Math.min(1, h));
// 막대는 바닥이 기준점이라 아래쪽 원점으로 세로로만 자란다 — 중심 기준이 아닌 실제 앵커 지점
bar.el.style.transform = 'scaleY(' + h + ')';
}
}
}
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 onSliderInput() { human(); }
sliders.forEach(function (s) { s.addEventListener('input', onSliderInput); });
var driveStart = 0;
function triangle(frac) { return frac < 0.5 ? frac * 2 : 2 - frac * 2; }
function frame(now) {
if (!t0) t0 = now;
if (isDemo) {
if (!driveStart) driveStart = now;
var DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
for (var b = 0; b < BAND_COUNT; b++) {
var phase = b / BAND_COUNT;
var frac = (((now - driveStart) / DRIVE_PERIOD_MS) + phase) % 1;
sliders[b].value = String(Math.round(triangle(frac) * 100));
}
}
render(now);
requestAnimationFrame(frame);
}
if (reduce) {
root.classList.add('is-still');
t0 = performance.now();
render(t0);
playBtn.addEventListener('click', function () { human(); setPlaying(!playing); render(performance.now()); });
playBtn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); setPlaying(!playing); render(performance.now()); }
});
sliders.forEach(function (s) { s.addEventListener('input', function () { human(); render(performance.now()); }); });
} else {
playBtn.addEventListener('click', onPlayClick);
playBtn.addEventListener('keydown', onPlayKeydown);
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from "react";
import styles from "./EqPresetSliders.module.scss";
type Bar = { el: HTMLDivElement | null; f1: number; p1: number; f2: number; p2: number; w2: number; base: number };
const BANDS = [
{ label: "저음", aria: "저음 반응폭" },
{ label: "중음", aria: "중음 반응폭" },
{ label: "고음", aria: "고음 반응폭" },
];
export default function EqPresetSliders() {
const rootRef = useRef<HTMLDivElement | null>(null);
const playRef = useRef<HTMLButtonElement | null>(null);
const barsElRefs = useRef<(HTMLDivElement | null)[]>([]);
const sliderRefs = useRef<(HTMLInputElement | null)[]>([]);
useEffect(() => {
const BAND_COUNT = 3;
const root = rootRef.current;
const playBtn = playRef.current;
const barsEls = (barsElRefs.current || []).filter((b): b is HTMLDivElement => !!b);
const sliders = (sliderRefs.current || []).filter((s): s is HTMLInputElement => !!s);
if (!root || !playBtn || barsEls.length !== BAND_COUNT || sliders.length !== BAND_COUNT) 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 BARS_PER_BAND = 6;
const BAND_RESPONSE_RANGE = 1.6; // 슬라이더 반응폭 배율 상한 — 변형: 키우면 슬라이더를 움직였을 때 막대 그룹이 흔들리는 높이 차이가 더 커진다
const rng = makeLcg(3307);
const bands: Bar[][] = [];
for (let b = 0; b < BAND_COUNT; b++) {
const barsEl = barsEls[b]!;
const bars: Bar[] = [];
for (let i = 0; i < BARS_PER_BAND; i++) {
const el = document.createElement("div");
el.className = styles.bar;
barsEl.appendChild(el);
bars.push({
el,
f1: 0.5 + rng() * 0.35,
p1: rng() * Math.PI * 2,
f2: 1.3 + rng() * 0.6,
p2: rng() * Math.PI * 2,
w2: 0.2 + rng() * 0.2,
base: 0.32 + rng() * 0.14,
});
}
bands.push(bars);
}
function synth(bar: Bar, tSec: number) {
const s1 = Math.sin(tSec * bar.f1 * Math.PI * 2 + bar.p1);
const s2 = Math.sin(tSec * bar.f2 * Math.PI * 2 + bar.p2) * bar.w2;
return (s1 + s2) / (1 + bar.w2); // -1..1
}
function human() { if (isDemo) { root!.classList.remove(styles["is-demo"]); isDemo = false; } }
let playing = true, t0 = 0;
function render(now: number) {
const tSec = (now - t0) / 1000;
const effPlaying = isDemo ? true : playing;
for (let b = 0; b < BAND_COUNT; b++) {
const norm = Number(sliders[b]!.value) / 100; // 0..1
const amp = norm * BAND_RESPONSE_RANGE;
const bars = bands[b];
for (let i = 0; i < bars.length; i++) {
const bar = bars[i];
const sway = effPlaying ? synth(bar, tSec) : 0;
let h = bar.base + sway * amp * 0.5;
h = Math.max(0.06, Math.min(1, h));
// 막대는 바닥이 기준점이라 아래쪽 원점으로 세로로만 자란다 — 중심 기준이 아닌 실제 앵커 지점
bar.el!.style.transform = "scaleY(" + h + ")";
}
}
}
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); }
}
function onSliderInput() { human(); }
function onReduceSliderInput() { human(); render(performance.now()); }
sliders.forEach((s) => s!.addEventListener("input", reduce ? onReduceSliderInput : onSliderInput));
let driveStart = 0;
function triangle(frac: number) { return frac < 0.5 ? frac * 2 : 2 - frac * 2; }
let raf = 0;
function frame(now: number) {
if (!t0) t0 = now;
if (isDemo) {
if (!driveStart) driveStart = now;
const DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
for (let b = 0; b < BAND_COUNT; b++) {
const phase = b / BAND_COUNT;
const frac = ((now - driveStart) / DRIVE_PERIOD_MS + phase) % 1;
sliders[b]!.value = String(Math.round(triangle(frac) * 100));
}
}
render(now);
raf = requestAnimationFrame(frame);
}
function onReduceClick() { human(); setPlaying(!playing); render(performance.now()); }
function onReduceKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); human(); setPlaying(!playing); render(performance.now()); }
}
if (reduce) {
root.classList.add(styles["is-still"]);
t0 = performance.now();
render(t0);
playBtn.addEventListener("click", onReduceClick);
playBtn.addEventListener("keydown", onReduceKeydown);
} else {
playBtn.addEventListener("click", onPlayClick);
playBtn.addEventListener("keydown", onPlayKeydown);
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
sliders.forEach((s) => s!.removeEventListener("input", reduce ? onReduceSliderInput : onSliderInput));
if (reduce) {
playBtn.removeEventListener("click", onReduceClick);
playBtn.removeEventListener("keydown", onReduceKeydown);
} else {
playBtn.removeEventListener("click", onPlayClick);
playBtn.removeEventListener("keydown", onPlayKeydown);
}
barsEls.forEach((el) => { if (el) el.innerHTML = ""; });
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.peq + " " + styles["is-demo"] + " " + styles["is-playing"]} ref={rootRef}>
<p className={styles.hint}><span>슬라이더 값을 올리면 그 대역 막대 그룹이 흔들리는 반응폭이 커집니다</span></p>
<div className={styles.panel}>
<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.bands}>
{BANDS.map((band, i) => (
<div className={styles.band} key={band.label} data-band={i}>
<div className={styles.bars} ref={(el: HTMLDivElement | null) => { if (barsElRefs.current) barsElRefs.current[i] = el; }} aria-hidden="true" />
<input
className={styles.slider}
type="range"
min={0}
max={100}
defaultValue={30}
ref={(el: HTMLInputElement | null) => { if (sliderRefs.current) sliderRefs.current[i] = el; }}
aria-label={band.aria}
/>
<span className={styles.label}>{band.label}</span>
</div>
))}
</div>
</div>
</div>
</main>
);
}07 슬라이더 값이 막대 반응폭을 정하는 프리셋 이퀄라이저 — 측정 누적면적 9.767% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 오디오 플레이어 디자인 9종 — 계산으로 움직이는 플레이어 UI
이 데모가 속한 글
css 오디오 플레이어 디자인 9종 — 계산으로 움직이는 플레이어 UIcss 오디오 플레이어 디자인 아홉 가지 가운데 하나는 여러 사인 곡선을 겹쳐 막대 높이를 미리 계산해 이퀄라이저처럼 흔들고, 하나는 각속도 값을 서서히 줄여 앨범판이 관성으로 멈추게 하며, 하나는 타임라인 값으로 이 글의 데모 전부 보기


