05액체가 차오르며 채워지는 북마크 버튼
누르면 아이콘 안쪽 아래에서부터 물결 모양의 색이 차올라 위쪽까지 채운다.
쓰임동영상 플레이어의 저장 버튼
WAVE_AMPLITUDEWAVE_FREQUENCYwaveD
<main class="stage">
<div class="lq is-demo" id="lq">
<p class="lq__caption">액체가 차오르며 채워지는 북마크 버튼</p>
<div class="lq__scene" id="lqScene">
<button class="lq__btn" id="lqBtn" type="button" aria-pressed="false">
<svg class="lq__icon" viewBox="0 0 24 24" width="60" height="60" aria-hidden="true" focusable="false">
<defs>
<clipPath id="lqClip">
<path d="M6.5 3A1.5 1.5 0 0 1 8 1.5h8A1.5 1.5 0 0 1 17.5 3v18.2a.9.9 0 0 1-1.38.76L12 19.1l-4.12 2.86A.9.9 0 0 1 6.5 21.2Z"/>
</clipPath>
</defs>
<path class="lq__outline" d="M6.5 3A1.5 1.5 0 0 1 8 1.5h8A1.5 1.5 0 0 1 17.5 3v18.2a.9.9 0 0 1-1.38.76L12 19.1l-4.12 2.86A.9.9 0 0 1 6.5 21.2Z"/>
<path class="lq__wave" id="lqWave" clip-path="url(#lqClip)" d="M0,24 L24,24 L24,24 L0,24 Z"/>
</svg>
</button>
</div>
<p class="lq__status" id="lqStatus" aria-live="polite">저장 전</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 물 높이(level) + 시간 위상 → 매 프레임 사인 물결 path 를 다시 계산한다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-sky;
@include stage($stage-paper);
.lq {
display: grid;
gap: $sp-3;
justify-items: center;
}
.lq__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: $subject-ink;
}
.lq__scene {
position: relative;
width: 200px;
height: 140px;
display: grid;
place-items: center;
--level: 0;
}
.lq__scene::before {
content: "";
position: absolute;
inset: 0;
margin: auto;
width: 200px;
height: 140px;
border-radius: $r-card;
background: radial-gradient(circle, rgba($subject-sky, calc(.05 + var(--level) * .78)) 0%, rgba($subject-sky, calc(.02 + var(--level) * .32)) 55%, transparent 88%);
pointer-events: none;
}
.lq__btn {
position: relative;
z-index: 1;
border: 0;
background: $subject-ink;
border-radius: $r-card;
padding: $sp-6;
cursor: pointer;
}
.lq__btn:focus-visible {
outline: 2px solid $subject-sky;
outline-offset: 3px;
}
.lq__outline {
fill: none;
stroke: $subject-cream;
stroke-width: 1.4;
stroke-linejoin: round;
}
.lq__wave {
fill: $color;
}
.lq__status {
margin: 0;
font-size: 11px;
color: $subject-ink;
font-weight: 600;
}
@media (max-width: 340px) {
.lq__scene { width: 150px; height: 96px; }
.lq__btn { padding: $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.lq__wave { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lq {
display: grid;
gap: 12px;
justify-items: center;
}
.lq__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.lq__scene {
position: relative;
width: 200px;
height: 140px;
display: grid;
place-items: center;
--level: 0;
}
.lq__scene::before {
content: "";
position: absolute;
inset: 0;
margin: auto;
width: 200px;
height: 140px;
border-radius: 16px;
background: radial-gradient(circle, rgba(110, 200, 255, calc(0.05 + var(--level) * 0.78)) 0%, rgba(110, 200, 255, calc(0.02 + var(--level) * 0.32)) 55%, transparent 88%);
pointer-events: none;
}
.lq__btn {
position: relative;
z-index: 1;
border: 0;
background: #17141a;
border-radius: 16px;
padding: 24px;
cursor: pointer;
}
.lq__btn:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 3px;
}
.lq__outline {
fill: none;
stroke: #fff7e6;
stroke-width: 1.4;
stroke-linejoin: round;
}
.lq__wave {
fill: #6ec8ff;
}
.lq__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.lq__scene {
width: 150px;
height: 96px;
}
.lq__btn {
padding: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.lq__wave {
transition: none;
}
}(function () {
"use strict";
var WAVE_AMPLITUDE = 1.15;
var WAVE_FREQUENCY = 0.85;
var WAVE_SPEED = 2.6;
var LEVEL_LERP = 0.16;
var CYCLE_S = 2;
var root = document.getElementById("lq");
var scene = document.getElementById("lqScene");
var btn = document.getElementById("lqBtn");
var wave = document.getElementById("lqWave");
var status = document.getElementById("lqStatus");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var saved = false;
function setSaved(v) {
saved = v;
btn.setAttribute("aria-pressed", String(v));
status.textContent = v ? "저장됨" : "저장 전";
}
var level = 0;
var target = 0;
// 값 하나: 물 높이(level, 0~1) + 시간 위상(phase) → 매 프레임 다시 계산한 사인 물결 path.
function waveD(lv, phase) {
var baseY = 24 * (1 - lv);
var d = "M0," + (baseY + WAVE_AMPLITUDE * Math.sin(phase)).toFixed(2) + " ";
for (var x = 0; x <= 24; x += 2) {
var y = baseY + WAVE_AMPLITUDE * Math.sin(x * WAVE_FREQUENCY * 0.26 + phase);
d += "L" + x + "," + y.toFixed(2) + " ";
}
d += "L24,24 L0,24 Z";
return d;
}
btn.addEventListener("click", function () {
root.classList.remove("is-demo");
setSaved(!saved);
target = saved ? 1 : 0;
});
var start = null;
var prevT = 0;
var toggledA = false;
var toggledB = false;
var phase = 0;
function loop(now) {
if (start === null) start = now;
var t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { toggledA = false; toggledB = false; }
prevT = t;
if (!reduce && root.classList.contains("is-demo")) {
if (!toggledA && t > 0.25) { toggledA = true; setSaved(true); target = 1; }
if (!toggledB && t > 1.25) { toggledB = true; setSaved(false); target = 0; }
}
if (reduce) {
// 정지: 물 높이는 목표값으로 즉시 고정, 위상은 갱신하지 않아 물결이 멈춘 채로 표시된다.
level = target;
} else {
level += (target - level) * LEVEL_LERP;
phase = ((now - start) / 1000) * WAVE_SPEED;
}
wave.setAttribute("d", waveD(level, phase));
scene.style.setProperty("--level", level.toFixed(3));
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// LiquidFillWaveBookmark — vanilla 판(run/416/demo/05-bookmark-btn-liquid-fill-wave/index.html) 의 React 포트.
// 값 하나: 물 높이(level) + 시간 위상(phase) → 매 프레임 다시 계산한 사인 물결 SVG path + 배경 톤.
import { useEffect, useRef, useState } from "react";
import styles from "./LiquidFillWaveBookmark.module.scss";
type Box<T> = { current: T };
const WAVE_AMPLITUDE = 1.15;
const WAVE_FREQUENCY = 0.85;
const WAVE_SPEED = 2.6;
const LEVEL_LERP = 0.16;
const CYCLE_S = 2;
function waveD(lv: number, phase: number): string {
const baseY = 24 * (1 - lv);
let d = "M0," + (baseY + WAVE_AMPLITUDE * Math.sin(phase)).toFixed(2) + " ";
for (let x = 0; x <= 24; x += 2) {
const y = baseY + WAVE_AMPLITUDE * Math.sin(x * WAVE_FREQUENCY * 0.26 + phase);
d += "L" + x + "," + y.toFixed(2) + " ";
}
d += "L24,24 L0,24 Z";
return d;
}
export default function LiquidFillWaveBookmark() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const waveRef = useRef<SVGPathElement | null>(null);
const levelRef = useRef<number>(0) as Box<number>;
const targetRef = useRef<number>(0) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const [saved, setSaved] = useState(false);
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0;
let prevT = 0;
let toggledA = false;
let toggledB = false;
let phase = 0;
function loop(now: number): void {
if (start === 0) start = now;
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { toggledA = false; toggledB = false; }
prevT = t;
if (!reduce && demo) {
if (!toggledA && t > 0.25) { toggledA = true; setSaved(true); targetRef.current = 1; }
if (!toggledB && t > 1.25) { toggledB = true; setSaved(false); targetRef.current = 0; }
}
if (reduce) {
// 정지: 물 높이는 목표값으로 즉시 고정, 위상은 갱신하지 않아 물결이 멈춘 채로 표시된다.
levelRef.current = targetRef.current;
} else {
levelRef.current += (targetRef.current - levelRef.current) * LEVEL_LERP;
phase = ((now - start) / 1000) * WAVE_SPEED;
}
const wave = waveRef.current;
const scene = sceneRef.current;
if (wave) wave.setAttribute("d", waveD(levelRef.current, phase));
if (scene) scene.style.setProperty("--level", levelRef.current.toFixed(3));
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
function onClick(): void {
setDemo(false);
const v = !saved;
setSaved(v);
targetRef.current = v ? 1 : 0;
}
return (
<main className={styles.stage}>
<div className={styles.lq}>
<p className={styles.caption}>액체가 차오르며 채워지는 북마크 버튼</p>
<div className={styles.scene} ref={sceneRef}>
<button className={styles.btn} type="button" aria-pressed={saved} onClick={onClick}>
<svg className={styles.icon} viewBox="0 0 24 24" width={60} height={60} aria-hidden="true" focusable="false">
<defs>
<clipPath id="lqClip">
<path d="M6.5 3A1.5 1.5 0 0 1 8 1.5h8A1.5 1.5 0 0 1 17.5 3v18.2a.9.9 0 0 1-1.38.76L12 19.1l-4.12 2.86A.9.9 0 0 1 6.5 21.2Z" />
</clipPath>
</defs>
<path className={styles.outline} d="M6.5 3A1.5 1.5 0 0 1 8 1.5h8A1.5 1.5 0 0 1 17.5 3v18.2a.9.9 0 0 1-1.38.76L12 19.1l-4.12 2.86A.9.9 0 0 1 6.5 21.2Z" />
<path className={styles.wave} ref={waveRef} clipPath="url(#lqClip)" d="M0,24 L24,24 L24,24 L0,24 Z" />
</svg>
</button>
</div>
<p className={styles.status} aria-live="polite">{saved ? "저장됨" : "저장 전"}</p>
</div>
</main>
);
}05 액체가 차오르며 채워지는 북마크 버튼 — 측정 누적면적 10.059% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 북마크 버튼 애니메이션 9가지 — 즐겨찾기 버튼도 같은 원리다


