07スライダー値がバーの反応幅を決めるプリセットイコライザー
低音・中音・高音の3つのスライダー値をそれぞれ計算して対応するバーグループの最大振れ幅を決め、スライダーを動かすとそのグループのバーだけが新しく計算された幅で揺れ直す。
使いどころ音質設定パネル・ポッドキャストアプリ
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 オリジナル · SCSS と React は zip に同梱 · 音楽 プレイヤー デザイン 9種 — 計算で動くカスタムUI
このデモが属する記事
音楽 プレイヤー デザイン 9種 — 計算で動くカスタムUI音楽 プレイヤー デザイン 9種のうち、1つは複数のサイン波を重ねてバーの高さを先に計算しイコライザーのように揺らし、1つは角速度の値を徐々に落としてレコード盤を慣性で止め、1つはタイムライン値で歌詞のハイライト行を探して中央へスクロールし この記事のデモをすべて見る


