06Circular Spectrum Ring That Grows From a Radius Value
Each dot ringing the album art gets its radius from a synthetic amplitude value, and while playing that radius ripples over time so the ring around the art pulses larger and smaller.
Best forAlbum detail pages and fullscreen player backdrops
makeLcgsynthRANGE_R

<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="viz is-demo is-playing" id="viz">
<p class="viz__hint"><span>Press play and the radius value is computed to spread a spectrum ring around the album art.</span></p>
<div class="viz__panel">
<div class="viz__ring-wrap">
<canvas class="viz__ring" id="ring" width="140" height="140" aria-hidden="true"></canvas>
<div class="viz__art"></div>
<span class="viz__colorref" aria-hidden="true"></span>
</div>
<button class="viz__play" id="play" type="button" aria-label="Play">
<svg class="viz__ico viz__ico--play" aria-hidden="true"><use href="#ico-play"></use></svg>
<svg class="viz__ico viz__ico--pause" aria-hidden="true"><use href="#ico-pause"></use></svg>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(사인 합성 반지름 값)은 index.html 의 synth()/draw() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-yellow);
.viz {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(400px, 92vw);
}
.viz__hint {
margin: 0;
color: $subject-ink;
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.viz__panel {
width: 100%;
border-radius: $r-card;
background: rgba($subject-ink, .06);
box-shadow: 0 0 0 1px rgba($subject-ink, .14);
padding: $sp-3;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
}
.viz__ring-wrap {
position: relative;
width: 140px;
height: 140px;
display: grid;
place-items: center;
}
.viz__ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.viz__art {
width: 64px;
height: 64px;
border-radius: $r-pill;
background: $color;
box-shadow: 0 0 0 1px rgba($subject-ink, .18);
}
.viz__colorref {
display: none;
color: $color;
}
.viz__play {
flex: 0 0 auto;
width: $sp-8;
height: $sp-8;
border-radius: $r-pill;
border: none;
background: $subject-ink;
color: $stage-yellow;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform $duration $easing;
}
.viz__play:hover { transform: scale(1.06); }
.viz__play:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.viz__play:active { transform: scale(.96); }
.viz__ico { width: 16px; height: 16px; }
.viz__ico--pause { display: none; }
.is-playing .viz__ico--play { display: none; }
.is-playing .viz__ico--pause { display: block; }
@media (max-width: 340px) {
.viz__panel { padding: 4px; gap: 4px; }
.viz__ring-wrap { width: 92px; height: 92px; }
.viz__art { width: 42px; height: 42px; }
.viz__play { width: 28px; height: 28px; }
}
@media (prefers-reduced-motion: reduce) {
.viz__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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.viz {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(400px, 92vw);
}
.viz__hint {
margin: 0;
color: #17141a;
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.viz__panel {
width: 100%;
border-radius: 16px;
background: rgba(23, 20, 26, 0.06);
box-shadow: 0 0 0 1px rgba(23, 20, 26, 0.14);
padding: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.viz__ring-wrap {
position: relative;
width: 140px;
height: 140px;
display: grid;
place-items: center;
}
.viz__ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.viz__art {
width: 64px;
height: 64px;
border-radius: 999px;
background: #0a4fe8;
box-shadow: 0 0 0 1px rgba(23, 20, 26, 0.18);
}
.viz__colorref {
display: none;
color: #0a4fe8;
}
.viz__play {
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: 999px;
border: none;
background: #17141a;
color: #ffd23f;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 300ms ease-out;
}
.viz__play:hover {
transform: scale(1.06);
}
.viz__play:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.viz__play:active {
transform: scale(0.96);
}
.viz__ico {
width: 16px;
height: 16px;
}
.viz__ico--pause {
display: none;
}
.is-playing .viz__ico--play {
display: none;
}
.is-playing .viz__ico--pause {
display: block;
}
@media (max-width: 340px) {
.viz__panel {
padding: 4px;
gap: 4px;
}
.viz__ring-wrap {
width: 92px;
height: 92px;
}
.viz__art {
width: 42px;
height: 42px;
}
.viz__play {
width: 28px;
height: 28px;
}
}
@media (prefers-reduced-motion: reduce) {
.viz__play {
transition: none;
}
}(function () {
var root = document.getElementById('viz');
var playBtn = document.getElementById('play');
var canvas = document.getElementById('ring');
var ctx = canvas.getContext('2d');
var colorRef = document.querySelector('.viz__colorref');
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 POINT_COUNT = 40;
var INNER_R = 36; // 앨범 아트 바깥 반지름 — 링이 시작되는 지점
var RANGE_R = 36; // 반지름 값이 늘어날 수 있는 최대 폭 — 변형: 진폭 배율, 값만 키움(20 → 출렁임이 보이도록)
var rng = makeLcg(4813);
var points = [];
for (var i = 0; i < POINT_COUNT; i++) {
points.push({
// 사인 곡선 두 겹을 겹쳐 점마다 다른 위상·주파수로 반지름 값을 합성한다 — 실제 소리를 분석해 얻는 값이 아니라
// 291 편의 AudioContext/AnalyserNode 분석과 달리 여기서는 사인 합성만으로 만든 장식용 값이다.
f1: 0.5 + rng() * 0.4,
p1: rng() * Math.PI * 2,
f2: 1.3 + rng() * 0.7,
p2: rng() * Math.PI * 2,
w2: 0.2 + rng() * 0.25,
idle: 0.08 + rng() * 0.06
});
}
function synth(pt, tSec) {
var s1 = Math.sin(tSec * pt.f1 * Math.PI * 2 + pt.p1);
var s2 = Math.sin(tSec * pt.f2 * Math.PI * 2 + pt.p2) * pt.w2;
var v = (s1 + s2) / (1 + pt.w2); // -1..1
return 0.5 + v * 0.5; // 0..1
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
var playing = true, t0 = 0;
var ringColor = '#3a3a3a';
function draw(now, playingNow) {
var w = canvas.width, h = canvas.height;
var cx = w / 2, cy = h / 2;
var tSec = (now - t0) / 1000;
ctx.clearRect(0, 0, w, h);
// 점마다 반지름 값을 먼저 계산해 아래 면·틱이 같은 값을 공유한다
var vals = [];
for (var i = 0; i < POINT_COUNT; i++) {
vals.push(playingNow ? synth(points[i], tSec) : points[i].idle);
}
// 출렁이는 반지름을 면으로 이어 진폭이 화면 면적으로 보이게 한다
ctx.beginPath();
for (var j = 0; j <= POINT_COUNT; j++) {
var k = j % POINT_COUNT;
var rj = INNER_R + RANGE_R * Math.max(0.04, vals[k]);
var aj = (k / POINT_COUNT) * Math.PI * 2 - Math.PI / 2;
var px = cx + Math.cos(aj) * rj, py = cy + Math.sin(aj) * rj;
if (j === 0) { ctx.moveTo(px, py); } else { ctx.lineTo(px, py); }
}
ctx.closePath();
ctx.fillStyle = ringColor;
ctx.globalAlpha = 0.42;
ctx.fill();
ctx.lineWidth = 3;
ctx.globalAlpha = 1;
ctx.strokeStyle = ringColor;
ctx.stroke();
ctx.lineCap = 'round';
ctx.strokeStyle = ringColor;
for (var i2 = 0; i2 < POINT_COUNT; i2++) {
var v = vals[i2];
var r1 = INNER_R;
var r2 = INNER_R + RANGE_R * Math.max(0.04, v);
var angle = (i2 / POINT_COUNT) * Math.PI * 2 - Math.PI / 2;
var cos = Math.cos(angle), sin = Math.sin(angle);
ctx.globalAlpha = 0.35 + v * 0.65;
ctx.lineWidth = 4.2;
ctx.beginPath();
ctx.moveTo(cx + cos * r1, cy + sin * r1);
ctx.lineTo(cx + cos * r2, cy + sin * r2);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
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;
draw(now, isDemo ? true : playing);
requestAnimationFrame(frame);
}
ringColor = getComputedStyle(colorRef).color || ringColor;
if (reduce) {
root.classList.add('is-still');
t0 = performance.now();
draw(t0, false);
playBtn.addEventListener('click', function () {
human(); setPlaying(!playing);
draw(performance.now(), playing);
});
playBtn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); human(); setPlaying(!playing);
draw(performance.now(), playing);
}
});
} else {
playBtn.addEventListener('click', onPlayClick);
playBtn.addEventListener('keydown', onPlayKeydown);
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from "react";
import styles from "./VisualizerCanvas.module.scss";
type Point = { f1: number; p1: number; f2: number; p2: number; w2: number; idle: number };
export default function VisualizerCanvas() {
const rootRef = useRef<HTMLDivElement | null>(null);
const playRef = useRef<HTMLButtonElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const colorRefEl = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const playBtn = playRef.current;
const canvas = canvasRef.current;
const colorRef = colorRefEl.current;
if (!root || !playBtn || !canvas || !colorRef) 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 POINT_COUNT = 40;
const INNER_R = 36; // 앨범 아트 바깥 반지름 — 링이 시작되는 지점
const RANGE_R = 36; // 반지름 값이 늘어날 수 있는 최대 폭 — 변형: 진폭 배율, 값만 키움(20 → 출렁임이 보이도록)
const rng = makeLcg(4813);
const points: Point[] = [];
for (let i = 0; i < POINT_COUNT; i++) {
points.push({
// 사인 곡선 두 겹을 겹쳐 점마다 다른 위상·주파수로 반지름 값을 합성한다 — 실제 소리를 분석해 얻는 값이 아니라
// 291 편의 AudioContext/AnalyserNode 분석과 달리 여기서는 사인 합성만으로 만든 장식용 값이다.
f1: 0.5 + rng() * 0.4,
p1: rng() * Math.PI * 2,
f2: 1.3 + rng() * 0.7,
p2: rng() * Math.PI * 2,
w2: 0.2 + rng() * 0.25,
idle: 0.08 + rng() * 0.06,
});
}
function synth(pt: Point, tSec: number) {
const s1 = Math.sin(tSec * pt.f1 * Math.PI * 2 + pt.p1);
const s2 = Math.sin(tSec * pt.f2 * Math.PI * 2 + pt.p2) * pt.w2;
const v = (s1 + s2) / (1 + pt.w2); // -1..1
return 0.5 + v * 0.5; // 0..1
}
function human() { if (isDemo) { root!.classList.remove(styles["is-demo"]); isDemo = false; } }
let playing = true, t0 = 0;
let ringColor = "#3a3a3a";
function draw(now: number, playingNow: boolean) {
const w = canvas!.width, h = canvas!.height;
const cx = w / 2, cy = h / 2;
const tSec = (now - t0) / 1000;
ctx!.clearRect(0, 0, w, h);
// 점마다 반지름 값을 먼저 계산해 아래 면·틱이 같은 값을 공유한다
const vals: number[] = [];
for (let i = 0; i < POINT_COUNT; i++) {
vals.push(playingNow ? synth(points[i], tSec) : points[i].idle);
}
// 출렁이는 반지름을 면으로 이어 진폭이 화면 면적으로 보이게 한다
ctx!.beginPath();
for (let j = 0; j <= POINT_COUNT; j++) {
const k = j % POINT_COUNT;
const rj = INNER_R + RANGE_R * Math.max(0.04, vals[k]);
const aj = (k / POINT_COUNT) * Math.PI * 2 - Math.PI / 2;
const px = cx + Math.cos(aj) * rj, py = cy + Math.sin(aj) * rj;
if (j === 0) { ctx!.moveTo(px, py); } else { ctx!.lineTo(px, py); }
}
ctx!.closePath();
ctx!.fillStyle = ringColor;
ctx!.globalAlpha = 0.42;
ctx!.fill();
ctx!.lineWidth = 3;
ctx!.globalAlpha = 1;
ctx!.strokeStyle = ringColor;
ctx!.stroke();
ctx!.lineCap = "round";
ctx!.strokeStyle = ringColor;
for (let i2 = 0; i2 < POINT_COUNT; i2++) {
const v = vals[i2];
const r1 = INNER_R;
const r2 = INNER_R + RANGE_R * Math.max(0.04, v);
const angle = (i2 / POINT_COUNT) * Math.PI * 2 - Math.PI / 2;
const cos = Math.cos(angle), sin = Math.sin(angle);
ctx!.globalAlpha = 0.35 + v * 0.65;
ctx!.lineWidth = 4.2;
ctx!.beginPath();
ctx!.moveTo(cx + cos * r1, cy + sin * r1);
ctx!.lineTo(cx + cos * r2, cy + sin * r2);
ctx!.stroke();
}
ctx!.globalAlpha = 1;
}
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;
draw(now, isDemo ? true : playing);
raf = requestAnimationFrame(frame);
}
ringColor = getComputedStyle(colorRef).color || ringColor;
function onReduceClick() { human(); setPlaying(!playing); draw(performance.now(), playing); }
function onReduceKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); human(); setPlaying(!playing);
draw(performance.now(), playing);
}
}
if (reduce) {
root.classList.add(styles["is-still"]);
t0 = performance.now();
draw(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);
}
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.viz + " " + styles["is-demo"] + " " + styles["is-playing"]} ref={rootRef}>
<p className={styles.hint}><span>재생을 누르면 반지름 값이 계산되어 앨범 아트 둘레로 스펙트럼 링이 번집니다</span></p>
<div className={styles.panel}>
<div className={styles.ringWrap}>
<canvas className={styles.ring} ref={canvasRef} width={140} height={140} aria-hidden="true" />
<div className={styles.art} />
<span className={styles.colorref} ref={colorRefEl} aria-hidden="true" />
</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>
</div>
</main>
);
}06 Circular Spectrum Ring That Grows From a Radius Value — Measured area 8.062% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Custom Audio Player CSS — 9 Computed UI Patterns
The post this demo lives in
Custom Audio Player CSS — 9 Computed UI PatternsAmong nine custom audio player css demos, one precomputes bar heights from sine waves, one decays angular velocity so th See all demos in this post
If you like this effect - similar demos
Creative UI06 Circular gauge bar — an angle that fills in proportion to a valueCSS Radial Menu: 9 Circular Control Patterns
Creative UI07 Cells That Even Out as the Iteration-count Value GrowsVoronoi Diagram Generator — 9 Patterns
Creative UI01 Bar spectrumAudio Visualizer JavaScript: 9 Parts, No Library