05Bass-bouncing letters
Letters of the title are split across low, mid and high bands so different letters jump for different parts of the sound. Each letter has to grow in place, so the origin stays centered and only translateY and scale move.
Best forAlbum or single release page title, event countdown header
translateYanimation-delayanimation-fill-mode

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-waveform" viewBox="0 0 256 256" fill="currentColor"><path d="M208 96v64H48V96Z" opacity=".2"/><path d="M56 96v64a8 8 0 0 1-16 0V96a8 8 0 0 1 16 0m32-72a8 8 0 0 0-8 8v192a8 8 0 0 0 16 0V32a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v128a8 8 0 0 0 16 0V64a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v64a8 8 0 0 0 16 0V96a8 8 0 0 0-8-8m40-16a8 8 0 0 0-8 8v96a8 8 0 0 0 16 0V72a8 8 0 0 0-8-8"/></symbol>
<symbol id="ico-play" viewBox="0 0 256 256" 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.88 15.88 0 0 0 0-27.02M80 215.94V40l143.83 88Z"/></symbol>
<symbol id="ico-pause" viewBox="0 0 256 256" 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"/></symbol>
</defs>
</svg>
<main class="stage">
<div class="tb is-demo" id="tb">
<p class="tb__hint"><svg class="tb__ico" aria-hidden="true"><use href="#ico-waveform"/></svg><span>Press play and it moves with real sound</span></p>
<h2 class="tb__title" aria-label="GODRICH">
<!-- 글자마다 data-band 로 대역을 나눈다: G·R 는 Bass, O·I·H 는 Mid, D·C 는 Treble. 브랜드 낱말이라 로케일을 안 탄다. -->
<b class="tb__l" data-band="0">G</b><b class="tb__l" data-band="1">O</b><b class="tb__l" data-band="2">D</b><b class="tb__l" data-band="0">R</b><b class="tb__l" data-band="1">I</b><b class="tb__l" data-band="2">C</b><b class="tb__l" data-band="1">H</b>
</h2>
<ul class="tb__legend" aria-hidden="true">
<li class="tb__band tb__band--low">Bass</li>
<li class="tb__band tb__band--mid">Mid</li>
<li class="tb__band tb__band--high">Treble</li>
</ul>
<div class="tb__foot">
<button class="tb__play" id="play" type="button" aria-pressed="false">
<svg class="tb__ico tb__ico--play" aria-hidden="true"><use href="#ico-play"/></svg>
<svg class="tb__ico tb__ico--pause" aria-hidden="true"><use href="#ico-pause"/></svg>
<span id="playLabel">Play</span>
</button>
<p class="tb__level">
<span class="tb__levelLabel">Loudest band</span>
<b class="tb__levelBox">
<span class="tb__levelText" id="levelText" aria-live="polite">Bass</span>
<span class="tb__loop" aria-hidden="true"><i>Bass</i><i>Mid</i><i>Treble</i></span>
</b>
</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 저음에 튀는 글자 — 제목의 글자를 저·중·고 대역에 나눠 건다. 대역마다 다른 글자가 튄다.
// 글자는 자기 자리에서 커져야 하므로(규칙 8) transform-origin 은 중심 그대로 — translateY 와 scale 만 쓴다.
// --j 는 실제 소리에서 온 대역 에너지(0~1). 재생을 누르면 JS 가 매 프레임 쓴다.
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-ink;
@include stage($stage-orange);
.tb { display: flex; flex-direction: column; align-items: center; gap: $sp-3; width: 100%; }
.tb__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(11px, 3vw, 12px); color: $subject-ink;
}
.tb__ico { width: 16px; height: 16px; color: $subject-ink; }
.tb__title { margin: 0; display: flex; align-items: baseline; gap: $sp-1; font-weight: 900; }
.tb__l {
display: inline-block; font-size: clamp(44px, 12vw, 60px); line-height: 1; color: $color;
transform-origin: 50% 50%;
transform: translateY(calc(-18px * var(--j, 0))) scale(calc(1 + var(--j, 0) * .16));
transition: transform $duration $easing;
}
// 대역 범례 — 어떤 글자가 어느 대역에 걸렸는지 색으로 보여준다(소리 없는 독자용 보조)
.tb__legend { margin: 0; padding: 0; list-style: none; display: flex; gap: $sp-1; }
.tb__band {
border-radius: $r-pill; padding: 0 $sp-2; height: 18px; display: grid; place-items: center;
font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1; color: $subject-ink;
border: 2px solid rgba($subject-ink, .5);
}
.tb__band--low::before, .tb__band--mid::before, .tb__band--high::before {
content: ""; width: 8px; height: 8px; border-radius: 50%; margin-right: $sp-1;
}
.tb__band--low::before { background: #905a47; }
.tb__band--mid::before { background: $subject-ink; }
.tb__band--high::before { background: rgba($subject-ink, .38); }
.tb__foot { display: flex; align-items: center; justify-content: center; gap: $sp-4; width: 100%; }
.tb__play {
display: inline-flex; align-items: center; gap: $sp-1;
border: 0; border-radius: $r-pill; padding: $sp-2 $sp-4;
background: $subject-ink; color: $subject-cream; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1;
}
.tb__ico--pause { display: none; }
.tb__play[aria-pressed="true"] .tb__ico--play { display: none; }
.tb__play[aria-pressed="true"] .tb__ico--pause { display: inline-block; }
.tb__play .tb__ico { color: $subject-cream; }
.tb__level { margin: 0; display: flex; align-items: center; gap: $sp-2; }
.tb__levelLabel { font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); color: $subject-ink; }
// 주황 무대 위 작은 글자 — 반투명 크림판을 깔아 밝힌 뒤 잉크 글자로 (잉크/주황 직접 조합은 4.5:1 에 아슬)
.tb__levelBox {
position: relative; display: grid; place-items: center;
min-width: 40px; height: 20px; border-radius: $r-xs; padding: 0 $sp-1;
background: rgba($subject-cream, .3); color: $subject-ink;
font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1;
}
.tb__loop { position: absolute; inset: 0; display: grid; place-items: center; visibility: hidden; }
.tb__loop i { grid-area: 1 / 1; font-style: normal; visibility: hidden; }
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 여섯 글자가 160ms 간격으로 차례로 튄다(양수 지연 + backwards — 지연 구간엔 0% 정지 자세가 유지된다).
// 글자는 크지만 색상은 그대로고 위치 이동만 있어 밝기 반전은 없다 — 광과민성 한계와 무관하다.
.tb.is-demo .tb__l {
animation-name: tbJump; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--n) * 140ms);
transition: none;
}
// 순서값 --n — 일곱 글자가 왼쪽부터 140ms 간격으로 도는 파도가 된다(7 × 140ms = 980ms < 2s)
.tb.is-demo .tb__l:nth-child(1) { --n: 0; }
.tb.is-demo .tb__l:nth-child(2) { --n: 1; }
.tb.is-demo .tb__l:nth-child(3) { --n: 2; }
.tb.is-demo .tb__l:nth-child(4) { --n: 3; }
.tb.is-demo .tb__l:nth-child(5) { --n: 4; }
.tb.is-demo .tb__l:nth-child(6) { --n: 5; }
.tb.is-demo .tb__l:nth-child(7) { --n: 6; }
@keyframes tbJump {
0%, 8% { transform: translateY(0) scale(1); }
16%, 30% { transform: translateY(-18px) scale(1.16); }
44%, 100% { transform: translateY(0) scale(1); }
}
.tb.is-demo .tb__levelText { color: transparent; }
.tb.is-demo .tb__loop { visibility: visible; }
.tb.is-demo .tb__loop i {
animation-name: tbBandName; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tb.is-demo .tb__loop i:nth-child(1) { animation-delay: 0ms; }
.tb.is-demo .tb__loop i:nth-child(2) { animation-delay: 666ms; }
.tb.is-demo .tb__loop i:nth-child(3) { animation-delay: 1332ms; }
@keyframes tbBandName {
0%, 33% { visibility: visible; }
34%, 100% { visibility: hidden; }
}
@media (max-width: 340px) {
.tb { gap: $sp-1; }
.tb__l { font-size: 34px; }
.tb__foot { gap: $sp-2; }
.tb__play { padding: $sp-1 $sp-3; }
}
// reduce 에서는 루프를 끈다 — 글자는 제자리에 그대로 보이고, 범례가 대역 구분을 대신 설명한다
@media (prefers-reduced-motion: reduce) {
.tb.is-demo .tb__l,
.tb.is-demo .tb__loop i { animation: none !important; }
.tb.is-demo .tb__levelText { color: $subject-ink !important; }
.tb.is-demo .tb__loop { visibility: hidden !important; }
.tb__l { transition: none !important; }
}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);
}
.tb {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
}
.tb__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
color: #17141a;
}
.tb__ico {
width: 16px;
height: 16px;
color: #17141a;
}
.tb__title {
margin: 0;
display: flex;
align-items: baseline;
gap: 4px;
font-weight: 900;
}
.tb__l {
display: inline-block;
font-size: clamp(44px, 12vw, 60px);
line-height: 1;
color: #17141a;
transform-origin: 50% 50%;
transform: translateY(calc(-18px * var(--j, 0))) scale(calc(1 + var(--j, 0) * 0.16));
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.tb__legend {
margin: 0;
padding: 0;
list-style: none;
display: flex;
gap: 4px;
}
.tb__band {
border-radius: 999px;
padding: 0 8px;
height: 18px;
display: grid;
place-items: center;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1;
color: #17141a;
border: 2px solid rgba(23, 20, 26, 0.5);
}
.tb__band--low::before, .tb__band--mid::before, .tb__band--high::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
}
.tb__band--low::before {
background: #905a47;
}
.tb__band--mid::before {
background: #17141a;
}
.tb__band--high::before {
background: rgba(23, 20, 26, 0.38);
}
.tb__foot {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
width: 100%;
}
.tb__play {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 8px 16px;
background: #17141a;
color: #fff7e6;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
}
.tb__ico--pause {
display: none;
}
.tb__play[aria-pressed=true] .tb__ico--play {
display: none;
}
.tb__play[aria-pressed=true] .tb__ico--pause {
display: inline-block;
}
.tb__play .tb__ico {
color: #fff7e6;
}
.tb__level {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.tb__levelLabel {
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
color: #17141a;
}
.tb__levelBox {
position: relative;
display: grid;
place-items: center;
min-width: 40px;
height: 20px;
border-radius: 4px;
padding: 0 4px;
background: rgba(255, 247, 230, 0.3);
color: #17141a;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
}
.tb__loop {
position: absolute;
inset: 0;
display: grid;
place-items: center;
visibility: hidden;
}
.tb__loop i {
grid-area: 1/1;
font-style: normal;
visibility: hidden;
}
.tb.is-demo .tb__l {
animation-name: tbJump;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--n) * 140ms);
transition: none;
}
.tb.is-demo .tb__l:nth-child(1) {
--n: 0;
}
.tb.is-demo .tb__l:nth-child(2) {
--n: 1;
}
.tb.is-demo .tb__l:nth-child(3) {
--n: 2;
}
.tb.is-demo .tb__l:nth-child(4) {
--n: 3;
}
.tb.is-demo .tb__l:nth-child(5) {
--n: 4;
}
.tb.is-demo .tb__l:nth-child(6) {
--n: 5;
}
.tb.is-demo .tb__l:nth-child(7) {
--n: 6;
}
@keyframes tbJump {
0%, 8% {
transform: translateY(0) scale(1);
}
16%, 30% {
transform: translateY(-18px) scale(1.16);
}
44%, 100% {
transform: translateY(0) scale(1);
}
}
.tb.is-demo .tb__levelText {
color: transparent;
}
.tb.is-demo .tb__loop {
visibility: visible;
}
.tb.is-demo .tb__loop i {
animation-name: tbBandName;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tb.is-demo .tb__loop i:nth-child(1) {
animation-delay: 0ms;
}
.tb.is-demo .tb__loop i:nth-child(2) {
animation-delay: 666ms;
}
.tb.is-demo .tb__loop i:nth-child(3) {
animation-delay: 1332ms;
}
@keyframes tbBandName {
0%, 33% {
visibility: visible;
}
34%, 100% {
visibility: hidden;
}
}
@media (max-width: 340px) {
.tb {
gap: 4px;
}
.tb__l {
font-size: 34px;
}
.tb__foot {
gap: 8px;
}
.tb__play {
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.tb.is-demo .tb__l,
.tb.is-demo .tb__loop i {
animation: none !important;
}
.tb.is-demo .tb__levelText {
color: #17141a !important;
}
.tb.is-demo .tb__loop {
visibility: hidden !important;
}
.tb__l {
transition: none !important;
}
}var root = document.getElementById('tb');
var playBtn = document.getElementById('play');
var playLabel = document.getElementById('playLabel');
var levelText = document.getElementById('levelText');
var letters = Array.prototype.slice.call(document.querySelectorAll('.tb__l'));
var ctx = null, analyser = null, freq = null, timer = 0, playing = false;
var BAND_NAMES = ['Bass', 'Mid', 'Treble'];
function buildAudio() {
ctx = new (window.AudioContext || window.webkitAudioContext)();
analyser = ctx.createAnalyser();
analyser.fftSize = 64;
analyser.smoothingTimeConstant = 0.6;
freq = new Uint8Array(analyser.frequencyBinCount);
var out = ctx.createGain();
out.gain.value = 0.06;
out.connect(analyser);
analyser.connect(ctx.destination);
// 외부 음원 파일 없이 코드 진행을 직접 합성한다 — 네 박자마다 화음이 바뀐다.
[196, 246.94, 293.66, 392].forEach(function (hz, n) {
var osc = ctx.createOscillator();
var amp = ctx.createGain();
osc.type = n === 3 ? 'triangle' : 'sawtooth';
osc.frequency.value = hz;
amp.gain.value = 0;
for (var beat = 0; beat < 64; beat++) {
var at = ctx.currentTime + beat * 0.5;
amp.gain.setValueAtTime(beat % 4 === n % 4 ? 0.9 : 0.25, at);
amp.gain.exponentialRampToValueAtTime(0.02, at + 0.45);
}
osc.connect(amp); amp.connect(out); osc.start();
});
}
function draw() {
analyser.getByteFrequencyData(freq);
// 대역 세 개의 에너지를 0~1 로 만들어 글자의 --j 로 넣는다. 대역마다 다른 글자가 튄다.
// 경계는 Bass 1~2번 빈, Mid 3~5번, Treble 6~10번 — 톱니파 고조파는 1/n 으로 줄어 성김문제로
// 중·고음에는 표시 게인(1.5·2.2배)을 준다. 미터 눈금 보정이지 소리를 키우는 게 아니다.
var GAIN = [1, 1.5, 2.2];
var bands = [0, 0, 0];
for (var k = 1; k <= 10; k++) {
var b = k <= 2 ? 0 : k <= 5 ? 1 : 2;
bands[b] += freq[k] / 255 / (b === 0 ? 2 : b === 1 ? 3 : 5);
}
for (var j = 0; j < 3; j++) { bands[j] = Math.min(1, bands[j] * GAIN[j]); }
for (var i = 0; i < letters.length; i++) {
var b = Number(letters[i].getAttribute('data-band'));
letters[i].style.setProperty('--j', bands[b].toFixed(3));
}
var top = bands.indexOf(Math.max(bands[0], bands[1], bands[2]));
if (levelText.textContent !== BAND_NAMES[top]) { levelText.textContent = BAND_NAMES[top]; }
timer = requestAnimationFrame(draw);
}
playBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (!ctx) { buildAudio(); }
playing = !playing;
playBtn.setAttribute('aria-pressed', playing ? 'true' : 'false');
playLabel.textContent = playing ? 'Stop' : 'Play';
// 자동재생 정책: AudioContext 는 사용자 제스처 뒤 resume() 해야 소리가 난다.
if (playing) { ctx.resume(); timer = requestAnimationFrame(draw); }
else { ctx.suspend(); cancelAnimationFrame(timer); }
});// TextBounce — vanilla 판(run/291/demo/05-audio-viz-text-bounce/index.html) 의 React 포트.
// 제목의 글자를 저·중·고 대역에 나눠 건다. 대역마다 다른 글자가 튄다(글자는 자기 중심에서 커진다).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TextBounce.module.scss";
export type TextBounceProps = {
/** 격자 미리보기용 2초 자동 루프. 재생을 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type LetterVars = CSSProperties & { "--j"?: string };
/** ambient 타입에 useRef<T> 의 null 합집합이 없어 로컬로 좁힌다 */
type Box<T> = { current: T };
const BAND_NAMES = ["저음", "중음", "고음"];
/** 브랜드 낱말이라 로케일을 안 탄다. data-band 로 저·중·고에 나눠 건다 */
const LETTERS: { ch: string; band: number }[] = [
{ ch: "G", band: 0 },
{ ch: "O", band: 1 },
{ ch: "D", band: 2 },
{ ch: "R", band: 0 },
{ ch: "I", band: 1 },
{ ch: "C", band: 2 },
{ ch: "H", band: 1 },
];
export default function TextBounce({
demo = true,
duration = 220,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#17141a",
}: TextBounceProps) {
const [live, setLive] = useState(demo);
const [playing, setPlaying] = useState(false);
const [bands, setBands] = useState<number[]>([0, 0, 0]);
const ctxBox = useRef(null) as Box<AudioContext | null>;
const nodeBox = useRef(null) as Box<AnalyserNode | null>;
const frameBox = useRef(0) as Box<number>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
return () => {
cancelAnimationFrame(frameBox.current);
if (ctxBox.current) {
ctxBox.current.close();
}
};
}, []);
function buildAudio() {
const ctx = new AudioContext();
const analyser = ctx.createAnalyser();
analyser.fftSize = 64;
analyser.smoothingTimeConstant = 0.6;
const out = ctx.createGain();
out.gain.value = 0.06;
out.connect(analyser);
analyser.connect(ctx.destination);
// 외부 음원 파일 없이 코드 진행을 직접 합성한다 — 네 박자마다 화음이 바뀐다.
[196, 246.94, 293.66, 392].forEach((hz, n) => {
const osc = ctx.createOscillator();
const amp = ctx.createGain();
osc.type = n === 3 ? "triangle" : "sawtooth";
osc.frequency.value = hz;
amp.gain.value = 0;
for (let beat = 0; beat < 64; beat++) {
const at = ctx.currentTime + beat * 0.5;
amp.gain.setValueAtTime(beat % 4 === n % 4 ? 0.9 : 0.25, at);
amp.gain.exponentialRampToValueAtTime(0.02, at + 0.45);
}
osc.connect(amp);
amp.connect(out);
osc.start();
});
ctxBox.current = ctx;
nodeBox.current = analyser;
}
function draw() {
const analyser = nodeBox.current;
if (!analyser) {
return;
}
const freq = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(freq);
// 대역 세 개의 에너지를 0~1 로 만들어 글자의 --j 로 넣는다(중·고음 게인 보정은 vanilla 주석 참조).
const GAIN = [1, 1.5, 2.2];
const next = [0, 0, 0];
for (let k = 1; k <= 10; k++) {
const b = k <= 2 ? 0 : k <= 5 ? 1 : 2;
next[b] += freq[k] / 255 / (b === 0 ? 2 : b === 1 ? 3 : 5);
}
for (let j = 0; j < 3; j++) {
next[j] = Math.min(1, next[j] * GAIN[j]);
}
setBands(next);
frameBox.current = requestAnimationFrame(draw);
}
function toggle() {
setLive(false);
if (!ctxBox.current) {
buildAudio();
}
const ctx = ctxBox.current;
if (!ctx) {
return;
}
const next = !playing;
setPlaying(next);
// 자동재생 정책: AudioContext 는 사용자 제스처 뒤 resume() 해야 소리가 난다.
if (next) {
ctx.resume();
frameBox.current = requestAnimationFrame(draw);
} else {
ctx.suspend();
cancelAnimationFrame(frameBox.current);
}
}
const cls = [styles.root, live ? styles.isDemo : ""].filter(Boolean).join(" ");
const top = bands.indexOf(Math.max(bands[0], bands[1], bands[2]));
return (
<main className={styles.stage} style={vars}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-waveform" viewBox="0 0 256 256" fill="currentColor">
<path d="M208 96v64H48V96Z" opacity=".2" />
<path d="M56 96v64a8 8 0 0 1-16 0V96a8 8 0 0 1 16 0m32-72a8 8 0 0 0-8 8v192a8 8 0 0 0 16 0V32a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v128a8 8 0 0 0 16 0V64a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v64a8 8 0 0 0 16 0V96a8 8 0 0 0-8-8m40-16a8 8 0 0 0-8 8v96a8 8 0 0 0 16 0V72a8 8 0 0 0-8-8" />
</symbol>
<symbol id="ico-play" viewBox="0 0 256 256" 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.88 15.88 0 0 0 0-27.02M80 215.94V40l143.83 88Z" />
</symbol>
<symbol id="ico-pause" viewBox="0 0 256 256" 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" />
</symbol>
</defs>
</svg>
<div className={cls}>
<p className={styles.hint}>
<svg className={styles.ico} aria-hidden="true">
<use href="#ico-waveform" />
</svg>
<span>재생을 누르면 실제 소리로 움직입니다</span>
</p>
<h2 className={styles.title} aria-label="GODRICH">
{LETTERS.map((l, i) => {
const letterVars: LetterVars = { "--j": bands[l.band].toFixed(3) };
return (
<b className={styles.l} key={i} style={letterVars}>
{l.ch}
</b>
);
})}
</h2>
<ul className={styles.legend} aria-hidden="true">
<li className={styles.bandLow}>저음</li>
<li className={styles.bandMid}>중음</li>
<li className={styles.bandHigh}>고음</li>
</ul>
<div className={styles.foot}>
<button className={styles.play} type="button" aria-pressed={playing} onClick={toggle}>
<svg className={[styles.ico, playing ? styles.icoHidden : ""].join(" ")} aria-hidden="true">
<use href="#ico-play" />
</svg>
<svg className={[styles.ico, playing ? "" : styles.icoHidden].join(" ")} aria-hidden="true">
<use href="#ico-pause" />
</svg>
<span>{playing ? "정지" : "재생"}</span>
</button>
<p className={styles.level}>
<span className={styles.levelLabel}>센 대역</span>
<b className={styles.levelBox}>
<span className={styles.levelText} aria-live="polite">
{BAND_NAMES[top]}
</span>
<span className={styles.loop} aria-hidden="true">
<i>저음</i>
<i>중음</i>
<i>고음</i>
</span>
</b>
</p>
</div>
</div>
</main>
);
}05 Bass-bouncing letters — Measured area 9.328% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · Audio Visualizer JavaScript: 9 Parts, No Library
The post this demo lives in
Audio Visualizer JavaScript: 9 Parts, No LibraryAn audio visualizer JavaScript project needs one thing from the browser: Web Audio's AnalyserNode, which turns live soun See all demos in this post


