09막대 반응 — 클릭할 때마다 여러 막대의 높이 값이 저마다 다르게 튀었다가 가라앉는 버튼
클릭이 일어날 때마다 나열된 여러 막대 각각의 높이 값이 서로 다른 크기로 순간 튀어오르고, 시간이 지나면서 각 막대의 높이 값이 줄어들어 처음 높이로 가라앉은 상태가 됩니다.
쓰임알림음 켜기 버튼, 재생/일시정지 버튼
DECAY=0.14MIN_H=0.14hit

<main class="stage">
<div class="ctavum is-demo" id="ctavum">
<div class="ctavum__panel">
<button class="ctavum__btn" id="ctavumBtn" type="button">
<span class="ctavum__bars" id="ctavumBars"></span>
<span class="ctavum__label">알림음 켜기</span>
</button>
<span class="ctavum__hint">클릭할 때마다 막대가 튀었다가 가라앉습니다</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(피크-감쇠 곡선)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-ink);
.stage { position: relative; }
.ctavum__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
padding: $sp-5;
border-radius: $r-card;
background: $tick-panel;
}
.ctavum__btn {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
border: 0;
border-radius: $r-control;
background: $subject-ink;
padding: $sp-4 $sp-5;
cursor: pointer;
}
.ctavum__bars {
display: flex;
align-items: flex-end;
gap: 4px;
height: 40px;
}
.ctavum__bar {
width: 6px;
height: 14%;
border-radius: $r-xs;
background: $tick-up;
}
.ctavum__bar:nth-child(4),
.ctavum__bar:nth-child(5) {
background: $tick-down;
}
.ctavum__label {
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 13px;
}
.ctavum__hint {
color: $tick-mute;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctavum__panel { padding: $sp-4; gap: $sp-3; }
.ctavum__btn { padding: $sp-3 $sp-4; }
.ctavum__bars { height: 34px; }
}
@media (prefers-reduced-motion: reduce) {
.ctavum__bar { 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.ctavum__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 20px;
border-radius: 16px;
background: #231f28;
}
.ctavum__btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
border: 0;
border-radius: 12px;
background: #17141a;
padding: 16px 20px;
cursor: pointer;
}
.ctavum__bars {
display: flex;
align-items: flex-end;
gap: 4px;
height: 40px;
}
.ctavum__bar {
width: 6px;
height: 14%;
border-radius: 4px;
background: #3ddc97;
}
.ctavum__bar:nth-child(4),
.ctavum__bar:nth-child(5) {
background: #ff7a5e;
}
.ctavum__label {
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 13px;
}
.ctavum__hint {
color: #9a93a6;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctavum__panel {
padding: 16px;
gap: 12px;
}
.ctavum__btn {
padding: 12px 16px;
}
.ctavum__bars {
height: 34px;
}
}
@media (prefers-reduced-motion: reduce) {
.ctavum__bar {
transition: none !important;
}
}(function () {
var root = document.getElementById('ctavum');
var btn = document.getElementById('ctavumBtn');
var barsWrap = document.getElementById('ctavumBars');
var N = 5;
var bars = [];
for (var i = 0; i < N; i++) {
var el = document.createElement('span');
el.className = 'ctavum__bar';
barsWrap.appendChild(el);
bars.push({ el: el, h: 0.14 });
}
// 계산 겹: 클릭마다 막대별 목표 높이 값을 서로 다른 난수로 정하고, 매 프레임
// 현재 높이에서 목표까지 감쇠(DECAY)시켜 실제 VU 미터의 피크-감쇠 곡선을 흉내낸다.
var DECAY = 0.14, MIN_H = 0.14;
var raf = null;
function render() {
for (var i = 0; i < N; i++) {
bars[i].el.style.height = (bars[i].h * 100).toFixed(1) + '%';
}
}
function tick() {
var moving = false;
for (var i = 0; i < N; i++) {
var b = bars[i];
var diff = MIN_H - b.h;
if (b.h > MIN_H) {
b.h += diff * DECAY;
if (b.h - MIN_H > 0.004) { moving = true; } else { b.h = MIN_H; }
}
}
render();
if (moving) { raf = requestAnimationFrame(tick); } else { raf = null; }
}
function hit() {
for (var i = 0; i < N; i++) {
bars[i].h = Math.max(bars[i].h, MIN_H + Math.random() * 0.82);
}
render();
if (!raf) { raf = requestAnimationFrame(tick); }
}
function wireReal() {
btn.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); hit(); });
}
wireReal();
render();
var autoplayTimer = null;
function stopAutoplay() { if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { hit(); autoplayTimer = setInterval(hit, 420); }
})();import { useEffect, useRef } from "react";
import styles from "./CtaBtnVuMeterTap.module.scss";
export type CtaBtnVuMeterTapProps = {
color?: string;
};
const N = 5;
export default function CtaBtnVuMeterTap({
color = "#4cd4a6",
}: CtaBtnVuMeterTapProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector<HTMLButtonElement>(`.${styles.btn}`);
const barsWrap = root.querySelector<HTMLSpanElement>(`.${styles.bars}`);
const hint = root.querySelector<HTMLSpanElement>(`.${styles.hint}`);
if (!btn || !barsWrap || !hint) return;
const bars: { el: HTMLSpanElement; h: number }[] = [];
for (let i = 0; i < N; i++) {
const el = document.createElement("span");
el.className = styles.bar;
barsWrap.appendChild(el);
bars.push({ el, h: 0.14 });
}
// 계산 겹: 클릭마다 막대별 목표 높이 값을 서로 다른 난수로 정하고, 매 프레임
// 현재 높이에서 목표까지 감쇠(DECAY)시켜 실제 VU 미터의 피크-감쇠 곡선을 흉내낸다.
const DECAY = 0.14;
const MIN_H = 0.14;
let raf: number | null = null;
function render() {
for (let i = 0; i < N; i++) {
bars[i].el.style.height = (bars[i].h * 100).toFixed(1) + "%";
}
}
function tick() {
let moving = false;
for (let i = 0; i < N; i++) {
const b = bars[i];
const diff = MIN_H - b.h;
if (b.h > MIN_H) {
b.h += diff * DECAY;
if (b.h - MIN_H > 0.004) { moving = true; } else { b.h = MIN_H; }
}
}
render();
raf = moving ? requestAnimationFrame(tick) : null;
}
function hit() {
for (let i = 0; i < N; i++) {
bars[i].h = Math.max(bars[i].h, MIN_H + Math.random() * 0.82);
}
render();
if (raf === null) { raf = requestAnimationFrame(tick); }
}
function onClick() { stopAutoplay(); hit(); }
btn.addEventListener("click", onClick);
render();
let autoplayTimer: number | null = null;
function stopAutoplay() { if (autoplayTimer !== null) { window.clearInterval(autoplayTimer); autoplayTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { hit(); autoplayTimer = window.setInterval(hit, 420); }
return () => {
stopAutoplay();
if (raf !== null) cancelAnimationFrame(raf);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.panel}>
<button className={styles.btn} type="button" style={{ background: color }}>
<span className={styles.bars} />
<span className={styles.label}>알림음 켜기</span>
</button>
<span className={styles.hint}>클릭할 때마다 막대가 튀었다가 가라앉습니다</span>
</div>
</div>
);
}09 막대 반응 — 클릭할 때마다 여러 막대의 높이 값이 저마다 다르게 튀었다가 가라앉는 버튼 — 측정 누적면적 0.293% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · CTA 버튼 디자인 9종 — 그림자가 늘어나는 것부터 막대가 튀는 것까지
이 데모가 속한 글
CTA 버튼 디자인 9종 — 그림자가 늘어나는 것부터 막대가 튀는 것까지cta 버튼 디자인은 클릭을 부르는 버튼에 상태 변화를 계산해 얹는 작업인데, 아홉 개 중 하나는 그림자가 뻗어나가는 길이 값이 호버 상태에 따라 늘고 줄고, 하나는 둘레를 감싼 진행 값이 정해진 시간 동안 0까지 이 글의 데모 전부 보기

