01押すと沈む矢印
矢印がポインタ側へ沈み、バネで元の位置へ戻って最後にもう一度揺れる。
使いどころダウンロードボタンの基本の動き
SINK_PXSPRING_KSPRING_DAMP

<main class="stage">
<div class="sk is-demo" id="skRoot">
<button class="sk__btn" id="skBtn" type="button">
<svg class="sk__icon" id="skIcon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11M6.5 10.5 12 16l5.5-5.5M5 20h14"/></svg>
<span>ダウンロード</span>
</button>
<p class="sk__meta"><span class="sk__tag">Y</span> <b id="skY">0.0</b> <span class="sk__tag">TILT</span> <b id="skTilt">0.0</b></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 눌림 깊이는 CSS transition 이 아니라 JS 가 스프링 방정식으로 적분한 값이다.
// 그 값 하나(--p)가 버튼 색 · 그림자 길이 · 화살표 위치를 함께 끈다.
$sink-face: $subject-lilac;
$sink-deep: $subject-pink;
@include stage($stage-yellow);
.sk {
width: min(100%, 280px);
display: grid;
gap: $sp-4;
justify-items: center;
}
.sk__btn {
--p: 0;
box-sizing: border-box;
width: 240px;
max-width: 100%;
height: 64px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: $sp-3;
border: 0;
border-radius: $r-control;
background: color-mix(in srgb, #{$sink-face}, #{$sink-deep} calc(var(--p) * 100%));
box-shadow: 0 calc(14px - 8px * var(--p)) calc(24px - 12px * var(--p)) rgba($subject-ink, .32);
color: $subject-ink;
font-family: inherit;
font-size: 20px;
font-weight: 700;
line-height: 24px;
cursor: pointer;
}
.sk__btn:focus-visible { outline: 3px solid $subject-ink; outline-offset: 3px; }
.sk__icon {
width: 28px;
height: 28px;
fill: none;
stroke: currentColor;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.sk__meta {
margin: 0;
display: flex;
align-items: baseline;
gap: $sp-2;
font-size: 12px;
line-height: 16px;
color: $subject-ink;
}
.sk__tag { font-weight: 700; letter-spacing: .08em; }
.sk__meta b { min-width: 5ch; text-align: right; font-variant-numeric: tabular-nums; }
@media (prefers-reduced-motion: reduce) {
.sk__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);
}
.sk {
width: min(100%, 280px);
display: grid;
gap: 16px;
justify-items: center;
}
.sk__btn {
--p: 0;
box-sizing: border-box;
width: 240px;
max-width: 100%;
height: 64px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 12px;
border: 0;
border-radius: 12px;
background: color-mix(in srgb, #b9a0ff, #ff9ebb calc(var(--p) * 100%));
box-shadow: 0 calc(14px - 8px * var(--p)) calc(24px - 12px * var(--p)) rgba(23, 20, 26, 0.32);
color: #17141a;
font-family: inherit;
font-size: 20px;
font-weight: 700;
line-height: 24px;
cursor: pointer;
}
.sk__btn:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.sk__icon {
width: 28px;
height: 28px;
fill: none;
stroke: currentColor;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.sk__meta {
margin: 0;
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
line-height: 16px;
color: #17141a;
}
.sk__tag {
font-weight: 700;
letter-spacing: 0.08em;
}
.sk__meta b {
min-width: 5ch;
text-align: right;
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
.sk__btn {
transition: none;
}
}(function () {
// 기법: 눌림 깊이와 기울기를 스프링 방정식 x'' = -K (x - 목표) - D x' 로 매 프레임 적분한다.
// 감쇠가 약해서 화살표는 제자리를 지나쳤다가 한 번 더 출렁이고 멈춘다 (CSS transition 으로는 못 만드는 곡선).
var SINK_PX = 10;
var SPRING_K = 120;
var SPRING_DAMP = 9;
var TILT_MAX = 14;
var START_MS = 200;
var HOLD_MS = 560;
var CYCLE_MS = 2000;
var root = document.getElementById('skRoot');
var btn = document.getElementById('skBtn');
var icon = document.getElementById('skIcon');
var yEl = document.getElementById('skY');
var tiltEl = document.getElementById('skTilt');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var y = 0;
var vy = 0;
var tilt = 0;
var vt = 0;
var pressed = false;
var side = 0;
var t0 = null;
var lastTs = null;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function stopDemo() { root.classList.remove('is-demo'); }
function goal(now) {
if (root.classList.contains('is-demo') && now >= START_MS) {
var ph = (now - START_MS) % CYCLE_MS;
return { depth: ph < HOLD_MS ? SINK_PX : 0, lean: ph < HOLD_MS ? -0.8 : (ph < 1100 ? 0.8 : 0) };
}
return { depth: pressed ? SINK_PX : 0, lean: side };
}
function integrate(dt, g) {
var n = Math.ceil(dt / 0.008);
var h = dt / n;
for (var i = 0; i < n; i += 1) {
vy += (-SPRING_K * (y - g.depth) - SPRING_DAMP * vy) * h;
y += vy * h;
vt += (-SPRING_K * (tilt - g.lean * TILT_MAX) - SPRING_DAMP * vt) * h;
tilt += vt * h;
}
}
function render() {
var p = clamp(y / SINK_PX, -0.3, 1);
icon.style.transform = 'translateY(' + y.toFixed(2) + 'px) rotate(' + tilt.toFixed(2) + 'deg)';
btn.style.transform = 'translateY(' + (y * 0.3).toFixed(2) + 'px)';
btn.style.setProperty('--p', p.toFixed(3));
yEl.textContent = y.toFixed(1);
tiltEl.textContent = tilt.toFixed(1);
}
function snap() {
var g = goal(0);
y = g.depth; vy = 0; tilt = g.lean * TILT_MAX; vt = 0;
render();
}
function press() { stopDemo(); pressed = true; if (reduce) snap(); }
function release() { pressed = false; if (reduce) snap(); }
btn.addEventListener('pointerdown', function (e) {
var r = btn.getBoundingClientRect();
side = clamp((e.clientX - r.left - r.width / 2) / (r.width / 2), -1, 1);
press();
});
btn.addEventListener('pointerup', release);
btn.addEventListener('pointerleave', release);
btn.addEventListener('pointercancel', release);
btn.addEventListener('keydown', function (e) {
if ((e.key === 'Enter' || e.key === ' ') && !e.repeat) { side = 0; press(); }
});
btn.addEventListener('keyup', function (e) { if (e.key === 'Enter' || e.key === ' ') release(); });
btn.addEventListener('blur', release);
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
integrate(dt, goal(now));
render();
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
render();
} else {
requestAnimationFrame(tick);
}
})();// SinkArrowButton — vanilla 판(01-dlbtn-arrow-sink-spring) 의 React 포트.
// 눌림 깊이와 기울기를 스프링 방정식으로 매 프레임 적분한다. CSS transition 은 쓰지 않는다.
import { useEffect, useRef } from "react";
import styles from "./SinkArrowButton.module.scss";
const SINK_PX = 10;
const SPRING_K = 120;
const SPRING_DAMP = 9;
const TILT_MAX = 14;
const START_MS = 200;
const HOLD_MS = 560;
const CYCLE_MS = 2000;
type Goal = { depth: number; lean: number };
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function SinkArrowButton() {
const btnRef = useRef<HTMLButtonElement | null>(null);
const iconRef = useRef<SVGSVGElement | null>(null);
const yRef = useRef<HTMLElement | null>(null);
const tiltRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const btn = btnRef.current;
const icon = iconRef.current;
const yEl = yRef.current;
const tiltEl = tiltRef.current;
if (!btn || !icon || !yEl || !tiltEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let y = 0;
let vy = 0;
let tilt = 0;
let vt = 0;
let pressed = false;
let side = 0;
let t0: number | null = null;
let lastTs: number | null = null;
let raf = 0;
const goal = (now: number): Goal => {
if (demo && now >= START_MS) {
const ph = (now - START_MS) % CYCLE_MS;
return { depth: ph < HOLD_MS ? SINK_PX : 0, lean: ph < HOLD_MS ? -0.8 : ph < 1100 ? 0.8 : 0 };
}
return { depth: pressed ? SINK_PX : 0, lean: side };
};
const integrate = (dt: number, g: Goal): void => {
const n = Math.ceil(dt / 0.008);
const h = dt / n;
for (let i = 0; i < n; i += 1) {
vy += (-SPRING_K * (y - g.depth) - SPRING_DAMP * vy) * h;
y += vy * h;
vt += (-SPRING_K * (tilt - g.lean * TILT_MAX) - SPRING_DAMP * vt) * h;
tilt += vt * h;
}
};
const render = (): void => {
const p = clamp(y / SINK_PX, -0.3, 1);
icon.style.transform = `translateY(${y.toFixed(2)}px) rotate(${tilt.toFixed(2)}deg)`;
btn.style.transform = `translateY(${(y * 0.3).toFixed(2)}px)`;
btn.style.setProperty("--p", p.toFixed(3));
yEl.textContent = y.toFixed(1);
tiltEl.textContent = tilt.toFixed(1);
};
const snap = (): void => {
const g = goal(0);
y = g.depth; vy = 0; tilt = g.lean * TILT_MAX; vt = 0;
render();
};
const press = (): void => { demo = false; pressed = true; if (reduce) snap(); };
const release = (): void => { pressed = false; if (reduce) snap(); };
const onDown = (e: PointerEvent): void => {
const r = btn.getBoundingClientRect();
side = clamp((e.clientX - r.left - r.width / 2) / (r.width / 2), -1, 1);
press();
};
const onKeyDown = (e: KeyboardEvent): void => {
if ((e.key === "Enter" || e.key === " ") && !e.repeat) { side = 0; press(); }
};
const onKeyUp = (e: KeyboardEvent): void => { if (e.key === "Enter" || e.key === " ") release(); };
btn.addEventListener("pointerdown", onDown);
btn.addEventListener("pointerup", release);
btn.addEventListener("pointerleave", release);
btn.addEventListener("pointercancel", release);
btn.addEventListener("keydown", onKeyDown);
btn.addEventListener("keyup", onKeyUp);
btn.addEventListener("blur", release);
function tick(ts: number): void {
if (t0 === null) t0 = ts;
const now = ts - t0;
const dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
integrate(dt, goal(now));
render();
raf = requestAnimationFrame(tick);
}
if (reduce) render();
else raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
btn.removeEventListener("pointerdown", onDown);
btn.removeEventListener("pointerup", release);
btn.removeEventListener("pointerleave", release);
btn.removeEventListener("pointercancel", release);
btn.removeEventListener("keydown", onKeyDown);
btn.removeEventListener("keyup", onKeyUp);
btn.removeEventListener("blur", release);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.sk}>
<button className={styles.sk__btn} type="button" ref={btnRef}>
<svg className={styles.sk__icon} viewBox="0 0 24 24" aria-hidden="true" ref={iconRef}>
<path d="M12 4v11M6.5 10.5 12 16l5.5-5.5M5 20h14" />
</svg>
<span>내려받기</span>
</button>
<p className={styles.sk__meta}>
<span className={styles.sk__tag}>Y</span> <b ref={yRef}>0.0</b> <span className={styles.sk__tag}>TILT</span> <b ref={tiltRef}>0.0</b>
</p>
</div>
</main>
);
}01 押すと沈む矢印 — 計測 累積面積 13.29% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · css download button 9パターン — 沈み・満ち・ファイルが飛ぶボタン
このデモが属する記事
css download button 9パターン — 沈み・満ち・ファイルが飛ぶボタンcss download button をラベルの入れ替えだけで作ると、矢印は硬く、進捗は段差になり、ファイルの行き先も見えない — 01番・02番・03番が計算で解く。取得ボタン一つを、押す・満ちる・届く・終わる・失敗するまで動きで見せる この記事のデモをすべて見る


