05進捗に合わせて上がる液面
ボタン内の液面が進捗に合わせて上がり、水面は波の合成で揺れ、満ちるほど振幅が収まる。
使いどころ進行状況を視覚で見せるCTA
WAVE_AMPWAVE_FREQLEVEL_LERP

<main class="stage">
<div class="lq is-demo" id="lqRoot">
<button class="lq__btn" id="lqBtn" type="button">
<svg class="lq__wave" viewBox="0 0 240 72" preserveAspectRatio="none" aria-hidden="true"><path id="lqPath" d="M0 72H240V72Z"/></svg>
<span class="lq__text"><span id="lqLabel">ダウンロード</span> <b id="lqPct">0%</b></span>
</button>
<p class="lq__meta"><span class="lq__tag">LEVEL</span> <b id="lqLevel">0.00</b> <span class="lq__tag">AMP</span> <b id="lqAmp">0.0</b></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 수면은 SVG path 의 d 를 JS 가 매 프레임 다시 계산한다. 속성 트윈이 아니라 파동 합성이다.
$lq-face: $subject-cream;
$lq-water: $subject-sky;
@include stage($stage-ink);
.lq {
width: min(100%, 280px);
display: grid;
gap: $sp-3;
justify-items: center;
}
.lq__btn {
position: relative;
box-sizing: border-box;
width: 240px;
max-width: 100%;
height: 72px;
overflow: hidden;
border: 0;
border-radius: $r-card;
background: $lq-face;
color: $subject-ink;
font-family: inherit;
font-size: 20px;
font-weight: 700;
line-height: 24px;
cursor: pointer;
}
.lq__btn:focus-visible { outline: 3px solid $stage-paper; outline-offset: 3px; }
.lq__wave {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
fill: $lq-water;
}
.lq__text { position: relative; }
.lq__text b { font-variant-numeric: tabular-nums; }
.lq__meta {
margin: 0;
display: flex;
align-items: baseline;
gap: $sp-2;
font-size: 12px;
line-height: 16px;
color: $stage-paper;
}
.lq__tag { font-weight: 700; letter-spacing: .08em; }
.lq__meta b { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
@media (prefers-reduced-motion: reduce) {
.lq__btn { transition: none; }
}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);
}
.lq {
width: min(100%, 280px);
display: grid;
gap: 12px;
justify-items: center;
}
.lq__btn {
position: relative;
box-sizing: border-box;
width: 240px;
max-width: 100%;
height: 72px;
overflow: hidden;
border: 0;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-size: 20px;
font-weight: 700;
line-height: 24px;
cursor: pointer;
}
.lq__btn:focus-visible {
outline: 3px solid #ffffff;
outline-offset: 3px;
}
.lq__wave {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
fill: #6ec8ff;
}
.lq__text {
position: relative;
}
.lq__text b {
font-variant-numeric: tabular-nums;
}
.lq__meta {
margin: 0;
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
line-height: 16px;
color: #ffffff;
}
.lq__tag {
font-weight: 700;
letter-spacing: 0.08em;
}
.lq__meta b {
min-width: 4ch;
text-align: right;
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
.lq__btn {
transition: none;
}
}(function () {
// 기법: 수위는 목표 진행률을 향해 지수 보간(LEVEL_LERP)으로 따라가고, 수면은 사인 두 개를 합성한 곡선이다.
// 파동 진폭에 (1 - 0.7 x 수위) 를 곱해서 물이 찰수록 수면이 가라앉는다.
var WAVE_AMP = 6;
var WAVE_FREQ = 2;
var LEVEL_LERP = 6;
var RUN_MS = 1500;
var START_MS = 200;
var CYCLE_MS = 2000;
var W = 240;
var H = 72;
var POINTS = 48;
var root = document.getElementById('lqRoot');
var btn = document.getElementById('lqBtn');
var pathEl = document.getElementById('lqPath');
var labelEl = document.getElementById('lqLabel');
var pctEl = document.getElementById('lqPct');
var levelEl = document.getElementById('lqLevel');
var ampEl = document.getElementById('lqAmp');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var clock = 0;
var runStart = null;
var level = 0;
var target = 0;
var t0 = null;
var lastTs = null;
function stopDemo() { root.classList.remove('is-demo'); }
function progressAt(now) {
var s = root.classList.contains('is-demo')
? (now < START_MS ? null : START_MS + Math.floor((now - START_MS) / CYCLE_MS) * CYCLE_MS)
: runStart;
return s === null ? 0 : Math.max(0, Math.min(1, (now - s) / RUN_MS));
}
function draw(phase) {
var reach = WAVE_AMP * 1.5;
var base = -reach + (H + 2 * reach) * (1 - level);
var amp = WAVE_AMP * (1 - 0.7 * level);
var d = 'M0 ' + H;
for (var i = 0; i <= POINTS; i += 1) {
var x = (i / POINTS) * W;
var a = 2 * Math.PI * WAVE_FREQ * (x / W);
var y = base + amp * (Math.sin(a + phase) + 0.5 * Math.sin(2 * a - 1.3 * phase));
d += 'L' + x.toFixed(1) + ' ' + y.toFixed(2);
}
d += 'L' + W + ' ' + H + 'Z';
pathEl.setAttribute('d', d);
var pct = Math.round(level * 100);
pctEl.textContent = pct + '%';
labelEl.textContent = level > 0.995 ? '完了' : (level > 0.01 ? '受信中' : 'ダウンロード');
levelEl.textContent = level.toFixed(2);
ampEl.textContent = amp.toFixed(1);
}
function activate() {
stopDemo();
if (reduce) { level = 1; draw(0); return; }
if (runStart !== null && clock - runStart < RUN_MS) return;
runStart = clock;
}
btn.addEventListener('click', activate);
function tick(ts) {
if (t0 === null) t0 = ts;
clock = ts - t0;
var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
target = progressAt(clock);
level += (target - level) * (1 - Math.exp(-dt * LEVEL_LERP));
draw(clock * 0.006);
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
draw(0);
} else {
requestAnimationFrame(tick);
}
})();// LiquidLevelButton — vanilla 판(05-dlbtn-liquid-level-wave) 의 React 포트.
// 수위는 목표 진행률을 지수 보간(LEVEL_LERP)으로 따라가고, 수면은 사인 두 개의 합성이며 물이 찰수록 진폭이 줄어든다.
import { useEffect, useRef } from "react";
import styles from "./LiquidLevelButton.module.scss";
const WAVE_AMP = 6;
const WAVE_FREQ = 2;
const LEVEL_LERP = 6;
const RUN_MS = 1500;
const START_MS = 200;
const CYCLE_MS = 2000;
const W = 240;
const H = 72;
const POINTS = 48;
export default function LiquidLevelButton() {
const btnRef = useRef<HTMLButtonElement | null>(null);
const pathRef = useRef<SVGPathElement | null>(null);
const labelRef = useRef<HTMLSpanElement | null>(null);
const pctRef = useRef<HTMLElement | null>(null);
const levelRef = useRef<HTMLElement | null>(null);
const ampRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const btn = btnRef.current;
const pathEl = pathRef.current;
const labelEl = labelRef.current;
const pctEl = pctRef.current;
const levelEl = levelRef.current;
const ampEl = ampRef.current;
if (!btn || !pathEl || !labelEl || !pctEl || !levelEl || !ampEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let clock = 0;
let runStart: number | null = null;
let level = 0;
let t0: number | null = null;
let lastTs: number | null = null;
let raf = 0;
const progressAt = (now: number): number => {
const s = demo
? now < START_MS ? null : START_MS + Math.floor((now - START_MS) / CYCLE_MS) * CYCLE_MS
: runStart;
return s === null ? 0 : Math.max(0, Math.min(1, (now - s) / RUN_MS));
};
const draw = (phase: number): void => {
const reach = WAVE_AMP * 1.5;
const base = -reach + (H + 2 * reach) * (1 - level);
const amp = WAVE_AMP * (1 - 0.7 * level);
let d = "M0 " + H;
for (let i = 0; i <= POINTS; i += 1) {
const x = (i / POINTS) * W;
const a = 2 * Math.PI * WAVE_FREQ * (x / W);
const y = base + amp * (Math.sin(a + phase) + 0.5 * Math.sin(2 * a - 1.3 * phase));
d += "L" + x.toFixed(1) + " " + y.toFixed(2);
}
d += "L" + W + " " + H + "Z";
pathEl.setAttribute("d", d);
pctEl.textContent = Math.round(level * 100) + "%";
labelEl.textContent = level > 0.995 ? "완료" : level > 0.01 ? "받는 중" : "내려받기";
levelEl.textContent = level.toFixed(2);
ampEl.textContent = amp.toFixed(1);
};
const activate = (): void => {
demo = false;
if (reduce) { level = 1; draw(0); return; }
if (runStart !== null && clock - runStart < RUN_MS) return;
runStart = clock;
};
btn.addEventListener("click", activate);
function tick(ts: number): void {
if (t0 === null) t0 = ts;
clock = ts - t0;
const dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
level += (progressAt(clock) - level) * (1 - Math.exp(-dt * LEVEL_LERP));
draw(clock * 0.006);
raf = requestAnimationFrame(tick);
}
if (reduce) draw(0);
else raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
btn.removeEventListener("click", activate);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.lq}>
<button className={styles.lq__btn} type="button" ref={btnRef}>
<svg className={styles.lq__wave} viewBox="0 0 240 72" preserveAspectRatio="none" aria-hidden="true">
<path d="M0 72H240V72Z" ref={pathRef} />
</svg>
<span className={styles.lq__text}>
<span ref={labelRef}>내려받기</span> <b ref={pctRef}>0%</b>
</span>
</button>
<p className={styles.lq__meta}>
<span className={styles.lq__tag}>LEVEL</span> <b ref={levelRef}>0.00</b> <span className={styles.lq__tag}>AMP</span> <b ref={ampRef}>0.0</b>
</p>
</div>
</main>
);
}05 進捗に合わせて上がる液面 — 計測 累積面積 12.04% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · css download button 9パターン — 沈み・満ち・ファイルが飛ぶボタン
このデモが属する記事
css download button 9パターン — 沈み・満ち・ファイルが飛ぶボタンcss download button をラベルの入れ替えだけで作ると、矢印は硬く、進捗は段差になり、ファイルの行き先も見えない — 01番・02番・03番が計算で解く。取得ボタン一つを、押す・満ちる・届く・終わる・失敗するまで動きで見せる この記事のデモをすべて見る

