ブックマーク ボタン html 実装 9パターン
ブックマーク ボタン htmlでは、保存。たった一言に九つの表情がある — 03番は押した瞬間にスプリング値が弾み、06番は保存数の値が一桁ずつ積み重なってバッジを押し上げ、09番はドラッグ距離の値が届くとリストにスッと引き寄せられる。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 ホバーで軽く傾いて光るブックマークアイコン
- 02 輪郭から塗りつぶしに変わるブックマークボタン
- 03 スプリングで弾んで大きくなるブックマークボタン
- 04 リボンが折れて落ちるブックマークボタン
- 05 液体が満ちていくブックマークボタン
- 06 保存数が一桁ずつ増えるバッジ
- 07 保存を知らせるトーストのスライド
- 08 保存先コレクションを選ぶポップオーバー
- 09 ドラッグして読書リストに落とすブックマーク
値ひとつがブックマーク ボタン html の保存の感触をどう変えるか — 9パターンの並び順
以下の9つはどれも「保存した/していない」という同じ2状態にたどり着きますが、そこに至る計算はそれぞれ違う値ひとつが握っています。並び順はおおよそ装飾寄りから機構寄りへ進みます — 01はポインターが乗っている間だけ反応し、02と03は一度のタップを形かスケールに変え、04と05はそのタップにもう一段(折れる・満ちる)物理的な読み取りを重ね、06と07は保存をもう一箇所の変化に伝え、08と09は一度のクリックではなく長押しやドラッグという長い操作に駆動値を渡します。お気に入り ボタン デザインとして紹介される実装の多くは前半の3〜4種で止まりますが、リストや件数、フォルダが絡む本物の保存機能はむしろ後半5種に近づきます。
ブックマーク ボタン html が実際に置かれる画面
01と02は密度優先の画面向きです — 30枚の記事カードが並ぶ一覧の中で、ホバーの傾きや輪郭から塗りへのモーフィングは20px前後でも十分読み取れ、行の他の要素が保存に合わせて動く必要もありません。03・04・06はもう少し大きな舞台を求めます — コメント欄・記事ヘッダー・カウンターバッジのように、一度の保存が画面上で何度も起きない場所でこそ、小さなお祝いの動きが意味を持ちます。
07・08・09は単発のタップ以外の何かに駆動される3つです。トーストは画面下にまだ何も占有していない場所を必要とし、コレクションのポップオーバーはボタンの下にパネルが開くための余白を必要とし、ドラッグして並べ替えるしおり ボタンは実際に落とし込める2つ目のリストを必要とします。3つとも密なグリッドに置くと読み取りにくく、カードではなくページのために作られています。
01ホバーで軽く傾いて光るブックマークアイコン
アイコンは本物のポインターが乗っている間だけ動きます。applyVector()がボタン中心からのカーソルのずれをひとつの角度に変換し、その角度がtransformによる傾きとconic-gradientの光の位置を同時に決めます。保存状態そのものには触れず、記事一覧カードの保存アイコンをクリック前からカーソルを追いかけているように見せる層です。
<main class="stage">
<div class="rb is-demo" id="rb">
<p class="rb__caption">ホバーでわずかに傾いて光るブックマークアイコン</p>
<div class="rb__zone" id="rbZone">
<div class="rb__glow" id="rbGlow" aria-hidden="true"></div>
<button class="rb__btn" id="rbBtn" type="button" aria-pressed="false">
<svg class="rb__icon" viewBox="0 0 24 24" width="30" height="30" aria-hidden="true" focusable="false">
<path class="rb__shape" 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"/>
</svg>
</button>
</div>
<p class="rb__status" id="rbStatus" aria-live="polite">未保存</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 포인터 벡터→각도. 그 각도가 기울기(rotateX/rotateY)와 광택 sweep 위치를 함께 정한다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
.rb {
display: grid;
gap: $sp-3;
justify-items: center;
}
.rb__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: $subject-ink;
}
.rb__zone {
position: relative;
width: 220px;
height: 150px;
display: grid;
place-items: center;
}
.rb__glow {
position: absolute;
inset: 0;
margin: auto;
width: 200px;
height: 140px;
--glow-deg: 0deg;
--glow-strength: 0;
background: conic-gradient(from var(--glow-deg), transparent 0deg, rgba($subject-blue, .55) 40deg, transparent 90deg);
opacity: var(--glow-strength);
border-radius: $r-card;
pointer-events: none;
}
.rb__btn {
position: relative;
z-index: 1;
border: 0;
background: $stage-paper;
border-radius: $r-pill;
padding: $sp-4;
cursor: pointer;
color: $color;
box-shadow: $shadow-press;
transform-origin: center;
will-change: transform;
}
.rb__btn:focus-visible {
outline: 2px solid $subject-blue;
outline-offset: 3px;
}
.rb__shape {
fill: none;
stroke: $color;
stroke-width: 1.6;
stroke-linejoin: round;
}
.rb.is-saved .rb__shape {
fill: $color;
}
.rb__status {
margin: 0;
font-size: 11px;
color: $subject-ink;
font-weight: 600;
}
@media (max-width: 340px) {
.rb__zone { width: 170px; height: 96px; }
.rb__glow { width: 150px; height: 86px; }
}
@media (prefers-reduced-motion: reduce) {
.rb__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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rb {
display: grid;
gap: 12px;
justify-items: center;
}
.rb__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.rb__zone {
position: relative;
width: 220px;
height: 150px;
display: grid;
place-items: center;
}
.rb__glow {
position: absolute;
inset: 0;
margin: auto;
width: 200px;
height: 140px;
--glow-deg: 0deg;
--glow-strength: 0;
background: conic-gradient(from var(--glow-deg), transparent 0deg, rgba(47, 109, 246, 0.55) 40deg, transparent 90deg);
opacity: var(--glow-strength);
border-radius: 16px;
pointer-events: none;
}
.rb__btn {
position: relative;
z-index: 1;
border: 0;
background: #ffffff;
border-radius: 999px;
padding: 16px;
cursor: pointer;
color: #0a4fe8;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform-origin: center;
will-change: transform;
}
.rb__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 3px;
}
.rb__shape {
fill: none;
stroke: #0a4fe8;
stroke-width: 1.6;
stroke-linejoin: round;
}
.rb.is-saved .rb__shape {
fill: #0a4fe8;
}
.rb__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.rb__zone {
width: 170px;
height: 96px;
}
.rb__glow {
width: 150px;
height: 86px;
}
}
@media (prefers-reduced-motion: reduce) {
.rb__btn {
transition: none;
}
}(function () {
"use strict";
var TILT_MAX_DEG = 14;
var GLOW_RADIUS_PX = 150;
var CYCLE_S = 2;
var root = document.getElementById("rb");
var zone = document.getElementById("rbZone");
var glow = document.getElementById("rbGlow");
var btn = document.getElementById("rbBtn");
var status = document.getElementById("rbStatus");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var saved = false;
function setSaved(v) {
saved = v;
btn.setAttribute("aria-pressed", String(v));
root.classList.toggle("is-saved", v);
status.textContent = v ? "保存済み" : "未保存";
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
// 값 하나: 포인터 벡터(dx,dy) → 각도. 그 각도가 기울기(rotateX/rotateY)와
// 광택 sweep 위치(conic-gradient 각도)를 동시에 결정한다.
function applyVector(dx, dy) {
var dist = Math.hypot(dx, dy);
var strength = clamp(1 - dist / GLOW_RADIUS_PX, 0, 1);
var angleDeg = Math.atan2(dy, dx) * 180 / Math.PI;
var tiltX = clamp(-dy / GLOW_RADIUS_PX, -1, 1) * TILT_MAX_DEG * strength;
var tiltY = clamp(dx / GLOW_RADIUS_PX, -1, 1) * TILT_MAX_DEG * strength;
btn.style.transform = "perspective(600px) rotateX(" + tiltX.toFixed(2) + "deg) rotateY(" + tiltY.toFixed(2) + "deg)";
glow.style.setProperty("--glow-deg", angleDeg.toFixed(1) + "deg");
glow.style.setProperty("--glow-strength", strength.toFixed(3));
}
var hasPointer = false;
zone.addEventListener("pointermove", function (e) {
hasPointer = true;
root.classList.remove("is-demo");
var r = btn.getBoundingClientRect();
var cx = r.left + r.width / 2;
var cy = r.top + r.height / 2;
applyVector(e.clientX - cx, e.clientY - cy);
});
zone.addEventListener("pointerleave", function () {
hasPointer = false;
applyVector(0, 0);
});
btn.addEventListener("focus", function () {
applyVector(0, -GLOW_RADIUS_PX * 0.4);
});
btn.addEventListener("blur", function () {
if (!hasPointer) applyVector(0, 0);
});
btn.addEventListener("click", function () {
setSaved(!saved);
});
// .is-demo 자동 시연: 가상 포인터가 원을 그리며 지나가고, 그 사이 두 번 저장 상태를 바꾼다.
var start = null;
var prevT = 0;
var toggledA = false;
var toggledB = false;
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 && !hasPointer && root.classList.contains("is-demo")) {
var ang = (t / CYCLE_S) * Math.PI * 2;
var x = Math.cos(ang) * 120;
var y = Math.sin(ang) * 70;
applyVector(x, y);
if (!toggledA && t > 0.45) { toggledA = true; setSaved(true); }
if (!toggledB && t > 1.45) { toggledB = true; setSaved(false); }
}
requestAnimationFrame(loop);
}
if (reduce) {
applyVector(0, 0);
} else {
requestAnimationFrame(loop);
}
})();// HoverTiltGlowBookmark — vanilla 판(run/416/demo/01-bookmark-btn-hover-tilt-glow/index.html) 의 React 포트.
// 값 하나: 포인터 벡터(dx,dy) → 각도. 그 각도가 버튼 기울기(rotateX/rotateY)와 광택 sweep 위치(conic-gradient)를 함께 정한다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./HoverTiltGlowBookmark.module.scss";
type Box<T> = { current: T };
const TILT_MAX_DEG = 14;
const GLOW_RADIUS_PX = 150;
const CYCLE_S = 2;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function HoverTiltGlowBookmark() {
const btnRef = useRef<HTMLButtonElement | null>(null);
const glowRef = useRef<HTMLDivElement | null>(null);
const hasPointerRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const [saved, setSaved] = useState(false);
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function applyVector(dx: number, dy: number): void {
const btn = btnRef.current;
const glow = glowRef.current;
if (!btn || !glow) return;
const dist = Math.hypot(dx, dy);
const strength = clamp(1 - dist / GLOW_RADIUS_PX, 0, 1);
const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
const tiltX = clamp(-dy / GLOW_RADIUS_PX, -1, 1) * TILT_MAX_DEG * strength;
const tiltY = clamp(dx / GLOW_RADIUS_PX, -1, 1) * TILT_MAX_DEG * strength;
btn.style.transform = "perspective(600px) rotateX(" + tiltX.toFixed(2) + "deg) rotateY(" + tiltY.toFixed(2) + "deg)";
glow.style.setProperty("--glow-deg", angleDeg.toFixed(1) + "deg");
glow.style.setProperty("--glow-strength", strength.toFixed(3));
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
applyVector(0, 0);
return;
}
let start = 0;
let prevT = 0;
let toggledA = false;
let toggledB = false;
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 (!hasPointerRef.current && demo) {
const ang = (t / CYCLE_S) * Math.PI * 2;
const x = Math.cos(ang) * 120;
const y = Math.sin(ang) * 70;
applyVector(x, y);
if (!toggledA && t > 0.45) { toggledA = true; setSaved(true); }
if (!toggledB && t > 1.45) { toggledB = true; setSaved(false); }
}
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
hasPointerRef.current = true;
setDemo(false);
const btn = btnRef.current;
if (!btn) return;
const r = btn.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
applyVector(event.clientX - cx, event.clientY - cy);
}
function onPointerLeave(): void {
hasPointerRef.current = false;
applyVector(0, 0);
}
function onFocus(): void {
applyVector(0, -GLOW_RADIUS_PX * 0.4);
}
function onBlur(): void {
if (!hasPointerRef.current) applyVector(0, 0);
}
function onClick(): void {
setDemo(false);
setSaved(!saved);
}
return (
<main className={styles.stage}>
<div className={styles.rb}>
<p className={styles.caption}>호버로 살짝 기울며 빛나는 북마크 아이콘</p>
<div className={styles.zone} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<div className={styles.glow} aria-hidden="true" ref={glowRef} />
<button className={styles.btn} ref={btnRef} type="button" aria-pressed={saved} onFocus={onFocus} onBlur={onBlur} onClick={onClick}>
<svg className={styles.icon} viewBox="0 0 24 24" width={30} height={30} aria-hidden="true" focusable="false">
<path className={styles.shape} 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" />
</svg>
</button>
</div>
<p className={styles.status} aria-live="polite">{saved ? "저장됨" : "저장 전"}</p>
</div>
</main>
);
}01 · ホバーで軽く傾いて光るブックマークアイコン — 計測 累積面積 17.316% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 値ひとつ: ポインターベクトル(dx,dy) → 角度。その角度が傾き(rotateX/rotateY)と
// 光沢のsweep位置(conic-gradient角度)を同時に決める。
function applyVector(dx, dy) {
var dist = Math.hypot(dx, dy);
var strength = clamp(1 - dist / GLOW_RADIUS_PX, 0, 1);
var angleDeg = Math.atan2(dy, dx) * 180 / Math.PI;
var tiltX = clamp(-dy / GLOW_RADIUS_PX, -1, 1) * TILT_MAX_DEG * strength;
var tiltY = clamp(dx / GLOW_RADIUS_PX, -1, 1) * TILT_MAX_DEG * strength;
btn.style.transform = "perspective(600px) rotateX(" + tiltX.toFixed(2) + "deg) rotateY(" + tiltY.toFixed(2) + "deg)";
glow.style.setProperty("--glow-deg", angleDeg.toFixed(1) + "deg");
glow.style.setProperty("--glow-strength", strength.toFixed(3));
}
測定値: 累積面積17.3%、平均強度24.3、動いたフレーム21/23。
02輪郭線から塗りつぶしに変わるブックマークボタン
タップ前のリボンは輪郭線だけで描かれています。setSaved()がひとつの真偽値を切り替え、render(p)がその進行率を読んで塗りつぶし用rectの高さを下から育てるので、アイコンが差し替わるのではなく自分自身が塗りで満ちていきます。商品カードのウィッシュリストボタンが欲しい、買い手が目で追える状態変化そのものです。
<main class="stage">
<div class="ob is-demo" id="ob">
<p class="ob__caption">輪郭線から塗りつぶしに変わるブックマークボタン</p>
<div class="ob__scene" id="obScene">
<div class="ob__panel" id="obPanel" aria-hidden="true"></div>
<button class="ob__btn" id="obBtn" type="button" aria-pressed="false">
<svg class="ob__icon" viewBox="0 0 24 24" width="34" height="34" aria-hidden="true" focusable="false">
<defs>
<clipPath id="obClip">
<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="ob__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"/>
<rect class="ob__fill" id="obFill" x="0" y="24" width="24" height="24" clip-path="url(#obClip)"/>
</svg>
</button>
</div>
<p class="ob__status" id="obStatus" aria-live="polite">未保存</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: fill 진행률(0~1) → SVG rect 의 y/height, clip-path 로 리본 모양 안에서만 차오른다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-yellow);
.ob {
display: grid;
gap: $sp-3;
justify-items: center;
}
.ob__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: $subject-ink;
}
.ob__scene {
position: relative;
width: 200px;
height: 140px;
display: grid;
place-items: center;
}
.ob__panel {
position: absolute;
inset: 0;
margin: auto;
width: 200px;
height: 140px;
--fill-p: 0;
border-radius: $r-card;
background: radial-gradient(circle, rgba($subject-blue-deep, calc(var(--fill-p) * .62)) 0%, rgba($subject-blue-deep, calc(var(--fill-p) * .28)) 55%, transparent 90%);
pointer-events: none;
}
.ob__btn {
position: relative;
z-index: 1;
border: 0;
background: $stage-paper;
border-radius: $r-pill;
padding: $sp-4;
cursor: pointer;
box-shadow: $shadow-press;
transform-origin: center;
}
.ob__btn:focus-visible {
outline: 2px solid $subject-blue;
outline-offset: 3px;
}
.ob__outline {
fill: none;
stroke: $color;
stroke-width: 1.6;
stroke-linejoin: round;
}
.ob__fill {
fill: $color;
}
.ob__status {
margin: 0;
font-size: 11px;
color: $subject-ink;
font-weight: 600;
}
@media (max-width: 340px) {
.ob__scene { width: 150px; height: 96px; }
.ob__panel { width: 130px; height: 78px; }
}
@media (prefers-reduced-motion: reduce) {
.ob__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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ob {
display: grid;
gap: 12px;
justify-items: center;
}
.ob__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.ob__scene {
position: relative;
width: 200px;
height: 140px;
display: grid;
place-items: center;
}
.ob__panel {
position: absolute;
inset: 0;
margin: auto;
width: 200px;
height: 140px;
--fill-p: 0;
border-radius: 16px;
background: radial-gradient(circle, rgba(10, 79, 232, calc(var(--fill-p) * 0.62)) 0%, rgba(10, 79, 232, calc(var(--fill-p) * 0.28)) 55%, transparent 90%);
pointer-events: none;
}
.ob__btn {
position: relative;
z-index: 1;
border: 0;
background: #ffffff;
border-radius: 999px;
padding: 16px;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform-origin: center;
}
.ob__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 3px;
}
.ob__outline {
fill: none;
stroke: #0a4fe8;
stroke-width: 1.6;
stroke-linejoin: round;
}
.ob__fill {
fill: #0a4fe8;
}
.ob__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.ob__scene {
width: 150px;
height: 96px;
}
.ob__panel {
width: 130px;
height: 78px;
}
}
@media (prefers-reduced-motion: reduce) {
.ob__btn {
transition: none;
}
}(function () {
"use strict";
var CYCLE_S = 2;
var root = document.getElementById("ob");
var scene = document.getElementById("obScene");
var panel = document.getElementById("obPanel");
var btn = document.getElementById("obBtn");
var fillRect = document.getElementById("obFill");
var status = document.getElementById("obStatus");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var saved = false;
var progress = 0;
var target = 0;
function render(p) {
// 값 하나: fill 진행률(p, 0~1) → SVG rect 의 y·height(=clip-path 로 잘려 채움 비율이 된다)
var y = 24 * (1 - p);
fillRect.setAttribute("y", y.toFixed(3));
fillRect.setAttribute("height", (24 - y).toFixed(3));
panel.style.setProperty("--fill-p", p.toFixed(3));
}
function setSaved(v) {
saved = v;
target = v ? 1 : 0;
btn.setAttribute("aria-pressed", String(v));
status.textContent = v ? "保存済み" : "未保存";
}
btn.addEventListener("click", function () {
root.classList.remove("is-demo");
setSaved(!saved);
});
var start = null;
var prevT = 0;
var toggledA = false;
var toggledB = false;
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.3) { toggledA = true; setSaved(true); }
if (!toggledB && t > 1.3) { toggledB = true; setSaved(false); }
}
if (reduce) {
progress = target;
} else {
// 관성 lerp: 진행률이 목표를 향해 부드럽게 수렴한다 (속성 트윈이 아니라 매 프레임 계산)
progress += (target - progress) * 0.22;
}
render(progress);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// OutlineFillMorphBookmark — vanilla 판(run/416/demo/02-bookmark-btn-outline-fill-morph/index.html) 의 React 포트.
// 값 하나: fill 진행률(0~1, 관성 lerp) → SVG rect 의 y/height(clip-path 로 리본 모양 안에서만 차오른다) + 배경 패널 강도.
import { useEffect, useRef, useState } from "react";
import styles from "./OutlineFillMorphBookmark.module.scss";
type Box<T> = { current: T };
const CYCLE_S = 2;
const LERP_RATE = 0.22;
export default function OutlineFillMorphBookmark() {
const panelRef = useRef<HTMLDivElement | null>(null);
const fillRef = useRef<SVGRectElement | null>(null);
const progressRef = 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);
function render(p: number): void {
const fill = fillRef.current;
const panel = panelRef.current;
if (!fill || !panel) return;
const y = 24 * (1 - p);
fill.setAttribute("y", y.toFixed(3));
fill.setAttribute("height", (24 - y).toFixed(3));
panel.style.setProperty("--fill-p", p.toFixed(3));
}
function toggle(v: boolean): void {
setSaved(v);
targetRef.current = v ? 1 : 0;
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0;
let prevT = 0;
let toggledA = false;
let toggledB = false;
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.3) { toggledA = true; toggle(true); }
if (!toggledB && t > 1.3) { toggledB = true; toggle(false); }
}
if (reduce) {
progressRef.current = targetRef.current;
} else {
progressRef.current += (targetRef.current - progressRef.current) * LERP_RATE;
}
render(progressRef.current);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
function onClick(): void {
setDemo(false);
toggle(!saved);
}
return (
<main className={styles.stage}>
<div className={styles.ob}>
<p className={styles.caption}>윤곽선에서 채움으로 바뀌는 북마크 버튼</p>
<div className={styles.scene}>
<div className={styles.panel} aria-hidden="true" ref={panelRef} />
<button className={styles.btn} type="button" aria-pressed={saved} onClick={onClick}>
<svg className={styles.icon} viewBox="0 0 24 24" width={34} height={34} aria-hidden="true" focusable="false">
<defs>
<clipPath id="obClip">
<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" />
<rect className={styles.fill} ref={fillRef} x={0} y={24} width={24} height={24} clipPath="url(#obClip)" />
</svg>
</button>
</div>
<p className={styles.status} aria-live="polite">{saved ? "저장됨" : "저장 전"}</p>
</div>
</main>
);
}02 · 輪郭から塗りつぶしに変わるブックマークボタン — 計測 累積面積 16.04% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function render(p) {
// 値ひとつ: 塗りつぶしの進行率(p, 0~1) → SVGのrectのy・height(塗りつぶし比率になる)
var y = 24 * (1 - p);
fillRect.setAttribute("y", y.toFixed(3));
fillRect.setAttribute("height", (24 - y).toFixed(3));
panel.style.setProperty("--fill-p", p.toFixed(3));
}
function setSaved(v) {
saved = v;
target = v ? 1 : 0;
btn.setAttribute("aria-pressed", String(v));
status.textContent = v ? "保存済み" : "未保存";
}
測定値: 累積面積16.0%、平均強度37.6、動いたフレーム7/23。
03スプリングで弾んで大きくなるブックマークボタン
タップした瞬間、アイコンは最終サイズへ素直に近づくのではありません。integrate()が毎フレーム本物のバネ方程式を計算するので、スケールは一度1を追い越し、自分の減衰で引き戻されます。この行き過ぎこそ、コメントの「いいね」隣にある保存ボタンを設定トグルではなく反応らしく見せる部分です。
<main class="stage">
<div class="sp is-demo" id="sp">
<p class="sp__caption">スプリングで弾んで大きくなるブックマークボタン</p>
<div class="sp__scene" id="spScene">
<button class="sp__btn" id="spBtn" type="button" aria-pressed="false">
<svg class="sp__icon" viewBox="0 0 24 24" width="30" height="30" aria-hidden="true" focusable="false">
<path class="sp__shape" 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"/>
</svg>
</button>
</div>
<p class="sp__status" id="spStatus" aria-live="polite">未保存</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 스프링 방정식(강성·감쇠) 을 적분한 위치가 버튼 scale 이 된다.
// BOOKMARK_SPRING_OVERSHOOT_PERCENT 가 넘길 수 있는 최대 비율을 정한다.
$duration: $dur-loop;
$easing: $ease-elastic;
$color: $subject-blue-deep;
@include stage($stage-ink);
.sp {
display: grid;
gap: $sp-3;
justify-items: center;
}
.sp__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: $subject-cream;
}
.sp__scene {
position: relative;
width: 200px;
height: 140px;
display: grid;
place-items: center;
--tone: 0;
}
.sp__scene::before {
content: "";
position: absolute;
inset: 0;
margin: auto;
width: 200px;
height: 140px;
border-radius: $r-card;
background: radial-gradient(circle, rgba($subject-blue-light, .68) 0%, rgba($subject-blue-light, .3) 55%, transparent 88%);
opacity: var(--tone);
transform: scale(calc(0.7 + var(--tone) * 0.4));
pointer-events: none;
}
.sp__btn {
position: relative;
z-index: 1;
border: 0;
background: $stage-paper;
border-radius: $r-pill;
padding: $sp-4;
cursor: pointer;
box-shadow: $shadow-raised;
transform-origin: center;
will-change: transform;
}
.sp__btn:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.sp__shape {
fill: none;
stroke: $color;
stroke-width: 1.6;
stroke-linejoin: round;
}
.sp.is-saved .sp__shape {
fill: $color;
}
.sp__status {
margin: 0;
font-size: 11px;
color: $subject-cream;
font-weight: 600;
}
@media (max-width: 340px) {
.sp__scene { width: 150px; height: 96px; }
.sp__scene::before { width: 130px; height: 78px; }
}
@media (prefers-reduced-motion: reduce) {
.sp__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);
}
.sp {
display: grid;
gap: 12px;
justify-items: center;
}
.sp__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #fff7e6;
}
.sp__scene {
position: relative;
width: 200px;
height: 140px;
display: grid;
place-items: center;
--tone: 0;
}
.sp__scene::before {
content: "";
position: absolute;
inset: 0;
margin: auto;
width: 200px;
height: 140px;
border-radius: 16px;
background: radial-gradient(circle, rgba(91, 140, 248, 0.68) 0%, rgba(91, 140, 248, 0.3) 55%, transparent 88%);
opacity: var(--tone);
transform: scale(calc(0.7 + var(--tone) * 0.4));
pointer-events: none;
}
.sp__btn {
position: relative;
z-index: 1;
border: 0;
background: #ffffff;
border-radius: 999px;
padding: 16px;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: center;
will-change: transform;
}
.sp__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.sp__shape {
fill: none;
stroke: #0a4fe8;
stroke-width: 1.6;
stroke-linejoin: round;
}
.sp.is-saved .sp__shape {
fill: #0a4fe8;
}
.sp__status {
margin: 0;
font-size: 11px;
color: #fff7e6;
font-weight: 600;
}
@media (max-width: 340px) {
.sp__scene {
width: 150px;
height: 96px;
}
.sp__scene::before {
width: 130px;
height: 78px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp__btn {
transition: none;
}
}(function () {
"use strict";
// 변형 3: BOOKMARK_SPRING_OVERSHOOT_PERCENT 가 스프링이 목표를 넘길 수 있는 최대치를 정한다.
var BOOKMARK_SPRING_OVERSHOOT_PERCENT = 12;
var SPRING_STIFFNESS = 210;
var SPRING_DAMPING = 18;
var SPRING_KICK_VELOCITY = 7.4;
var CYCLE_S = 2;
var root = document.getElementById("sp");
var scene = document.getElementById("spScene");
var btn = document.getElementById("spBtn");
var status = document.getElementById("spStatus");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var saved = false;
function setSaved(v) {
saved = v;
btn.setAttribute("aria-pressed", String(v));
root.classList.toggle("is-saved", v);
status.textContent = v ? "保存済み" : "未保存";
}
var maxScale = 1 + BOOKMARK_SPRING_OVERSHOOT_PERCENT / 100;
var pos = 1;
var vel = 0;
function render(p) {
var s = clamp01to(p);
btn.style.transform = "scale(" + s.toFixed(4) + ")";
var tone = clamp(s - 1, 0, maxScale - 1) / (maxScale - 1);
scene.style.setProperty("--tone", tone.toFixed(3));
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function clamp01to(v) { return v; }
// 스프링 방정식(강성 SPRING_STIFFNESS · 감쇠 SPRING_DAMPING) 을 매 프레임 적분한다.
// 눌리는 순간 속도를 한 번 차 넣으면(kick), 스프링이 목표(1)를 넘겼다가 되돌아온다.
function integrate(dt) {
var accel = SPRING_STIFFNESS * (1 - pos) - SPRING_DAMPING * vel;
vel += accel * dt;
pos += vel * dt;
if (pos > maxScale) { pos = maxScale; vel *= -0.35; }
if (pos < 1 && vel < 0 && Math.abs(pos - 1) < 0.001 && Math.abs(vel) < 0.01) { pos = 1; vel = 0; }
}
function kick() {
vel += SPRING_KICK_VELOCITY;
}
btn.addEventListener("click", function () {
root.classList.remove("is-demo");
setSaved(!saved);
kick();
});
var lastNow = null;
var start = null;
var prevT = 0;
var toggledA = false;
var toggledB = false;
function loop(now) {
if (start === null) { start = now; lastNow = now; }
var dt = Math.min((now - lastNow) / 1000, 0.05);
lastNow = 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.3) { toggledA = true; setSaved(true); kick(); }
if (!toggledB && t > 1.3) { toggledB = true; setSaved(false); kick(); }
}
if (reduce) {
pos = 1; vel = 0;
} else {
integrate(dt);
}
render(pos);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// SpringPopScaleBookmark — vanilla 판(run/416/demo/03-bookmark-btn-spring-pop-scale/index.html) 의 React 포트.
// 스프링 방정식(강성·감쇠)을 매 프레임 적분한 위치가 버튼 scale 이 된다. 누르는 순간 속도를 한 번 차 넣으면
// 스프링이 목표를 BOOKMARK_SPRING_OVERSHOOT_PERCENT 만큼 넘겼다가 되돌아온다.
import { useEffect, useRef, useState } from "react";
import styles from "./SpringPopScaleBookmark.module.scss";
type Box<T> = { current: T };
const BOOKMARK_SPRING_OVERSHOOT_PERCENT = 12;
const SPRING_STIFFNESS = 210;
const SPRING_DAMPING = 18;
const SPRING_KICK_VELOCITY = 7.4;
const CYCLE_S = 2;
const MAX_SCALE = 1 + BOOKMARK_SPRING_OVERSHOOT_PERCENT / 100;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function SpringPopScaleBookmark() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const posRef = useRef<number>(1) as Box<number>;
const velRef = 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);
function render(p: number): void {
const btn = btnRef.current;
const scene = sceneRef.current;
if (!btn || !scene) return;
btn.style.transform = "scale(" + p.toFixed(4) + ")";
const tone = clamp(p - 1, 0, MAX_SCALE - 1) / (MAX_SCALE - 1);
scene.style.setProperty("--tone", tone.toFixed(3));
}
function integrate(dt: number): void {
const accel = SPRING_STIFFNESS * (1 - posRef.current) - SPRING_DAMPING * velRef.current;
velRef.current += accel * dt;
posRef.current += velRef.current * dt;
if (posRef.current > MAX_SCALE) { posRef.current = MAX_SCALE; velRef.current *= -0.35; }
if (posRef.current < 1 && velRef.current < 0 && Math.abs(posRef.current - 1) < 0.001 && Math.abs(velRef.current) < 0.01) {
posRef.current = 1; velRef.current = 0;
}
}
function kick(): void {
velRef.current += SPRING_KICK_VELOCITY;
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let lastNow = 0;
let start = 0;
let prevT = 0;
let toggledA = false;
let toggledB = false;
function loop(now: number): void {
if (start === 0) { start = now; lastNow = now; }
const dt = Math.min((now - lastNow) / 1000, 0.05);
lastNow = now;
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { toggledA = false; toggledB = false; }
prevT = t;
if (!reduce && demo) {
if (!toggledA && t > 0.3) { toggledA = true; setSaved(true); kick(); }
if (!toggledB && t > 1.3) { toggledB = true; setSaved(false); kick(); }
}
if (reduce) {
posRef.current = 1; velRef.current = 0;
} else {
integrate(dt);
}
render(posRef.current);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
function onClick(): void {
setDemo(false);
setSaved(!saved);
kick();
}
return (
<main className={styles.stage}>
<div className={styles.sp}>
<p className={styles.caption}>스프링으로 튕겨 커지는 북마크 버튼</p>
<div className={styles.scene} ref={sceneRef}>
<button className={styles.btn} ref={btnRef} type="button" aria-pressed={saved} onClick={onClick}>
<svg className={styles.icon} viewBox="0 0 24 24" width={30} height={30} aria-hidden="true" focusable="false">
<path className={styles.shape} 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" />
</svg>
</button>
</div>
<p className={styles.status} aria-live="polite">{saved ? "저장됨" : "저장 전"}</p>
</div>
</main>
);
}03 · スプリングで弾んで大きくなるブックマークボタン — 計測 累積面積 9.194% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// スプリング方程式(剛性SPRING_STIFFNESS・減衰SPRING_DAMPING)を毎フレーム積分する。
// 押した瞬間に速度を一度蹴り込む(kick)と、スプリングが目標値(1)を超えてから戻る。
function integrate(dt) {
var accel = SPRING_STIFFNESS * (1 - pos) - SPRING_DAMPING * vel;
vel += accel * dt;
pos += vel * dt;
if (pos > maxScale) { pos = maxScale; vel *= -0.35; }
if (pos < 1 && vel < 0 && Math.abs(pos - 1) < 0.001 && Math.abs(vel) < 0.01) { pos = 1; vel = 0; }
}
測定値: 累積面積9.2%、平均強度28.0、動いたフレーム12/23。
04リボンが折れて落ちるブックマークボタン
保存するタップと同じ入力が折りたたみも起こします。render(p)がサインカーブに沿ったひとつの進行値を読み、rotateXの折れ角度・小さなtranslateYの落下・そして落ちるほど濃くなる影のぼかしと不透明度に変換します。記事詳細ページ上部の保存ボタンのように、折れと影がきちんと読み取れる余白がある場所向けです。
<main class="stage">
<div class="fd is-demo" id="fd">
<p class="fd__caption">リボンが折れて落ちるブックマークボタン</p>
<div class="fd__scene" id="fdScene">
<div class="fd__shadow" id="fdShadow" aria-hidden="true"></div>
<button class="fd__btn" id="fdBtn" type="button" aria-pressed="false">
<span class="fd__wrap" id="fdWrap">
<svg class="fd__icon" viewBox="0 0 24 24" width="30" height="30" aria-hidden="true" focusable="false">
<path class="fd__shape" 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"/>
</svg>
</span>
</button>
</div>
<p class="fd__status" id="fdStatus" aria-live="polite">未保存</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 접힘 진행률(사인 봉우리) → rotateX 접힘 각 + 낙하 거리 + 그림자 블러/농도.
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-orange-deep;
@include stage($stage-orange);
.fd {
display: grid;
gap: $sp-3;
justify-items: center;
}
.fd__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: $subject-ink;
}
.fd__scene {
position: relative;
width: 200px;
height: 140px;
display: grid;
place-items: center;
--tone: 0;
}
.fd__scene::before {
content: "";
position: absolute;
inset: 0;
margin: auto;
width: 200px;
height: 140px;
border-radius: $r-card;
background: radial-gradient(circle, rgba($subject-orange-deep, calc(.05 + var(--tone) * .72)) 0%, rgba($subject-orange-deep, calc(.02 + var(--tone) * .3)) 55%, transparent 88%);
pointer-events: none;
}
.fd__shadow {
position: absolute;
bottom: 28px;
width: 60px;
height: 14px;
border-radius: $r-pill;
background: transparent;
pointer-events: none;
}
.fd__btn {
position: relative;
z-index: 1;
border: 0;
background: $stage-paper;
border-radius: $r-pill;
padding: $sp-4;
cursor: pointer;
perspective: 300px;
}
.fd__btn:focus-visible {
outline: 2px solid $subject-orange-deep;
outline-offset: 3px;
}
.fd__wrap {
display: block;
transform-origin: top center;
will-change: transform;
}
.fd__shape {
fill: none;
stroke: $color;
stroke-width: 1.6;
stroke-linejoin: round;
}
.fd.is-saved .fd__shape {
fill: $color;
}
.fd__status {
margin: 0;
font-size: 11px;
color: $subject-ink;
font-weight: 600;
}
@media (max-width: 340px) {
.fd__scene { width: 150px; height: 96px; }
.fd__shadow { bottom: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.fd__wrap { transition: none; }
}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);
}
.fd {
display: grid;
gap: 12px;
justify-items: center;
}
.fd__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.fd__scene {
position: relative;
width: 200px;
height: 140px;
display: grid;
place-items: center;
--tone: 0;
}
.fd__scene::before {
content: "";
position: absolute;
inset: 0;
margin: auto;
width: 200px;
height: 140px;
border-radius: 16px;
background: radial-gradient(circle, rgba(201, 48, 0, calc(0.05 + var(--tone) * 0.72)) 0%, rgba(201, 48, 0, calc(0.02 + var(--tone) * 0.3)) 55%, transparent 88%);
pointer-events: none;
}
.fd__shadow {
position: absolute;
bottom: 28px;
width: 60px;
height: 14px;
border-radius: 999px;
background: transparent;
pointer-events: none;
}
.fd__btn {
position: relative;
z-index: 1;
border: 0;
background: #ffffff;
border-radius: 999px;
padding: 16px;
cursor: pointer;
perspective: 300px;
}
.fd__btn:focus-visible {
outline: 2px solid #c93000;
outline-offset: 3px;
}
.fd__wrap {
display: block;
transform-origin: top center;
will-change: transform;
}
.fd__shape {
fill: none;
stroke: #c93000;
stroke-width: 1.6;
stroke-linejoin: round;
}
.fd.is-saved .fd__shape {
fill: #c93000;
}
.fd__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.fd__scene {
width: 150px;
height: 96px;
}
.fd__shadow {
bottom: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.fd__wrap {
transition: none;
}
}(function () {
"use strict";
var FOLD_MAX_DEG = 56;
var FOLD_DROP_MAX_PX = 12;
var FOLD_MS = 560;
var CYCLE_S = 2;
var root = document.getElementById("fd");
var scene = document.getElementById("fdScene");
var shadow = document.getElementById("fdShadow");
var btn = document.getElementById("fdBtn");
var wrap = document.getElementById("fdWrap");
var status = document.getElementById("fdStatus");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var saved = false;
function setSaved(v) {
saved = v;
btn.setAttribute("aria-pressed", String(v));
root.classList.toggle("is-saved", v);
status.textContent = v ? "保存済み" : "未保存";
}
// 값 하나: 접힘 진행률 p(0~1, 사인 곡선 한 번의 봉우리) → 접힘 각도(rotateX) + 낙하 거리
// + 그림자 블러·투명도(높이가 낮아질수록 그림자가 진해진다).
function render(p) {
scene.style.setProperty("--tone", p.toFixed(3));
var deg = p * FOLD_MAX_DEG;
var drop = p * FOLD_DROP_MAX_PX;
wrap.style.transform = "perspective(300px) rotateX(" + deg.toFixed(2) + "deg) translateY(" + drop.toFixed(2) + "px)";
var blur = 3 + p * 10;
var spread = 6 - p * 4;
var alpha = 0.22 + p * 0.28;
shadow.style.boxShadow = "0 " + (2 + p * 6).toFixed(1) + "px " + blur.toFixed(1) + "px " + spread.toFixed(1) + "px rgba(23,20,26," + alpha.toFixed(2) + ")";
shadow.style.transform = "scaleX(" + (1 - p * 0.18).toFixed(3) + ")";
}
var foldStart = null;
var pending = false;
function triggerFold() {
pending = true;
}
btn.addEventListener("click", function () {
root.classList.remove("is-demo");
setSaved(!saved);
triggerFold();
});
var start = null;
var prevT = 0;
var toggledA = false;
var toggledB = false;
function loop(now) {
if (start === null) start = now;
if (pending) { foldStart = now; pending = false; }
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.35) { toggledA = true; setSaved(true); triggerFold(); }
if (!toggledB && t > 1.35) { toggledB = true; setSaved(false); triggerFold(); }
}
var p = 0;
if (!reduce && foldStart !== null) {
var ft = (now - foldStart) / FOLD_MS;
if (ft >= 1) {
foldStart = null;
p = 0;
} else {
p = Math.sin(Math.PI * ft);
}
}
render(p);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// RibbonFoldDropBookmark — vanilla 판(run/416/demo/04-bookmark-btn-ribbon-fold-drop/index.html) 의 React 포트.
// 값 하나: 접힘 진행률(사인 곡선 한 번의 봉우리) → 접힘 각도(rotateX) + 낙하 거리 + 그림자 블러·농도 + 배경 톤.
import { useEffect, useRef, useState } from "react";
import styles from "./RibbonFoldDropBookmark.module.scss";
type Box<T> = { current: T };
const FOLD_MAX_DEG = 56;
const FOLD_DROP_MAX_PX = 12;
const FOLD_MS = 560;
const CYCLE_S = 2;
export default function RibbonFoldDropBookmark() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const shadowRef = useRef<HTMLDivElement | null>(null);
const wrapRef = useRef<HTMLSpanElement | null>(null);
const foldStartRef = useRef<number | null>(null);
const pendingRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const [saved, setSaved] = useState(false);
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(p: number): void {
const scene = sceneRef.current;
const wrap = wrapRef.current;
const shadow = shadowRef.current;
if (!scene || !wrap || !shadow) return;
scene.style.setProperty("--tone", p.toFixed(3));
const deg = p * FOLD_MAX_DEG;
const drop = p * FOLD_DROP_MAX_PX;
wrap.style.transform = "perspective(300px) rotateX(" + deg.toFixed(2) + "deg) translateY(" + drop.toFixed(2) + "px)";
const blur = 3 + p * 10;
const spread = 6 - p * 4;
const alpha = 0.22 + p * 0.28;
shadow.style.boxShadow = "0 " + (2 + p * 6).toFixed(1) + "px " + blur.toFixed(1) + "px " + spread.toFixed(1) + "px rgba(23,20,26," + alpha.toFixed(2) + ")";
shadow.style.transform = "scaleX(" + (1 - p * 0.18).toFixed(3) + ")";
}
function triggerFold(): void {
pendingRef.current = true;
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0;
let prevT = 0;
let toggledA = false;
let toggledB = false;
function loop(now: number): void {
if (start === 0) start = now;
if (pendingRef.current) { foldStartRef.current = now; pendingRef.current = false; }
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { toggledA = false; toggledB = false; }
prevT = t;
if (!reduce && demo) {
if (!toggledA && t > 0.35) { toggledA = true; setSaved(true); triggerFold(); }
if (!toggledB && t > 1.35) { toggledB = true; setSaved(false); triggerFold(); }
}
let p = 0;
if (!reduce && foldStartRef.current !== null) {
const ft = (now - foldStartRef.current) / FOLD_MS;
if (ft >= 1) {
foldStartRef.current = null;
p = 0;
} else {
p = Math.sin(Math.PI * ft);
}
}
render(p);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
function onClick(): void {
setDemo(false);
setSaved(!saved);
triggerFold();
}
return (
<main className={styles.stage}>
<div className={styles.fd}>
<p className={styles.caption}>리본이 접히며 떨어지는 북마크 버튼</p>
<div className={styles.scene} ref={sceneRef}>
<div className={styles.shadow} ref={shadowRef} aria-hidden="true" />
<button className={styles.btn} type="button" aria-pressed={saved} onClick={onClick}>
<span className={styles.wrap} ref={wrapRef}>
<svg className={styles.icon} viewBox="0 0 24 24" width={30} height={30} aria-hidden="true" focusable="false">
<path className={styles.shape} 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" />
</svg>
</span>
</button>
</div>
<p className={styles.status} aria-live="polite">{saved ? "저장됨" : "저장 전"}</p>
</div>
</main>
);
}04 · リボンが折れて落ちるブックマークボタン — 計測 累積面積 10.631% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 値ひとつ: 折りたたみ進行度p(0~1、サインカーブ1山) → 折れ角度(rotateX) + 落下距離
// + 影のぼかし・不透明度(高さが下がるほど影が濃くなる)。
function render(p) {
scene.style.setProperty("--tone", p.toFixed(3));
var deg = p * FOLD_MAX_DEG;
var drop = p * FOLD_DROP_MAX_PX;
wrap.style.transform = "perspective(300px) rotateX(" + deg.toFixed(2) + "deg) translateY(" + drop.toFixed(2) + "px)";
var blur = 3 + p * 10;
var spread = 6 - p * 4;
var alpha = 0.22 + p * 0.28;
shadow.style.boxShadow = "0 " + (2 + p * 6).toFixed(1) + "px " + blur.toFixed(1) + "px " + spread.toFixed(1) + "px rgba(23,20,26," + alpha.toFixed(2) + ")";
shadow.style.transform = "scaleX(" + (1 - p * 0.18).toFixed(3) + ")";
}
測定値: 累積面積10.6%、平均強度64.4、動いたフレーム17/23。
05液体が満ちていくブックマークボタン
waveD()は毎フレーム、水位と時間位相というたった2つの値からSVGパスを一から組み立て直します。塗りは静止した図形ではなく、1秒に60回描き直されるサインカーブそのものです。動画プレーヤーの保存ボタンのように、画面自体が常に描き直されている場所だからこそ許される計算量です。
<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 値ひとつ: 水位(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;
}
測定値: 累積面積10.1%、平均強度68.8、動いたフレーム11/23。
06保存数が一桁ずつ増えるバッジ
保存はアイコンを切り替えるだけでなく、2桁のカウンターバッジも回します。beginRoll()はひとつの間隔値を読んで各桁がいつ回り始めるかを決め、一の位が先に、十の位はその一拍後に動き出します。この段差こそ、記事の保存数を示すカウンターバッジで数字がいきなり切り替わるのではなく「増えた」と読める理由です。
<main class="stage">
<div class="cb is-demo" id="cb">
<p class="cb__caption">保存数</p>
<div class="cb__row" id="cbRow">
<span class="cb__rowTitle">デザインインスピレーション集</span>
<button class="cb__btn" id="cbBtn" type="button" aria-pressed="false">
<svg class="cb__icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
<path class="cb__shape" 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"/>
</svg>
<span class="cb__badge" id="cbBadge" aria-live="polite">
<span class="cb__digit"><span class="cb__digitOld" id="cbOld0">0</span><span class="cb__digitNew" id="cbNew0">0</span></span>
<span class="cb__digit"><span class="cb__digitOld" id="cbOld1">9</span><span class="cb__digitNew" id="cbNew1">9</span></span>
</span>
</button>
</div>
<p class="cb__status" id="cbStatus">未保存</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 자릿수 인덱스 × BOOKMARK_COUNT_STAGGER_MS 지연 → 그 칸이 구르기 시작하는 시각.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
.cb {
display: grid;
gap: $sp-3;
justify-items: center;
width: 100%;
}
.cb__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: $subject-ink;
}
.cb__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-3;
width: 260px;
padding: $sp-4 $sp-5;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-press;
transform: scale(1);
}
.cb__row--pulse {
background: mix($subject-cream, $subject-blue, 88%);
transform: scale(1.03);
}
.cb__rowTitle {
font-size: 13px;
font-weight: 700;
color: $subject-ink;
}
.cb__btn {
position: relative;
display: inline-flex;
align-items: center;
gap: $sp-1;
border: 0;
background: transparent;
cursor: pointer;
color: $color;
}
.cb__btn:focus-visible {
outline: 2px solid $subject-blue;
outline-offset: 3px;
}
.cb__shape {
fill: $color;
stroke: $color;
stroke-width: 1.4;
}
.cb__badge {
display: inline-flex;
overflow: hidden;
border-radius: $r-xs;
background: $subject-ink;
padding: $sp-1 $sp-2;
font-weight: 700;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: $subject-cream;
}
.cb__digit {
position: relative;
display: inline-block;
width: 8px;
height: 14px;
overflow: hidden;
}
.cb__digitOld,
.cb__digitNew {
position: absolute;
inset: 0;
display: block;
will-change: transform;
}
.cb__status {
margin: 0;
font-size: 11px;
color: $subject-ink;
font-weight: 600;
}
@media (max-width: 340px) {
.cb__row { width: 220px; padding: $sp-3 $sp-4; }
}
@media (prefers-reduced-motion: reduce) {
.cb__digitOld, .cb__digitNew { 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);
}
.cb {
display: grid;
gap: 12px;
justify-items: center;
width: 100%;
}
.cb__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.cb__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 260px;
padding: 16px 20px;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: scale(1);
}
.cb__row--pulse {
background: rgb(90.2117647059%, 90.368627451%, 90.9490196078%);
transform: scale(1.03);
}
.cb__rowTitle {
font-size: 13px;
font-weight: 700;
color: #17141a;
}
.cb__btn {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
cursor: pointer;
color: #0a4fe8;
}
.cb__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 3px;
}
.cb__shape {
fill: #0a4fe8;
stroke: #0a4fe8;
stroke-width: 1.4;
}
.cb__badge {
display: inline-flex;
overflow: hidden;
border-radius: 4px;
background: #17141a;
padding: 4px 8px;
font-weight: 700;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: #fff7e6;
}
.cb__digit {
position: relative;
display: inline-block;
width: 8px;
height: 14px;
overflow: hidden;
}
.cb__digitOld,
.cb__digitNew {
position: absolute;
inset: 0;
display: block;
will-change: transform;
}
.cb__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.cb__row {
width: 220px;
padding: 12px 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.cb__digitOld, .cb__digitNew {
transition: none;
}
}(function () {
"use strict";
var BOOKMARK_COUNT_STAGGER_MS = 40;
var DIGIT_ROLL_MS = 260;
var CYCLE_S = 2;
var root = document.getElementById("cb");
var row = document.getElementById("cbRow");
var btn = document.getElementById("cbBtn");
var status = document.getElementById("cbStatus");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var oldEls = [document.getElementById("cbOld0"), document.getElementById("cbOld1")];
var newEls = [document.getElementById("cbNew0"), document.getElementById("cbNew1")];
var count = 9;
var saved = false;
// 값 하나: 자릿수 인덱스(오른쪽부터) × BOOKMARK_COUNT_STAGGER_MS = 그 칸이 구르기 시작하는 지연 시간.
var columns = [
{ delay: 1 * BOOKMARK_COUNT_STAGGER_MS, start: null, from: "0", to: "0" },
{ delay: 0 * BOOKMARK_COUNT_STAGGER_MS, start: null, from: "9", to: "9" }
];
function pad2(n) { return (n < 10 ? "0" : "") + n; }
function beginRoll(now) {
var next = saved ? count + 1 : count - 1;
var fromStr = pad2(count);
var toStr = pad2(next);
count = next;
for (var i = 0; i < 2; i++) {
columns[i].from = fromStr[i];
columns[i].to = toStr[i];
columns[i].start = now + columns[i].delay;
}
status.textContent = saved ? "保存済み" : "未保存";
row.classList.add("cb__row--pulse");
window.setTimeout(function () { row.classList.remove("cb__row--pulse"); }, 320);
}
function renderColumns(now) {
for (var i = 0; i < 2; i++) {
var col = columns[i];
oldEls[i].textContent = col.from;
newEls[i].textContent = col.to;
var p = 0;
if (col.start !== null) {
var raw = (now - col.start) / DIGIT_ROLL_MS;
p = Math.max(0, Math.min(1, raw));
if (raw >= 1) { col.start = null; }
}
var eased = 1 - Math.pow(1 - p, 2);
oldEls[i].style.transform = "translateY(" + (-eased * 100).toFixed(1) + "%)";
newEls[i].style.transform = "translateY(" + ((1 - eased) * 100).toFixed(1) + "%)";
}
}
var pendingRoll = false;
btn.addEventListener("click", function () {
root.classList.remove("is-demo");
saved = !saved;
btn.setAttribute("aria-pressed", String(saved));
pendingRoll = true;
});
var start = null;
var prevT = 0;
var toggledA = false;
var toggledB = false;
function loop(now) {
if (start === null) start = now;
if (pendingRoll) { beginRoll(now); pendingRoll = false; }
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.3) {
toggledA = true; saved = true; btn.setAttribute("aria-pressed", "true"); beginRoll(now);
}
if (!toggledB && t > 1.3) {
toggledB = true; saved = false; btn.setAttribute("aria-pressed", "false"); beginRoll(now);
}
}
if (reduce) {
for (var i = 0; i < 2; i++) { columns[i].start = null; }
}
renderColumns(now);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// StackCountBadgeBookmark — vanilla 판(run/416/demo/06-bookmark-btn-stack-count-badge/index.html) 의 React 포트.
// 값 하나: 자릿수 인덱스(오른쪽부터) × BOOKMARK_COUNT_STAGGER_MS = 그 칸이 구르기 시작하는 지연 시간.
import { useEffect, useRef, useState } from "react";
import styles from "./StackCountBadgeBookmark.module.scss";
type Box<T> = { current: T };
type Column = { delay: number; start: number | null; from: string; to: string };
const BOOKMARK_COUNT_STAGGER_MS = 40;
const DIGIT_ROLL_MS = 260;
const CYCLE_S = 2;
function pad2(n: number): string {
return (n < 10 ? "0" : "") + n;
}
export default function StackCountBadgeBookmark() {
const rowRef = useRef<HTMLDivElement | null>(null);
const oldRefs = useRef<Array<HTMLSpanElement | null>>([null, null]) as Box<Array<HTMLSpanElement | null>>;
const newRefs = useRef<Array<HTMLSpanElement | null>>([null, null]) as Box<Array<HTMLSpanElement | null>>;
const countRef = useRef<number>(9) as Box<number>;
const savedRef = useRef<boolean>(false) as Box<boolean>;
const pendingRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const columnsRef = useRef<Column[]>([
{ delay: 1 * BOOKMARK_COUNT_STAGGER_MS, start: null, from: "0", to: "0" },
{ delay: 0 * BOOKMARK_COUNT_STAGGER_MS, start: null, from: "9", to: "9" },
]) as Box<Column[]>;
const [status, setStatus] = useState("저장 전");
const [pressed, setPressed] = useState(false);
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function beginRoll(now: number): void {
const next = savedRef.current ? countRef.current + 1 : countRef.current - 1;
const fromStr = pad2(countRef.current);
const toStr = pad2(next);
countRef.current = next;
const cols = columnsRef.current;
for (let i = 0; i < 2; i++) {
cols[i].from = fromStr[i];
cols[i].to = toStr[i];
cols[i].start = now + cols[i].delay;
}
setStatus(savedRef.current ? "저장됨" : "저장 전");
const row = rowRef.current;
if (row) {
row.classList.add(styles.rowPulse);
window.setTimeout(() => { row.classList.remove(styles.rowPulse); }, 320);
}
}
function renderColumns(now: number): void {
const cols = columnsRef.current;
for (let i = 0; i < 2; i++) {
const col = cols[i];
const oldEl = oldRefs.current[i];
const newEl = newRefs.current[i];
if (!oldEl || !newEl) continue;
oldEl.textContent = col.from;
newEl.textContent = col.to;
let p = 0;
if (col.start !== null) {
const raw = (now - col.start) / DIGIT_ROLL_MS;
p = Math.max(0, Math.min(1, raw));
if (raw >= 1) { col.start = null; }
}
const eased = 1 - Math.pow(1 - p, 2);
oldEl.style.transform = "translateY(" + (-eased * 100).toFixed(1) + "%)";
newEl.style.transform = "translateY(" + ((1 - eased) * 100).toFixed(1) + "%)";
}
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0;
let prevT = 0;
let toggledA = false;
let toggledB = false;
function loop(now: number): void {
if (start === 0) start = now;
if (pendingRef.current) { beginRoll(now); pendingRef.current = false; }
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { toggledA = false; toggledB = false; }
prevT = t;
if (!reduce && demo) {
if (!toggledA && t > 0.3) {
toggledA = true; savedRef.current = true; setPressed(true); beginRoll(now);
}
if (!toggledB && t > 1.3) {
toggledB = true; savedRef.current = false; setPressed(false); beginRoll(now);
}
}
if (reduce) {
for (let i = 0; i < 2; i++) { columnsRef.current[i].start = null; }
}
renderColumns(now);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
function onClick(): void {
setDemo(false);
const v = !savedRef.current;
savedRef.current = v;
setPressed(v);
pendingRef.current = true;
}
return (
<main className={styles.stage}>
<div className={styles.cb}>
<p className={styles.caption}>저장 수</p>
<div className={styles.row} ref={rowRef}>
<span className={styles.rowTitle}>디자인 영감 모음</span>
<button className={styles.btn} type="button" aria-pressed={pressed} onClick={onClick}>
<svg className={styles.icon} viewBox="0 0 24 24" width={20} height={20} aria-hidden="true" focusable="false">
<path className={styles.shape} 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" />
</svg>
<span className={styles.badge} aria-live="polite">
<span className={styles.digit}>
<span className={styles.digitOld} ref={(el: HTMLSpanElement | null) => { oldRefs.current[0] = el; }}>0</span>
<span className={styles.digitNew} ref={(el: HTMLSpanElement | null) => { newRefs.current[0] = el; }}>0</span>
</span>
<span className={styles.digit}>
<span className={styles.digitOld} ref={(el: HTMLSpanElement | null) => { oldRefs.current[1] = el; }}>9</span>
<span className={styles.digitNew} ref={(el: HTMLSpanElement | null) => { newRefs.current[1] = el; }}>9</span>
</span>
</span>
</button>
</div>
<p className={styles.status}>{status}</p>
</div>
</main>
);
}06 · 保存数が一桁ずつ増えるバッジ — 計測 累積面積 12.285% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 値ひとつ: 桁インデックス(右から) × BOOKMARK_COUNT_STAGGER_MS = その桁が回り始めるまでの遅延時間。
var columns = [
{ delay: 1 * BOOKMARK_COUNT_STAGGER_MS, start: null, from: "0", to: "0" },
{ delay: 0 * BOOKMARK_COUNT_STAGGER_MS, start: null, from: "9", to: "9" }
];
function pad2(n) { return (n < 10 ? "0" : "") + n; }
function beginRoll(now) {
var next = saved ? count + 1 : count - 1;
var fromStr = pad2(count);
var toStr = pad2(next);
count = next;
for (var i = 0; i < 2; i++) {
columns[i].from = fromStr[i];
columns[i].to = toStr[i];
columns[i].start = now + columns[i].delay;
}
status.textContent = saved ? "保存済み" : "未保存";
row.classList.add("cb__row--pulse");
window.setTimeout(function () { row.classList.remove("cb__row--pulse"); }, 320);
}
測定値: 累積面積12.3%、平均強度69.5、動いたフレーム12/23。
07保存を知らせるトーストのスライド
ここではアイコン自体はほとんど動きません。render(now)はshowToast()が呼ばれてから経過したひとつの時間値を読み、それを進入・保持・退出という3区間のどこにいるか、その区間内でどこまで進んだかに変換します。モバイルアプリ画面の保存完了通知のように、アイコンの状態変化が小さいぶん、実際に保存を伝えるのはトーストの役目になる場面向けです。
<main class="stage">
<div class="ts is-demo" id="ts">
<p class="ts__caption">保存完了の通知</p>
<div class="ts__scene" id="tsScene">
<button class="ts__btn" id="tsBtn" type="button" aria-pressed="false">
<svg class="ts__icon" viewBox="0 0 24 24" width="30" height="30" aria-hidden="true" focusable="false">
<path class="ts__shape" 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"/>
</svg>
</button>
<div class="ts__toast" id="tsToast" role="status" aria-live="polite">
<span id="tsToastText">保存しました</span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 토스트가 뜬 뒤 경과 시간(rAF dt 누적) → 진입/유지/퇴장 구간과 그 진행률(위치·투명도).
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-ink);
.ts {
display: grid;
gap: $sp-3;
justify-items: center;
}
.ts__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: $subject-cream;
}
.ts__scene {
position: relative;
width: 260px;
height: 150px;
display: grid;
place-items: center;
overflow: hidden;
}
.ts__btn {
border: 0;
background: $stage-paper;
border-radius: $r-pill;
padding: $sp-4;
cursor: pointer;
color: $color;
}
.ts__btn:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.ts__shape {
fill: none;
stroke: $color;
stroke-width: 1.6;
stroke-linejoin: round;
}
.ts__btn[aria-pressed="true"] .ts__shape {
fill: $color;
}
.ts__toast {
position: absolute;
bottom: $sp-6;
left: $sp-5;
right: $sp-5;
padding: $sp-3 $sp-5;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 13px;
text-align: center;
box-shadow: $shadow-raised;
will-change: transform, opacity;
}
@media (max-width: 340px) {
.ts__scene { width: 220px; height: 130px; }
}
@media (prefers-reduced-motion: reduce) {
.ts__toast { 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);
}
.ts {
display: grid;
gap: 12px;
justify-items: center;
}
.ts__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #fff7e6;
}
.ts__scene {
position: relative;
width: 260px;
height: 150px;
display: grid;
place-items: center;
overflow: hidden;
}
.ts__btn {
border: 0;
background: #ffffff;
border-radius: 999px;
padding: 16px;
cursor: pointer;
color: #0a4fe8;
}
.ts__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.ts__shape {
fill: none;
stroke: #0a4fe8;
stroke-width: 1.6;
stroke-linejoin: round;
}
.ts__btn[aria-pressed=true] .ts__shape {
fill: #0a4fe8;
}
.ts__toast {
position: absolute;
bottom: 24px;
left: 20px;
right: 20px;
padding: 12px 20px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 13px;
text-align: center;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
will-change: transform, opacity;
}
@media (max-width: 340px) {
.ts__scene {
width: 220px;
height: 130px;
}
}
@media (prefers-reduced-motion: reduce) {
.ts__toast {
transition: none;
}
}(function () {
"use strict";
var TOAST_ENTER_MS = 180;
var TOAST_HOLD_MS = 520;
var TOAST_EXIT_MS = 180;
var CYCLE_S = 2;
var root = document.getElementById("ts");
var btn = document.getElementById("tsBtn");
var toast = document.getElementById("tsToast");
var toastText = document.getElementById("tsToastText");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var saved = false;
var toastStart = null;
var pendingMsg = null;
function showToast(now, msg) {
toastText.textContent = msg;
toastStart = now;
}
// 값 하나: 토스트가 뜬 뒤 경과 시간(rAF dt 누적) → 진입/유지/퇴장 세 구간 중 어디인지, 그 구간의 진행률.
function render(now) {
if (toastStart === null) {
toast.style.transform = "translateY(120%)";
toast.style.opacity = "0";
return;
}
var elapsed = now - toastStart;
var enterMs = reduce ? 1 : TOAST_ENTER_MS;
var exitMs = reduce ? 1 : TOAST_EXIT_MS;
var enterEnd = enterMs;
var holdEnd = enterEnd + TOAST_HOLD_MS;
var exitEnd = holdEnd + exitMs;
var y, op;
if (elapsed < enterEnd) {
var p = elapsed / enterEnd;
var eased = 1 - Math.pow(1 - p, 3);
y = 120 - eased * 120;
op = eased;
} else if (elapsed < holdEnd) {
y = 0; op = 1;
} else if (elapsed < exitEnd) {
var q = (elapsed - holdEnd) / exitMs;
var easedQ = Math.pow(q, 2);
y = easedQ * 120;
op = 1 - easedQ;
} else {
y = 120; op = 0;
toastStart = null;
}
toast.style.transform = "translateY(" + y.toFixed(2) + "%)";
toast.style.opacity = op.toFixed(3);
}
btn.addEventListener("click", function () {
root.classList.remove("is-demo");
saved = !saved;
btn.setAttribute("aria-pressed", String(saved));
pendingMsg = saved ? "保存しました" : "保存を取り消しました";
});
var start = null;
var prevT = 0;
var toggledA = false;
var toggledB = false;
function loop(now) {
if (start === null) start = now;
if (pendingMsg) { showToast(now, pendingMsg); pendingMsg = null; }
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.05) {
toggledA = true; saved = true; btn.setAttribute("aria-pressed", "true");
showToast(now, "保存しました");
}
if (!toggledB && t > 1.05) {
toggledB = true; saved = false; btn.setAttribute("aria-pressed", "false");
showToast(now, "保存を取り消しました");
}
}
render(now);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// ToastConfirmSlideBookmark — vanilla 판(run/416/demo/07-bookmark-btn-toast-confirm-slide/index.html) 의 React 포트.
// 값 하나: 토스트가 뜬 뒤 경과 시간(rAF now 기준) → 진입/유지/퇴장 세 구간 중 어디인지, 그 구간의 진행률.
import { useEffect, useRef, useState } from "react";
import styles from "./ToastConfirmSlideBookmark.module.scss";
type Box<T> = { current: T };
const TOAST_ENTER_MS = 180;
const TOAST_HOLD_MS = 520;
const TOAST_EXIT_MS = 180;
const CYCLE_S = 2;
export default function ToastConfirmSlideBookmark() {
const toastRef = useRef<HTMLDivElement | null>(null);
const toastTextRef = useRef<HTMLSpanElement | null>(null);
const toastStartRef = useRef<number | null>(null);
const pendingMsgRef = useRef<string | null>(null);
const frameRef = useRef<number>(0) as Box<number>;
const [pressed, setPressed] = useState(false);
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function showToast(now: number, msg: string): void {
if (toastTextRef.current) toastTextRef.current.textContent = msg;
toastStartRef.current = now;
}
function render(now: number): void {
const toast = toastRef.current;
if (!toast) return;
if (toastStartRef.current === null) {
toast.style.transform = "translateY(120%)";
toast.style.opacity = "0";
return;
}
const elapsed = now - toastStartRef.current;
const enterMs = reduce ? 1 : TOAST_ENTER_MS;
const exitMs = reduce ? 1 : TOAST_EXIT_MS;
const enterEnd = enterMs;
const holdEnd = enterEnd + TOAST_HOLD_MS;
const exitEnd = holdEnd + exitMs;
let y: number;
let op: number;
if (elapsed < enterEnd) {
const p = elapsed / enterEnd;
const eased = 1 - Math.pow(1 - p, 3);
y = 120 - eased * 120;
op = eased;
} else if (elapsed < holdEnd) {
y = 0; op = 1;
} else if (elapsed < exitEnd) {
const q = (elapsed - holdEnd) / exitMs;
const easedQ = Math.pow(q, 2);
y = easedQ * 120;
op = 1 - easedQ;
} else {
y = 120; op = 0;
toastStartRef.current = null;
}
toast.style.transform = "translateY(" + y.toFixed(2) + "%)";
toast.style.opacity = op.toFixed(3);
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0;
let prevT = 0;
let toggledA = false;
let toggledB = false;
function loop(now: number): void {
if (start === 0) start = now;
if (pendingMsgRef.current) { showToast(now, pendingMsgRef.current); pendingMsgRef.current = null; }
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { toggledA = false; toggledB = false; }
prevT = t;
if (!reduce && demo) {
if (!toggledA && t > 0.05) {
toggledA = true; setPressed(true);
showToast(now, "저장했어요");
}
if (!toggledB && t > 1.05) {
toggledB = true; setPressed(false);
showToast(now, "저장을 취소했어요");
}
}
render(now);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
function onClick(): void {
setDemo(false);
const v = !pressed;
setPressed(v);
pendingMsgRef.current = v ? "저장했어요" : "저장을 취소했어요";
}
return (
<main className={styles.stage}>
<div className={styles.ts}>
<p className={styles.caption}>저장 완료 알림</p>
<div className={styles.scene}>
<button className={styles.btn} type="button" aria-pressed={pressed} onClick={onClick}>
<svg className={styles.icon} viewBox="0 0 24 24" width={30} height={30} aria-hidden="true" focusable="false">
<path className={styles.shape} 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" />
</svg>
</button>
<div className={styles.toast} ref={toastRef} role="status" aria-live="polite">
<span ref={toastTextRef}>저장했어요</span>
</div>
</div>
</div>
</main>
);
}07 · 保存を知らせるトーストのスライド — 計測 累積面積 10.493% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function showToast(now, msg) {
toastText.textContent = msg;
toastStart = now;
}
// 値ひとつ: トーストが出てからの経過時間(rAF dtの累積) → enter/hold/exitのどの区間か、
// その区間内の進行度。
function render(now) {
if (toastStart === null) {
toast.style.transform = "translateY(120%)";
toast.style.opacity = "0";
return;
}
var elapsed = now - toastStart;
var enterMs = reduce ? 1 : TOAST_ENTER_MS;
var exitMs = reduce ? 1 : TOAST_EXIT_MS;
var enterEnd = enterMs;
var holdEnd = enterEnd + TOAST_HOLD_MS;
var exitEnd = holdEnd + exitMs;
測定値: 累積面積10.5%、平均強度109.0、動いたフレーム11/23。
08保存先コレクションを選ぶポップオーバー
引き金はタップではなく長押しです。保持時間がLONGPRESS_MSを超えると、renderPanel()が項目インデックスひとつと間隔値の掛け算を読み、コレクション名の一覧を一つずつ順に開いていき、選ぶとパネルは折りたたまれて閉じます。レシピサイトのフォルダ別保存ボタンのように、「保存した」だけでは足りず、どのリストに入れたかまで伝える必要がある場面向けです。
<main class="stage">
<div class="cp is-demo" id="cp">
<p class="cp__caption">フォルダに保存</p>
<div class="cp__scene" id="cpScene">
<button class="cp__btn" id="cpBtn" type="button" aria-pressed="false" aria-expanded="false" aria-haspopup="true">
<svg class="cp__icon" viewBox="0 0 24 24" width="28" height="28" aria-hidden="true" focusable="false">
<path class="cp__shape" 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"/>
</svg>
</button>
<div class="cp__panel" id="cpPanel" role="menu">
<p class="cp__panelTitle">保存するリストを選ぶ</p>
<button class="cp__item" type="button" role="menuitem" data-name="リーディングリスト">リーディングリスト</button>
<button class="cp__item" type="button" role="menuitem" data-name="あとで読む">あとで読む</button>
<button class="cp__item" type="button" role="menuitem" data-name="料理レシピ">料理レシピ</button>
</div>
</div>
<p class="cp__status" id="cpStatus" aria-live="polite">未保存</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 팝오버가 열린 뒤 경과 시간 → 항목 인덱스 × POPOVER_STAGGER_MS 지연을 지나면 그 항목이 뜬다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-orange);
.cp {
display: grid;
gap: $sp-3;
justify-items: center;
width: 100%;
}
.cp__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: $subject-ink;
}
.cp__scene {
position: relative;
width: 260px;
height: 190px;
display: flex;
justify-content: center;
}
.cp__btn {
position: relative;
z-index: 2;
border: 0;
background: $stage-paper;
border-radius: $r-pill;
padding: $sp-4;
height: max-content;
cursor: pointer;
color: $color;
box-shadow: $shadow-raised;
}
.cp__btn:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.cp__shape {
fill: none;
stroke: $color;
stroke-width: 1.6;
stroke-linejoin: round;
}
.cp__btn[aria-pressed="true"] .cp__shape {
fill: $color;
}
.cp__panel {
position: absolute;
top: 64px;
left: 50%;
transform: translateX(-50%);
width: 200px;
display: grid;
gap: $sp-2;
padding: $sp-4;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
opacity: 0;
pointer-events: none;
transition: opacity $duration $easing;
}
.cp__panel--open {
opacity: 1;
pointer-events: auto;
}
.cp__panelTitle {
margin: 0 0 $sp-1;
font-size: 11px;
font-weight: 700;
color: $subject-blue-deep;
}
.cp__item {
border: 0;
border-radius: $r-sm;
background: $subject-sky;
padding: $sp-2 $sp-3;
font-size: 12px;
font-weight: 600;
color: $subject-ink;
text-align: left;
cursor: pointer;
will-change: transform, opacity;
}
.cp__item:hover,
.cp__item:focus-visible {
background: $subject-blue-light;
outline: none;
}
.cp__status {
margin: 0;
font-size: 11px;
color: $subject-ink;
font-weight: 600;
}
@media (max-width: 340px) {
.cp__scene { width: 200px; height: 100px; }
.cp__btn { padding: $sp-3; }
.cp__panel {
top: 42px;
width: 180px;
padding: $sp-2;
display: flex;
flex-wrap: wrap;
gap: $sp-1;
justify-content: center;
}
.cp__panelTitle { display: none; }
.cp__item { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.cp__panel,
.cp__item {
transition: none;
}
}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);
}
.cp {
display: grid;
gap: 12px;
justify-items: center;
width: 100%;
}
.cp__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.cp__scene {
position: relative;
width: 260px;
height: 190px;
display: flex;
justify-content: center;
}
.cp__btn {
position: relative;
z-index: 2;
border: 0;
background: #ffffff;
border-radius: 999px;
padding: 16px;
height: max-content;
cursor: pointer;
color: #0a4fe8;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.cp__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.cp__shape {
fill: none;
stroke: #0a4fe8;
stroke-width: 1.6;
stroke-linejoin: round;
}
.cp__btn[aria-pressed=true] .cp__shape {
fill: #0a4fe8;
}
.cp__panel {
position: absolute;
top: 64px;
left: 50%;
transform: translateX(-50%);
width: 200px;
display: grid;
gap: 8px;
padding: 16px;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
pointer-events: none;
transition: opacity 300ms ease-out;
}
.cp__panel--open {
opacity: 1;
pointer-events: auto;
}
.cp__panelTitle {
margin: 0 0 4px;
font-size: 11px;
font-weight: 700;
color: #0a4fe8;
}
.cp__item {
border: 0;
border-radius: 8px;
background: #6ec8ff;
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
color: #17141a;
text-align: left;
cursor: pointer;
will-change: transform, opacity;
}
.cp__item:hover,
.cp__item:focus-visible {
background: #5b8cf8;
outline: none;
}
.cp__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.cp__scene {
width: 200px;
height: 100px;
}
.cp__btn {
padding: 12px;
}
.cp__panel {
top: 42px;
width: 180px;
padding: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
justify-content: center;
}
.cp__panelTitle {
display: none;
}
.cp__item {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cp__panel,
.cp__item {
transition: none;
}
}(function () {
"use strict";
var LONGPRESS_MS = 420;
var POPOVER_STAGGER_MS = 45;
var POPOVER_ITEM_MS = 210;
var CYCLE_S = 2;
var root = document.getElementById("cp");
var btn = document.getElementById("cpBtn");
var panel = document.getElementById("cpPanel");
var items = Array.prototype.slice.call(panel.querySelectorAll(".cp__item"));
var status = document.getElementById("cpStatus");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var open = false;
var pressStart = null;
var pressing = false;
function setOpen(v, now) {
open = v;
btn.setAttribute("aria-expanded", String(v));
panel.classList.toggle("cp__panel--open", v);
if (v) { openStart = now; }
else { openStart = null; }
}
var openStart = null;
// 값 하나: 목록 항목 인덱스 × POPOVER_STAGGER_MS = 그 항목이 열리기 시작하는 지연.
function renderPanel(now) {
if (openStart === null && !open) {
items.forEach(function (el) { el.style.opacity = "0"; el.style.transform = "translateY(6px)"; });
return;
}
var elapsed = openStart === null ? POPOVER_ITEM_MS * 3 : now - openStart;
items.forEach(function (el, i) {
var delay = i * POPOVER_STAGGER_MS;
var raw = (elapsed - delay) / POPOVER_ITEM_MS;
var p = Math.max(0, Math.min(1, reduce ? 1 : raw));
var eased = 1 - Math.pow(1 - p, 3);
el.style.opacity = eased.toFixed(3);
el.style.transform = "translateY(" + ((1 - eased) * 6).toFixed(2) + "px)";
});
}
function selectCollection(name) {
status.textContent = "保存済み";
btn.setAttribute("aria-pressed", "true");
root.classList.add("cp__scene--picked");
setOpen(false);
}
function focusItem(i) {
var idx = ((i % items.length) + items.length) % items.length;
items[idx].focus({ preventScroll: true });
}
items.forEach(function (el, i) {
el.addEventListener("click", function () {
root.classList.remove("is-demo");
selectCollection(el.getAttribute("data-name"));
btn.focus({ preventScroll: true });
});
el.addEventListener("keydown", function (e) {
if (e.key === "ArrowDown") { e.preventDefault(); focusItem(i + 1); }
else if (e.key === "ArrowUp") { e.preventDefault(); focusItem(i - 1); }
else if (e.key === "Escape") {
e.preventDefault();
root.classList.remove("is-demo");
setOpen(false);
btn.focus({ preventScroll: true });
}
});
});
function shortSave() {
var v = btn.getAttribute("aria-pressed") !== "true";
btn.setAttribute("aria-pressed", String(v));
status.textContent = v ? "保存済み" : "未保存";
}
btn.addEventListener("pointerdown", function () {
root.classList.remove("is-demo");
pressing = true;
pressStart = null;
});
btn.addEventListener("pointerup", function () {
// longpress 임계(LONGPRESS_MS)를 못 넘기고 뗀 경우에만 짧은 저장 토글로 처리한다
if (pressing && !open) { shortSave(); }
pressing = false;
pressStart = null;
});
btn.addEventListener("pointerleave", function () { pressing = false; pressStart = null; });
btn.addEventListener("keydown", function (e) {
// 롱프레스(포인터 전용) 대안: Shift+Enter 또는 ArrowDown 으로 팝오버를 열고 첫 항목으로 이동
if (e.key === "ArrowDown" || (e.key === "Enter" && e.shiftKey)) {
e.preventDefault();
root.classList.remove("is-demo");
if (!open) { setOpen(true, performance.now()); }
focusItem(0);
} else if (e.key === "Escape" && open) {
e.preventDefault();
root.classList.remove("is-demo");
setOpen(false);
}
});
btn.addEventListener("click", function (e) {
// pointerdown/up longpress 경로와 중복되지 않도록: longpress 로 이미 열렸으면 click 무시
if (open) { e.preventDefault(); return; }
// 키보드(Enter/Space) 로 발생한 click 은 pointerup 경로를 거치지 않으므로 여기서 저장 토글
if (e.detail === 0) { root.classList.remove("is-demo"); shortSave(); }
});
var start = null;
var prevT = 0;
var cycleOpened = false;
var cyclePicked = false;
function loop(now) {
if (start === null) start = now;
if (pressing && pressStart === null) { pressStart = now; }
if (pressing && pressStart !== null && !open && (now - pressStart) > LONGPRESS_MS) {
setOpen(true, now);
}
var t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { cycleOpened = false; cyclePicked = false; }
prevT = t;
if (!reduce && root.classList.contains("is-demo")) {
if (!cycleOpened && t > 0.15) { cycleOpened = true; setOpen(true, now); }
if (!cyclePicked && t > 1.0) { cyclePicked = true; selectCollection("リーディングリスト"); }
}
renderPanel(now);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// CollectionPickerPopoverBookmark — vanilla 판(run/416/demo/08-bookmark-btn-collection-picker-popover/index.html) 의 React 포트.
// 값 하나: 팝오버가 열린 뒤 경과 시간 → 항목 인덱스 × POPOVER_STAGGER_MS 지연을 지나면 그 항목이 뜬다.
import { useEffect, useRef, useState } from "react";
import styles from "./CollectionPickerPopoverBookmark.module.scss";
type Box<T> = { current: T };
const LONGPRESS_MS = 420;
const POPOVER_STAGGER_MS = 45;
const POPOVER_ITEM_MS = 210;
const CYCLE_S = 2;
const NAMES = ["읽기 목록", "나중에 볼 글", "요리 레시피"];
export default function CollectionPickerPopoverBookmark() {
const btnRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([null, null, null]) as Box<Array<HTMLButtonElement | null>>;
const openStartRef = useRef<number | null>(null);
const pressStartRef = useRef<number | null>(null);
const pressingRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const [pressed, setPressed] = useState(false);
const [status, setStatus] = useState("저장 전");
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function setOpen(v: boolean, now: number): void {
if (btnRef.current) btnRef.current.setAttribute("aria-expanded", String(v));
if (panelRef.current) panelRef.current.classList.toggle(styles.panelOpen, v);
openStartRef.current = v ? now : null;
}
function renderPanel(now: number): void {
const items = itemRefs.current;
if (openStartRef.current === null) {
items.forEach((el) => {
if (!el) return;
el.style.opacity = "0";
el.style.transform = "translateY(6px)";
});
return;
}
const elapsed = now - openStartRef.current;
items.forEach((el, i) => {
if (!el) return;
const delay = i * POPOVER_STAGGER_MS;
const raw = (elapsed - delay) / POPOVER_ITEM_MS;
const p = Math.max(0, Math.min(1, reduce ? 1 : raw));
const eased = 1 - Math.pow(1 - p, 3);
el.style.opacity = eased.toFixed(3);
el.style.transform = "translateY(" + ((1 - eased) * 6).toFixed(2) + "px)";
});
}
function selectCollection(now: number): void {
setStatus("저장됨");
setPressed(true);
setOpen(false, now);
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0;
let prevT = 0;
let cycleOpened = false;
let cyclePicked = false;
function loop(now: number): void {
if (start === 0) start = now;
if (pressingRef.current && pressStartRef.current === null) { pressStartRef.current = now; }
if (pressingRef.current && pressStartRef.current !== null && openStartRef.current === null && (now - pressStartRef.current) > LONGPRESS_MS) {
setOpen(true, now);
}
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { cycleOpened = false; cyclePicked = false; }
prevT = t;
if (!reduce && demo) {
if (!cycleOpened && t > 0.15) { cycleOpened = true; setOpen(true, now); }
if (!cyclePicked && t > 1.0) { cyclePicked = true; selectCollection(now); }
}
renderPanel(now);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
function onItemClick(now: number): void {
setDemo(false);
selectCollection(now);
btnRef.current?.focus({ preventScroll: true });
}
function focusItem(i: number): void {
const items = itemRefs.current;
const idx = ((i % items.length) + items.length) % items.length;
items[idx]?.focus({ preventScroll: true });
}
function shortSave(): void {
const v = !pressed;
setPressed(v);
setStatus(v ? "저장됨" : "저장 전");
}
function onPointerDown(): void {
setDemo(false);
pressingRef.current = true;
pressStartRef.current = null;
}
function onPointerUp(): void {
if (pressingRef.current && openStartRef.current === null) { shortSave(); }
pressingRef.current = false;
pressStartRef.current = null;
}
function onPointerLeave(): void {
pressingRef.current = false;
pressStartRef.current = null;
}
function onBtnClick(event: { detail: number; preventDefault: () => void }): void {
if (openStartRef.current !== null) { event.preventDefault(); return; }
// 키보드(Enter/Space) 로 발생한 click 은 pointerup 경로를 거치지 않으므로 여기서 저장 토글
if (event.detail === 0) { setDemo(false); shortSave(); }
}
function onBtnKeyDown(event: { key: string; shiftKey: boolean; preventDefault: () => void }): void {
// 롱프레스(포인터 전용) 대안: Shift+Enter 또는 ArrowDown 으로 팝오버를 열고 첫 항목으로 이동
if (event.key === "ArrowDown" || (event.key === "Enter" && event.shiftKey)) {
event.preventDefault();
setDemo(false);
if (openStartRef.current === null) { setOpen(true, performance.now()); }
focusItem(0);
} else if (event.key === "Escape" && openStartRef.current !== null) {
event.preventDefault();
setDemo(false);
setOpen(false, performance.now());
}
}
function onItemKeyDown(i: number, event: { key: string; preventDefault: () => void }): void {
if (event.key === "ArrowDown") { event.preventDefault(); focusItem(i + 1); }
else if (event.key === "ArrowUp") { event.preventDefault(); focusItem(i - 1); }
else if (event.key === "Escape") {
event.preventDefault();
setDemo(false);
setOpen(false, performance.now());
btnRef.current?.focus({ preventScroll: true });
}
}
return (
<main className={styles.stage}>
<div className={styles.cp}>
<p className={styles.caption}>폴더에 저장</p>
<div className={styles.scene}>
<button
className={styles.btn}
ref={btnRef}
type="button"
aria-pressed={pressed}
aria-expanded={false}
aria-haspopup="true"
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerLeave={onPointerLeave}
onClick={onBtnClick}
onKeyDown={onBtnKeyDown}
>
<svg className={styles.icon} viewBox="0 0 24 24" width={28} height={28} aria-hidden="true" focusable="false">
<path className={styles.shape} 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" />
</svg>
</button>
<div className={styles.panel} ref={panelRef} role="menu">
<p className={styles.panelTitle}>저장할 목록 고르기</p>
{NAMES.map((name, i) => (
<button
key={name}
className={styles.item}
type="button"
role="menuitem"
ref={(el: HTMLButtonElement | null) => { itemRefs.current[i] = el; }}
onClick={() => onItemClick(performance.now())}
onKeyDown={(event: { key: string; preventDefault: () => void }) => onItemKeyDown(i, event)}
>
{name}
</button>
))}
</div>
</div>
<p className={styles.status} aria-live="polite">{status}</p>
</div>
</main>
);
}08 · 保存先コレクションを選ぶポップオーバー — 計測 累積面積 31.662% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 値ひとつ: リスト項目インデックス × POPOVER_STAGGER_MS = その項目が開き始めるまでの遅延。
function renderPanel(now) {
if (openStart === null && !open) {
items.forEach(function (el) { el.style.opacity = "0"; el.style.transform = "translateY(6px)"; });
return;
}
var elapsed = openStart === null ? POPOVER_ITEM_MS * 3 : now - openStart;
items.forEach(function (el, i) {
var delay = i * POPOVER_STAGGER_MS;
var raw = (elapsed - delay) / POPOVER_ITEM_MS;
var p = Math.max(0, Math.min(1, reduce ? 1 : raw));
var eased = 1 - Math.pow(1 - p, 3);
el.style.opacity = eased.toFixed(3);
el.style.transform = "translateY(" + ((1 - eased) * 6).toFixed(2) + "px)";
});
}
測定値: 累積面積31.7%、平均強度79.1、動いたフレーム6/23。
09ドラッグして読書リストに落とすブックマーク
release()が気にする入力はドラッグひとつだけです。指を離した位置とドロップゾーンの距離をひとつのスナップ距離定数と比べ、その比較だけでチップがリストに吸い込まれるか元の位置へ跳ね返るかが決まります。ブックマーク管理画面の読書リスト整理のように、並び替え自体がドラッグでこそ意味を持つ場面向けです。
<main class="stage">
<div class="dg is-demo" id="dg">
<p class="dg__caption">ドラッグしてリストに保存</p>
<div class="dg__scene" id="dgScene">
<div class="dg__drop" id="dgDrop">
<span class="dg__dropLabel">リーディングリストに落とす</span>
</div>
<button class="dg__chip" id="dgChip" type="button" aria-pressed="false">
<svg class="dg__icon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true" focusable="false">
<path class="dg__shape" 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"/>
</svg>
</button>
</div>
<p class="dg__status" id="dgStatus" aria-live="polite">リスト外</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 놓은 지점 - 드롭존 거리 vs BOOKMARK_DRAG_SNAP_PX → 스냅 여부, 이후 관성(감쇠)+정착(lerp) 로 자리를 잡는다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
.dg {
display: grid;
gap: $sp-3;
justify-items: center;
width: 100%;
}
.dg__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: $subject-ink;
}
.dg__scene {
position: relative;
width: 260px;
height: 190px;
display: flex;
flex-direction: column;
align-items: center;
}
.dg__chip {
position: relative;
z-index: 2;
border: 0;
background: $subject-cream;
border-radius: $r-pill;
padding: $sp-3;
cursor: grab;
color: $color;
box-shadow: $shadow-raised;
touch-action: none;
will-change: transform;
}
.dg__chip:active {
cursor: grabbing;
}
.dg__chip:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.dg__shape {
fill: none;
stroke: $color;
stroke-width: 1.6;
stroke-linejoin: round;
}
.dg__chip[aria-pressed="true"] .dg__shape {
fill: $color;
}
.dg__drop {
position: absolute;
top: 108px;
width: 220px;
height: 56px;
display: grid;
place-items: center;
border-radius: $r-card;
border: 2px dashed $subject-blue-light;
background: transparent;
transition: background $duration $easing, border-color $duration $easing, transform $duration $easing;
}
.dg__drop--near {
border-color: $subject-blue;
background: mix($subject-sky, $stage-paper, 55%);
transform: scale(1.03);
}
.dg__drop--filled {
border-style: solid;
border-color: $subject-blue-deep;
background: $subject-sky;
}
.dg__dropLabel {
font-size: 12px;
font-weight: 700;
color: $subject-blue-deep;
}
.dg__status {
margin: 0;
font-size: 11px;
color: $subject-ink;
font-weight: 600;
}
@media (max-width: 340px) {
.dg__scene { width: 200px; height: 100px; }
.dg__drop { width: 150px; top: 50px; height: 34px; }
}
@media (prefers-reduced-motion: reduce) {
.dg__chip { transition: none; }
.dg__drop { 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);
}
.dg {
display: grid;
gap: 12px;
justify-items: center;
width: 100%;
}
.dg__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.dg__scene {
position: relative;
width: 260px;
height: 190px;
display: flex;
flex-direction: column;
align-items: center;
}
.dg__chip {
position: relative;
z-index: 2;
border: 0;
background: #fff7e6;
border-radius: 999px;
padding: 12px;
cursor: grab;
color: #0a4fe8;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
touch-action: none;
will-change: transform;
}
.dg__chip:active {
cursor: grabbing;
}
.dg__chip:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.dg__shape {
fill: none;
stroke: #0a4fe8;
stroke-width: 1.6;
stroke-linejoin: round;
}
.dg__chip[aria-pressed=true] .dg__shape {
fill: #0a4fe8;
}
.dg__drop {
position: absolute;
top: 108px;
width: 220px;
height: 56px;
display: grid;
place-items: center;
border-radius: 16px;
border: 2px dashed #5b8cf8;
background: transparent;
transition: background 300ms ease-out, border-color 300ms ease-out, transform 300ms ease-out;
}
.dg__drop--near {
border-color: #2f6df6;
background: rgb(68.7254901961%, 88.137254902%, 100%);
transform: scale(1.03);
}
.dg__drop--filled {
border-style: solid;
border-color: #0a4fe8;
background: #6ec8ff;
}
.dg__dropLabel {
font-size: 12px;
font-weight: 700;
color: #0a4fe8;
}
.dg__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.dg__scene {
width: 200px;
height: 100px;
}
.dg__drop {
width: 150px;
top: 50px;
height: 34px;
}
}
@media (prefers-reduced-motion: reduce) {
.dg__chip {
transition: none;
}
.dg__drop {
transition: none;
}
}(function () {
"use strict";
var BOOKMARK_DRAG_SNAP_PX = 24;
var DRAG_INERTIA_DECAY = 0.86;
var DRAG_SETTLE_RATE = 0.22;
var CYCLE_S = 2;
var root = document.getElementById("dg");
var scene = document.getElementById("dgScene");
var chip = document.getElementById("dgChip");
var drop = document.getElementById("dgDrop");
var status = document.getElementById("dgStatus");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var dropX = 0, dropY = 78;
var chipX = 0, chipY = 0;
var velX = 0, velY = 0;
var targetX = 0, targetY = 0;
var dragging = false;
var saved = false;
var lastPX = 0, lastPY = 0;
function setSaved(v) {
saved = v;
chip.setAttribute("aria-pressed", String(v));
status.textContent = v ? "保存済み" : "リスト外";
drop.classList.toggle("dg__drop--filled", v);
}
// 값 하나: 놓은 지점과 드롭존 사이 거리 vs BOOKMARK_DRAG_SNAP_PX → 목록에 스냅하거나 원위치로 튕긴다.
function release() {
var dx = chipX - dropX, dy = chipY - dropY;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= BOOKMARK_DRAG_SNAP_PX) {
targetX = dropX; targetY = dropY;
setSaved(true);
} else {
targetX = 0; targetY = 0;
setSaved(false);
}
}
function startDrag(clientX, clientY) {
root.classList.remove("is-demo");
dragging = true;
velX = 0; velY = 0;
lastPX = clientX; lastPY = clientY;
}
function moveDrag(clientX, clientY) {
var dx = clientX - lastPX, dy = clientY - lastPY;
chipX += dx; chipY += dy;
velX = dx; velY = dy;
lastPX = clientX; lastPY = clientY;
}
chip.addEventListener("pointerdown", function (e) {
chip.setPointerCapture(e.pointerId);
startDrag(e.clientX, e.clientY);
});
chip.addEventListener("pointermove", function (e) {
if (dragging) moveDrag(e.clientX, e.clientY);
});
chip.addEventListener("pointerup", function () {
if (dragging) { dragging = false; release(); }
});
chip.addEventListener("click", function () {
if (dragging) return;
root.classList.remove("is-demo");
if (saved) { targetX = 0; targetY = 0; setSaved(false); }
else { targetX = dropX; targetY = dropY; setSaved(true); }
});
function settle(dt) {
if (dragging) return;
if (!reduce) {
chipX += velX; chipY += velY;
velX *= DRAG_INERTIA_DECAY; velY *= DRAG_INERTIA_DECAY;
chipX += (targetX - chipX) * DRAG_SETTLE_RATE;
chipY += (targetY - chipY) * DRAG_SETTLE_RATE;
} else {
chipX = targetX; chipY = targetY; velX = 0; velY = 0;
}
}
function render() {
chip.style.transform = "translate(" + chipX.toFixed(2) + "px, " + chipY.toFixed(2) + "px)" +
(dragging ? " scale(1.06)" : "");
var near = Math.abs(chipX - dropX) < 40 && Math.abs(chipY - dropY) < 40;
drop.classList.toggle("dg__drop--near", near && (dragging || !saved && targetX === dropX));
}
// .is-demo 자동재생: 실제 pointer 이벤트 없이 chipX/Y·release() 를 같은 경로로 호출한다.
var start = null;
var prevT = 0;
var phase = "idle";
function synth(t) {
if (t > 0.1 && t < 0.55 && phase === "idle") {
phase = "dragIn";
dragging = true;
}
if (phase === "dragIn") {
var p = (t - 0.1) / 0.45;
p = Math.max(0, Math.min(1, p));
chipX = dropX * p; chipY = dropY * p;
if (t >= 0.55) { dragging = false; phase = "afterIn"; release(); }
}
if (t > 1.0 && t < 1.4 && phase === "afterIn") {
phase = "dragOut";
dragging = true;
}
if (phase === "dragOut") {
var q = (t - 1.0) / 0.4;
q = Math.max(0, Math.min(1, q));
chipX = dropX * (1 - q); chipY = dropY * (1 - q);
if (t >= 1.4) { dragging = false; phase = "done"; release(); }
}
}
function loop(now) {
if (start === null) start = now;
var t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { phase = "idle"; }
prevT = t;
if (!reduce && root.classList.contains("is-demo")) {
synth(t);
}
settle();
render();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// DragReadingListDropBookmark — vanilla 판(run/416/demo/09-bookmark-btn-drag-reading-list-drop/index.html) 의 React 포트.
// 값 하나: 놓은 지점 - 드롭존 거리 vs BOOKMARK_DRAG_SNAP_PX → 스냅 여부. 이후 관성(감쇠)+정착(lerp) 로 자리를 잡는다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./DragReadingListDropBookmark.module.scss";
type Box<T> = { current: T };
const BOOKMARK_DRAG_SNAP_PX = 24;
const DRAG_INERTIA_DECAY = 0.86;
const DRAG_SETTLE_RATE = 0.22;
const CYCLE_S = 2;
const DROP_X = 0;
const DROP_Y = 78;
export default function DragReadingListDropBookmark() {
const chipRef = useRef<HTMLButtonElement | null>(null);
const dropRef = useRef<HTMLDivElement | null>(null);
const chipXRef = useRef<number>(0) as Box<number>;
const chipYRef = useRef<number>(0) as Box<number>;
const velXRef = useRef<number>(0) as Box<number>;
const velYRef = useRef<number>(0) as Box<number>;
const targetXRef = useRef<number>(0) as Box<number>;
const targetYRef = useRef<number>(0) as Box<number>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const lastPXRef = useRef<number>(0) as Box<number>;
const lastPYRef = useRef<number>(0) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const [saved, setSavedState] = useState(false);
const [status, setStatus] = useState("목록 밖");
const [near, setNear] = useState(false);
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function setSaved(v: boolean): void {
setSavedState(v);
setStatus(v ? "저장됨" : "목록 밖");
}
function release(): void {
const dx = chipXRef.current - DROP_X;
const dy = chipYRef.current - DROP_Y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= BOOKMARK_DRAG_SNAP_PX) {
targetXRef.current = DROP_X; targetYRef.current = DROP_Y;
setSaved(true);
} else {
targetXRef.current = 0; targetYRef.current = 0;
setSaved(false);
}
}
function startDrag(clientX: number, clientY: number): void {
setDemo(false);
draggingRef.current = true;
velXRef.current = 0; velYRef.current = 0;
lastPXRef.current = clientX; lastPYRef.current = clientY;
}
function moveDrag(clientX: number, clientY: number): void {
const dx = clientX - lastPXRef.current;
const dy = clientY - lastPYRef.current;
chipXRef.current += dx; chipYRef.current += dy;
velXRef.current = dx; velYRef.current = dy;
lastPXRef.current = clientX; lastPYRef.current = clientY;
}
function settle(): void {
if (draggingRef.current) return;
if (!reduce) {
chipXRef.current += velXRef.current;
chipYRef.current += velYRef.current;
velXRef.current *= DRAG_INERTIA_DECAY;
velYRef.current *= DRAG_INERTIA_DECAY;
chipXRef.current += (targetXRef.current - chipXRef.current) * DRAG_SETTLE_RATE;
chipYRef.current += (targetYRef.current - chipYRef.current) * DRAG_SETTLE_RATE;
} else {
chipXRef.current = targetXRef.current; chipYRef.current = targetYRef.current;
velXRef.current = 0; velYRef.current = 0;
}
}
function render(): void {
const chip = chipRef.current;
if (!chip) return;
chip.style.transform = "translate(" + chipXRef.current.toFixed(2) + "px, " + chipYRef.current.toFixed(2) + "px)" +
(draggingRef.current ? " scale(1.06)" : "");
const isNear = Math.abs(chipXRef.current - DROP_X) < 40 && Math.abs(chipYRef.current - DROP_Y) < 40;
setNear(isNear && (draggingRef.current || (!saved && targetXRef.current === DROP_X)));
}
function synth(t: number, phaseRef: Box<string>): void {
if (t > 0.1 && t < 0.55 && phaseRef.current === "idle") {
phaseRef.current = "dragIn";
draggingRef.current = true;
}
if (phaseRef.current === "dragIn") {
let p = (t - 0.1) / 0.45;
p = Math.max(0, Math.min(1, p));
chipXRef.current = DROP_X * p; chipYRef.current = DROP_Y * p;
if (t >= 0.55) { draggingRef.current = false; phaseRef.current = "afterIn"; release(); }
}
if (t > 1.0 && t < 1.4 && phaseRef.current === "afterIn") {
phaseRef.current = "dragOut";
draggingRef.current = true;
}
if (phaseRef.current === "dragOut") {
let q = (t - 1.0) / 0.4;
q = Math.max(0, Math.min(1, q));
chipXRef.current = DROP_X * (1 - q); chipYRef.current = DROP_Y * (1 - q);
if (t >= 1.4) { draggingRef.current = false; phaseRef.current = "done"; release(); }
}
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0;
let prevT = 0;
const phaseRef: Box<string> = { current: "idle" };
function loop(now: number): void {
if (start === 0) start = now;
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { phaseRef.current = "idle"; }
prevT = t;
if (!reduce && demo) {
synth(t, phaseRef);
}
settle();
render();
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo, saved]);
function onPointerDown(event: ReactPointerEvent<HTMLButtonElement>): void {
event.currentTarget.setPointerCapture(event.pointerId);
startDrag(event.clientX, event.clientY);
}
function onPointerMove(event: ReactPointerEvent<HTMLButtonElement>): void {
if (draggingRef.current) moveDrag(event.clientX, event.clientY);
}
function onPointerUp(): void {
if (draggingRef.current) { draggingRef.current = false; release(); }
}
function onClick(): void {
if (draggingRef.current) return;
setDemo(false);
if (saved) { targetXRef.current = 0; targetYRef.current = 0; setSaved(false); }
else { targetXRef.current = DROP_X; targetYRef.current = DROP_Y; setSaved(true); }
}
const dropCls = [styles.drop, near ? styles.dropNear : "", saved ? styles.dropFilled : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={styles.dg}>
<p className={styles.caption}>끌어서 목록에 저장</p>
<div className={styles.scene}>
<div className={dropCls} ref={dropRef}>
<span className={styles.dropLabel}>읽기 목록에 놓기</span>
</div>
<button
className={styles.chip}
ref={chipRef}
type="button"
aria-pressed={saved}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onClick={onClick}
>
<svg className={styles.icon} viewBox="0 0 24 24" width={26} height={26} aria-hidden="true" focusable="false">
<path className={styles.shape} 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" />
</svg>
</button>
</div>
<p className={styles.status} aria-live="polite">{status}</p>
</div>
</main>
);
}09 · ドラッグして読書リストに落とすブックマーク — 計測 累積面積 14.492% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 値ひとつ: 離した位置とドロップゾーンの距離 vs BOOKMARK_DRAG_SNAP_PX →
// リストにスナップするか元の位置に戻るか。
function release() {
var dx = chipX - dropX, dy = chipY - dropY;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= BOOKMARK_DRAG_SNAP_PX) {
targetX = dropX; targetY = dropY;
setSaved(true);
} else {
targetX = 0; targetY = 0;
setSaved(false);
}
}
測定値: 累積面積14.5%、平均強度43.1、動いたフレーム19/23。
どこで壊れるか — 動作軽減設定・誤タップ・二重クリック
9つのボタンはどれも、動きがあってもなくてもクリックやpointerupのハンドラを毎回呼ぶので、モーションを省いてもスクリーンリーダーへの通知とsavedの実際の値がずれることはありません。ここまでは9つとも同じです。05番のprefers-reduced-motion: reduceの扱いも今は隙がありません — reduceがtrueになった瞬間に水位levelは目標値へ即座に固定され、波を作るphaseの更新も同時に止まるので、動作を減らす設定を入れると水面の波紋も完全に静止します。この形を再利用するときは、水位と位相の両方をreduce分岐でまとめて止める書き方をそのまま持っていく価値があります。
08番はキーボード操作の穴もすでに塞がれています。ボタンにフォーカスした状態でEnterかSpaceを押すとclickイベントのe.detail === 0分岐がshortSave()を呼んで保存を切り替え、ArrowDownまたはShift+Enterはパネルを開いて最初の項目にフォーカスを移し、開いた後はArrowUp/Downで項目間を移動してEnterで選び、Escapeでパネルを閉じてボタンへフォーカスを戻します。aria-expandedとaria-haspopup、そしてパネルのrole="menu"もあわせて付いているので、ポインターを使わない読者でも保存もコレクション選択もキーボードだけで完了できます。1〜7番に共通する小さなリスクは二重クリックで、どのハンドラも連打を防いでいないため、素早い2回タップはsavedを2回切り替えて見た目は元通りになり、遅い回線や重いフレームでは「押したのに何も起きなかった」ように映ります。
値が保存の動きを変える場所 — スプリングオーバーシュート比率・カウント間隔・ドラッグスナップ距離
| 項目 | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|
| 01 ホバーの傾きと光 | 17.3% | 24.3 | 21/23 |
| 02 輪郭から塗りつぶしへの変形 | 16.0% | 37.6 | 7/23 |
| 03 スプリングポップ拡大 | 9.2% | 28.0 | 12/23 |
| 04 リボンの折れと落下 | 10.6% | 64.4 | 17/23 |
| 05 液体の波で満ちる | 10.1% | 68.8 | 11/23 |
| 06 桁が積み重なるカウントバッジ | 12.3% | 69.5 | 12/23 |
| 07 トースト確認スライド | 10.5% | 109.0 | 11/23 |
| 08 コレクション選択ポップオーバー | 31.7% | 79.1 | 6/23 |
| 09 ドラッグで読書リストに落とす | 14.5% | 43.1 | 19/23 |
08番の累積面積(31.7%)が次点のほぼ2倍なのは、9つの中で唯一、アイコンをその場で描き直すのではなくレイアウトに新しい要素(パネルの各行)を足し、しかもその行それぞれが自分の遅延で描き直されるからです。動いたフレーム数がもっとも少ないのは08番(6/23)で、パネルの各行がその遅延の間だけ描き直されるぶん平均強度79.1が集中しており、02番(7/23)もsetSaved()が連続ループではなく本物のクリックでしか発火しない点は同じです。9つの違いの大部分は3つの定数が握っています — BOOKMARK_SPRING_OVERSHOOT_PERCENT(03番)はスプリングが1をどこまで超えられるかを、BOOKMARK_COUNT_STAGGER_MS(06番)は2つの桁が回り始める間隔を、BOOKMARK_DRAG_SNAP_PX(09番)は落とした位置がどれだけ近ければ保存扱いになるかを決めていて、上の9つの関数はどれもこの3種類の数値のどれかを、違う速さで読んでいるにすぎません。
3つのバリエーションとキーボード操作に残るアクセシビリティの穴
| バリエーション | 値の変化 | 変わること |
|---|---|---|
| スプリングオーバーシュート比率値を調整する | BOOKMARK_SPRING_OVERSHOOT_PERCENT 12 → 38 |
値を小さくすると弾む度合いが減り落ち着いて大きくなります。値を大きくするとスプリングが大きく飛び出してから戻り、誇張されたポップになります |
| 保存数バッジの桁上がり間隔値を調整する | BOOKMARK_COUNT_STAGGER_MS 40 → 160 |
値を短くすると数字がほぼ同時に切り替わります。値を長くすると一桁ずつはっきり上がっていくのが見えます |
| ドラッグを離す瞬間のスナップ距離値を調整する | BOOKMARK_DRAG_SNAP_PX 24 → 96 |
値を小さくすると目標のすぐ近くまで寄らないと吸着しません。値を大きくすると遠くからでもスッと吸着します |
3つのバリエーションはどれも、09番のキーボード動作を変えません。BOOKMARK_DRAG_SNAP_PXが24でも96でも、チップは変わらずネイティブbutton要素なので、フォーカスしてEnter・Spaceを押せば同じclickハンドラが呼ばれ、ドラッグで吸着させたときと同じsaved・スナップ位置に切り替わります。9つすべてで変わらず保たれているのは、すべてのボタンがaria-pressedを持ち、すべての状態テキストがaria-live="polite"の領域に置かれていることです。09番でキーボードに再現できないのはドラッグという動きそのものだけで、保存・配置という結果はEnter・Spaceだけで届きます。w36j826fを開くと、09番のindex.htmlでこのclickハンドラが実際どう書かれているか確認できます。
しおり ボタンとブックマーク ボタンは何が違うのか?
多くのプロダクトで同じクリックを指す2つの言葉ですが、お気に入り ボタン デザインはたいてい並び順もフォルダもないフラットな集合を意味するのに対し、しおり ボタンは「その場所に印を挟む」という古いメタファーを引きずっていて、だからこそ上の06番・08番・09番は件数・コレクション・位置という「お気に入り」だけでは普通は要らない要素を足しています。ショッピングサイトのハートも読書アプリのリボンも、裏側ではまったく同じaria-pressedトグルを共有していながら、片方は「気に入った」を、もう片方は「この場所に戻ってくる」を意味するだけで読み手の受け取り方が変わります。同じ「毎フレーム値をひとつ読む」という考え方でまとめた編としては、9 CSS Heart Animationsが「気に入った/いない」の半分をより詳しく扱っていて、9 CSS Toast Notificationsは07番の確認バンドに一番近い編で、Drag to Reorder UIは09番が一度しか触れないドロップ位置の計算をさらに掘り下げています。
ブックマーク ボタン html を組み込む前によくある質問
モーションを切ると保存そのものも止まりますか?
9つとも止まりません。savedを切り替えてaria-pressedを更新するクリックやポインターのハンドラは、prefers-reduced-motionの値に関わらず同じように動くからです。05番はむしろ動作を減らす設定にいちばん忠実で、水位が目標値へ即座に切り替わるだけでなく波を作るphaseの更新も止まるため、保存は機能したまま見た目は最終的な塗りだけで静止します。
9つのうち、今すぐ本物の保存ボタンとして使えそうなのはどれですか?
1番から9番まで、実際の保存トグルはEnter・Space・クリック・タップのいずれかに結び付いていてキーボード経路も欠けていないので、見た目だけ差し替えて本番のボタンに落とし込めます。09番だけはドラッグという動きそのものをキーボードで再現できませんが、チップはネイティブbuttonなのでEnter・Spaceが同じclickハンドラを呼び、ドラッグで吸着させたのと同じ保存・位置に切り替わります。
ブックマーク ボタン と は違う、リスト画面向けの実装として9つをまとめて使い回せますか?
01番から06番は要素の参照と定数を自分の中に閉じて持っているので、ページ側の前提には触れません。一覧の行ごとに初期化関数を1回ずつ呼べば、ページ全体で1回呼ぶのと同じ感覚で並べられます。08番と09番は一覧画面ではより注意が必要です。1つのボタン向けに調整したポップオーバーやドラッグの判定距離が、30個並んだ状態では隣同士とぶつかりやすくなります。








