CTA ボタン デザイン 9選 — 伸びる影から跳ねるバーまで
9つのうち1つは伸びる影の長さの値がホバー状態に応じて伸び縮みし、1つは周囲の進行の値が決まった時間の間に0まで減り、もう1つは引いた位置の値が基準を超えると確定した状態に変わります。cta ボタン デザインの9つの動きを見ていきます。
01 cta-btn-long-shadow-retro
02 cta-btn-glitch-split
03 cta-btn-confetti-burst
04 cta-btn-progress-ring-countdown
05 cta-btn-flip-3d-face
06 cta-btn-drag-confirm-slide
07 cta-btn-typewriter-reveal
08 cta-btn-skeleton-shimmer
09 cta-btn-vu-meter-tap
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 ロングシャドウ・レトロ — ホバー状態に応じて伸びる影の長さの値が変わるボタン
- 02 ずれ分離 — 重なったテキスト層の位置の値が一瞬ずれてから再び重なるボタン
- 03 破片発散 — クリック地点から複数の破片がそれぞれ異なる角度の値で散ってから消えるボタン
- 04 周囲進行 — ボタンを囲む周囲の進行の値が決まった時間の間に減っていくボタン
- 05 立体反転 — クリックすると回転の値が180度まで回り反対の面が現れるボタン
- 06 スライド確定 — 引いた位置の値が基準を超えると確定状態になるボタン
- 07 順次表示 — ホバー状態で2つ目の文言が文字の順番の値に沿って一つずつ現れるボタン
- 08 流れる待機 — コンテンツが準備できるまで明るさの値が一方向へ流れるボタン
- 09 バー反応 — クリックのたびに複数のバーの高さの値がそれぞれ異なって跳ねてから沈むボタン
9つのどれも決済処理や実際の通知サービスにはつながっていません。09のタップは実際のWeb Audioで拾った音ではなく擬似的に再現した反応にすぎず、この中にfetchを呼び出す処理は一つもありません。9つのうちどれも、訪問者が実際に行った操作に応じて動きます。01・07はmouseenter/mouseleaveとfocus/blurによるホバーやフォーカスの状態に応じ、02はmouseenter/focus/clickに応じ、03・04・08・09は単純なclickに応じ、05はclickとkeydownの両方に応じ、06はpointerdown-pointermove-pointerupと続くドラッグに応じます — 自動再生だけで動くものは9つの中に一つもありません。
9つのパターンの解剖 — どの値がいつ変わるか
9つのパターンは、ホバー・クリック・ドラッグのいずれかを動かす価値へ変換する3つの方法に分かれており、この層を省いてしまうと、cta ボタン 作り方の実例集が9つの同じ色の四角形の並びのように見えてしまい、1つのボタンに組み込まれた9つの小さな計算装置には見えなくなってしまいます。
なぜ3つのパターンは経過時間から直接、物理や三角関数の式を解くのか
03・05・08は範囲のある進行比率にはまったく触れず、それぞれ時間ベースの式を解いています。03のtick()は毎フレームGRAVITY(420)とDRAG(0.985)を各破片の速度に積み上げており、そのため散らばりが台本通りの弧ではなく実際に重力を受けて落ちていく破片のように見えます。05のease()は経過秒数をFREQ(14)で振動しDECAY(9)で減衰するサイン項に代入しており、これが反転した面が180度を超えてから落ち着く動きを生んでいます。08のframe()は経過時間をeaseInOutSine()に通して固定のCYCLE(1100ms)の中でハイライト帯を行き来させており、直線的なスクラブでは決してありません。
なぜ3つのパターンは同じ滑らかさの形で値を目標へ引き寄せるのか
01・06・09はいずれも同じ形を繰り返しています — 目標値と現在値の差に一定の係数を掛けて毎フレーム足し戻し、差が縮まるまで続けるという形です。01のstep()はlenをtargetへdiff * 0.28で近づけます。06のsettle()はドラッグ後のposを0か1へdiff * 0.3で戻します。09のtick()はタップ後に各バーの高さをMIN_Hへdiff * DECAY(0.14)で戻します。3つの間で違うのは係数と目標だけです。
なぜ3つのパターンはインデックス・ティック数・時間比率で動きを決めるのか
02・04・07はいずれも物理項以外の値から動きを計算しています。02のrunGlitch()は経過ティックを固定のTICK(40ms)とDURATION(320ms)の組と比較してオフセットを戻すタイミングを決めています。04のframe()は経過時間をDURATION(1500ms)で割った0から1の進行比率をstrokeDashoffsetに直接反映させます。07の文字ごとの遅延は経過時間ではなく各文字自身のインデックスをMath.pow(i, 0.82) * BASEに代入して決まっており、フレームループがどれだけ速くても画面上最後の文字(インデックス9)は1番目より400ms以上長く待たされます。
パターン別コード
01ロングシャドウ・レトロ — ホバー状態に応じて伸びる影の長さの値が変わるボタン
影が伸びる長さの値はホバー状態になると目標値まで伸び、ホバー状態が解けると元の長さの値に戻ります。

<main class="stage">
<div class="ctalsr is-demo" id="ctalsr">
<div class="ctalsr__panel">
<button class="ctalsr__btn" id="ctalsrBtn" type="button">今すぐ始める</button>
<span class="ctalsr__hint">ホバーすると影の長さが伸びます</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(색·거리 보간)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-yellow);
.stage { position: relative; }
.ctalsr__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
padding: $sp-5;
border-radius: $r-card;
background: $stage-paper;
}
.ctalsr__btn {
border: 0;
border-radius: $r-control;
background: $subject-blue-deep;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 16px;
padding: $sp-4 $sp-5;
cursor: pointer;
}
.ctalsr__hint {
color: rgba($subject-ink, .72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctalsr__panel { padding: $sp-4; gap: $sp-3; }
.ctalsr__btn { padding: $sp-3 $sp-4; font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.ctalsr__btn { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.ctalsr__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 20px;
border-radius: 16px;
background: #ffffff;
}
.ctalsr__btn {
border: 0;
border-radius: 12px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 16px;
padding: 16px 20px;
cursor: pointer;
}
.ctalsr__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctalsr__panel {
padding: 16px;
gap: 12px;
}
.ctalsr__btn {
padding: 12px 16px;
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.ctalsr__btn {
transition: none !important;
}
}(function () {
var root = document.getElementById('ctalsr');
var btn = document.getElementById('ctalsrBtn');
// 계산 겹: 그림자 길이 값을 매 프레임 목표로 선형 보간(0.28)해 부드럽게 좇고,
// 그 길이만큼 45도 방향(dx=dy=1px)으로 레이어를 쌓아 box-shadow 문자열을 만든다.
// 레이어마다 색을 옅은 파랑에서 짙은 잉크색으로 선형 보간해 원근감을 준다.
var MAXLEN = 22;
var C_NEAR = [47, 109, 246]; // $subject-blue
var C_FAR = [23, 20, 26]; // $subject-ink
var len = 0, target = 0, raf = null;
function lerp(a, b, t) { return a + (b - a) * t; }
function buildShadow(l) {
var n = Math.round(l);
if (n <= 0) { return 'none'; }
var parts = [];
for (var i = 1; i <= n; i++) {
var t = i / n;
var r = Math.round(lerp(C_NEAR[0], C_FAR[0], t));
var g = Math.round(lerp(C_NEAR[1], C_FAR[1], t));
var b = Math.round(lerp(C_NEAR[2], C_FAR[2], t));
parts.push(i + 'px ' + i + 'px 0 rgb(' + r + ',' + g + ',' + b + ')');
}
return parts.join(',');
}
function step() {
var diff = target - len;
len += diff * 0.28;
if (Math.abs(diff) < 0.05) { len = target; }
btn.style.boxShadow = buildShadow(len);
if (Math.abs(target - len) > 0.05) {
raf = requestAnimationFrame(step);
} else {
raf = null;
}
}
function go(to) {
target = to;
if (raf === null) { raf = requestAnimationFrame(step); }
}
function wireReal() {
btn.addEventListener('mouseenter', function () { root.classList.remove('is-demo'); stopAutoplay(); go(MAXLEN); });
btn.addEventListener('mouseleave', function () { go(0); });
btn.addEventListener('focus', function () { root.classList.remove('is-demo'); stopAutoplay(); go(MAXLEN); });
btn.addEventListener('blur', function () { go(0); });
}
wireReal();
var hovered = false;
var autoplayTimer = null;
function autoplayTick() { hovered = !hovered; go(hovered ? MAXLEN : 0); }
function stopAutoplay() { if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoplayTimer = setInterval(autoplayTick, 700); }
btn.style.boxShadow = 'none';
})();import { useEffect, useRef } from "react";
import styles from "./CtaBtnLongShadowRetro.module.scss";
export type CtaBtnLongShadowRetroProps = {
color?: string;
};
export default function CtaBtnLongShadowRetro({
color = "#0a4fe8",
}: CtaBtnLongShadowRetroProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector<HTMLButtonElement>(`.${styles.btn}`);
if (!btn) return;
// 계산 겹: 그림자 길이 값을 매 프레임 목표로 선형 보간(0.28)해 부드럽게 좇고,
// 그 길이만큼 45도 방향(dx=dy=1px)으로 레이어를 쌓아 box-shadow 문자열을 만든다.
const MAXLEN = 22;
const C_NEAR: [number, number, number] = [47, 109, 246];
const C_FAR: [number, number, number] = [23, 20, 26];
let len = 0;
let target = 0;
let raf: number | null = null;
function lerp(a: number, b: number, t: number) { return a + (b - a) * t; }
function buildShadow(l: number) {
const n = Math.round(l);
if (n <= 0) return "none";
const parts: string[] = [];
for (let i = 1; i <= n; i++) {
const t = i / n;
const r = Math.round(lerp(C_NEAR[0], C_FAR[0], t));
const g = Math.round(lerp(C_NEAR[1], C_FAR[1], t));
const b = Math.round(lerp(C_NEAR[2], C_FAR[2], t));
parts.push(i + "px " + i + "px 0 rgb(" + r + "," + g + "," + b + ")");
}
return parts.join(",");
}
function step() {
const diff = target - len;
len += diff * 0.28;
if (Math.abs(diff) < 0.05) { len = target; }
btn!.style.boxShadow = buildShadow(len);
if (Math.abs(target - len) > 0.05) {
raf = requestAnimationFrame(step);
} else {
raf = null;
}
}
function go(to: number) {
target = to;
if (raf === null) { raf = requestAnimationFrame(step); }
}
function onEnter() { stopAutoplay(); go(MAXLEN); }
function onLeave() { go(0); }
btn.addEventListener("mouseenter", onEnter);
btn.addEventListener("mouseleave", onLeave);
btn.addEventListener("focus", onEnter);
btn.addEventListener("blur", onLeave);
let hovered = false;
let autoplayTimer: number | null = null;
function autoplayTick() { hovered = !hovered; go(hovered ? MAXLEN : 0); }
function stopAutoplay() { if (autoplayTimer !== null) { window.clearInterval(autoplayTimer); autoplayTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoplayTimer = window.setInterval(autoplayTick, 700); }
btn.style.boxShadow = "none";
return () => {
stopAutoplay();
if (raf !== null) { cancelAnimationFrame(raf); }
btn.removeEventListener("mouseenter", onEnter);
btn.removeEventListener("mouseleave", onLeave);
btn.removeEventListener("focus", onEnter);
btn.removeEventListener("blur", onLeave);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.panel}>
<button className={styles.btn} type="button" style={{ background: color }}>
지금 시작하기
</button>
<span className={styles.hint}>호버하면 그림자 길이가 늘어납니다</span>
</div>
</div>
);
}01 · ロングシャドウ・レトロ — ホバー状態に応じて伸びる影の長さの値が変わるボタン — 計測 累積面積 2.695% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレームのうち01は8フレームで動き、累積面積2.70%・平均強度166.3を記録しています — 見えている変化は1pxずつずれた影の層が近い色から遠い色へ塗り替わっていく積み重ねで、step()内のdiff * 0.28という滑らかさがあるからこそ、影の長さは一気に飛ぶのではなく目標値へ緩やかに近づいていきます。
function buildShadow(l) {
var n = Math.round(l);
if (n <= 0) { return 'none'; }
var parts = [];
for (var i = 1; i <= n; i++) {
var t = i / n;
var r = Math.round(lerp(C_NEAR[0], C_FAR[0], t));
var g = Math.round(lerp(C_NEAR[1], C_FAR[1], t));
var b = Math.round(lerp(C_NEAR[2], C_FAR[2], t));
parts.push(i + 'px ' + i + 'px 0 rgb(' + r + ',' + g + ',' + b + ')');
}
return parts.join(',');
}
function step() {
var diff = target - len;
len += diff * 0.28;
if (Math.abs(diff) < 0.05) { len = target; }
btn.style.boxShadow = buildShadow(len);
if (Math.abs(target - len) > 0.05) {
raf = requestAnimationFrame(step);
} else {
raf = null;
}
}
02ずれ分離 — 重なったテキスト層の位置の値が一瞬ずれてから再び重なるボタン
ホバー状態になった瞬間、重なった2つのテキスト層の位置の値が決まった幅だけ一瞬ずれ、決まった時間が過ぎると再び同じ位置の値に重なります。

<main class="stage">
<div class="ctagls is-demo" id="ctagls">
<div class="ctagls__panel">
<button class="ctagls__btn" id="ctaglsBtn" type="button">
<span class="ctagls__layer ctagls__layer--a" id="ctaglsA">限定版を購入</span>
<span class="ctagls__layer ctagls__layer--b" id="ctaglsB" aria-hidden="true">限定版を購入</span>
</button>
<span class="ctagls__hint">ホバーするとテキスト層が一瞬ずれます</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(시드 난수 어긋남)은 JS 에 있다 — 여기는 정적 배치·색만.
@include stage($stage-ink);
.stage { position: relative; }
.ctagls__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
padding: $sp-5;
border-radius: $r-card;
background: $tick-panel;
}
.ctagls__btn {
position: relative;
border: 0;
border-radius: $r-control;
background: $subject-pink;
padding: $sp-4 $sp-5;
cursor: pointer;
overflow: hidden;
}
.ctagls__layer {
display: block;
font-family: inherit;
font-weight: 700;
font-size: 16px;
color: $subject-ink;
white-space: nowrap;
}
.ctagls__layer--b {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: $subject-blue-light;
mix-blend-mode: screen;
}
.ctagls__hint {
color: $tick-mute;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctagls__panel { padding: $sp-4; gap: $sp-3; }
.ctagls__btn { padding: $sp-3 $sp-4; }
.ctagls__layer { font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.ctagls__layer { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.ctagls__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 20px;
border-radius: 16px;
background: #231f28;
}
.ctagls__btn {
position: relative;
border: 0;
border-radius: 12px;
background: #ff9ebb;
padding: 16px 20px;
cursor: pointer;
overflow: hidden;
}
.ctagls__layer {
display: block;
font-family: inherit;
font-weight: 700;
font-size: 16px;
color: #17141a;
white-space: nowrap;
}
.ctagls__layer--b {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #5b8cf8;
mix-blend-mode: screen;
}
.ctagls__hint {
color: #9a93a6;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctagls__panel {
padding: 16px;
gap: 12px;
}
.ctagls__btn {
padding: 12px 16px;
}
.ctagls__layer {
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.ctagls__layer {
transition: none !important;
}
}(function () {
var root = document.getElementById('ctagls');
var btn = document.getElementById('ctaglsBtn');
var layerA = document.getElementById('ctaglsA');
var layerB = document.getElementById('ctaglsB');
// 계산 겹: mulberry32 시드 난수로 어긋남 폭을 매 틱 정하고, 정해진 시간(320ms) 동안
// TICK 간격으로 흔들다가 마지막엔 반드시 0 으로 되돌린다 — 재생마다 같은 시드로 같은 패턴.
var SEED = 20260920;
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rng = mulberry32(SEED);
var DURATION = 320, TICK = 40;
var glitchTimer = null;
function runGlitch() {
if (glitchTimer) { return; }
var elapsed = 0;
glitchTimer = setInterval(function () {
elapsed += TICK;
if (elapsed >= DURATION) {
clearInterval(glitchTimer); glitchTimer = null;
layerA.style.transform = 'translate(0px,0px)';
layerB.style.transform = 'translate(0px,0px)';
return;
}
var ax = (rng() - 0.5) * 10;
var ay = (rng() - 0.5) * 4;
var bx = (rng() - 0.5) * 10;
var by = (rng() - 0.5) * 4;
layerA.style.transform = 'translate(' + ax.toFixed(1) + 'px,' + ay.toFixed(1) + 'px)';
layerB.style.transform = 'translate(' + bx.toFixed(1) + 'px,' + by.toFixed(1) + 'px)';
}, TICK);
}
function wireReal() {
btn.addEventListener('mouseenter', function () { root.classList.remove('is-demo'); stopAutoplay(); runGlitch(); });
btn.addEventListener('focus', function () { root.classList.remove('is-demo'); stopAutoplay(); runGlitch(); });
btn.addEventListener('click', function () { runGlitch(); });
}
wireReal();
var autoplayTimer = null;
function stopAutoplay() { if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoplayTimer = setInterval(runGlitch, 900); }
})();import { useEffect, useRef } from "react";
import styles from "./CtaBtnGlitchSplit.module.scss";
export type CtaBtnGlitchSplitProps = {
color?: string;
};
export default function CtaBtnGlitchSplit({
color = "#5b8cf8",
}: CtaBtnGlitchSplitProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector<HTMLButtonElement>(`.${styles.btn}`);
const layerA = root.querySelector<HTMLSpanElement>(`.${styles.layerA}`);
const layerB = root.querySelector<HTMLSpanElement>(`.${styles.layerB}`);
if (!btn || !layerA || !layerB) return;
// 계산 겹: mulberry32 시드 난수로 어긋남 폭을 매 틱 정하고, 정해진 시간(320ms) 동안
// TICK 간격으로 흔들다가 마지막엔 반드시 0 으로 되돌린다 — 재생마다 같은 시드로 같은 패턴.
const SEED = 20260920;
function mulberry32(a: number) {
return function () {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rng = mulberry32(SEED);
const DURATION = 320, TICK = 40;
let glitchTimer: number | null = null;
function runGlitch() {
if (glitchTimer !== null) return;
let elapsed = 0;
glitchTimer = window.setInterval(() => {
elapsed += TICK;
if (elapsed >= DURATION) {
if (glitchTimer !== null) { window.clearInterval(glitchTimer); glitchTimer = null; }
layerA!.style.transform = "translate(0px,0px)";
layerB!.style.transform = "translate(0px,0px)";
return;
}
const ax = (rng() - 0.5) * 10;
const ay = (rng() - 0.5) * 4;
const bx = (rng() - 0.5) * 10;
const by = (rng() - 0.5) * 4;
layerA!.style.transform = "translate(" + ax.toFixed(1) + "px," + ay.toFixed(1) + "px)";
layerB!.style.transform = "translate(" + bx.toFixed(1) + "px," + by.toFixed(1) + "px)";
}, TICK);
}
function onEnter() { stopAutoplay(); runGlitch(); }
function onClick() { runGlitch(); }
btn.addEventListener("mouseenter", onEnter);
btn.addEventListener("focus", onEnter);
btn.addEventListener("click", onClick);
let autoplayTimer: number | null = null;
function stopAutoplay() { if (autoplayTimer !== null) { window.clearInterval(autoplayTimer); autoplayTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoplayTimer = window.setInterval(runGlitch, 900); }
return () => {
stopAutoplay();
if (glitchTimer !== null) { window.clearInterval(glitchTimer); }
btn.removeEventListener("mouseenter", onEnter);
btn.removeEventListener("focus", onEnter);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.panel}>
<button className={styles.btn} type="button">
<span className={styles.layerA}>한정판 구매</span>
<span className={styles.layerB} aria-hidden="true" style={{ color }}>한정판 구매</span>
</button>
<span className={styles.hint}>호버하면 텍스트 층이 잠깐 어긋납니다</span>
</div>
</div>
);
}02 · ずれ分離 — 重なったテキスト層の位置の値が一瞬ずれてから再び重なるボタン — 計測 累積面積 0.914% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレームのうち02は7フレームで動き、累積面積0.91%・平均強度115.5を記録しています — runGlitch()は種を固定したmulberry32()の乱数をTICK(40ms)ごとに読み取りDURATION(320ms)に達するまで続けるので、同じSEEDであれば毎回のホバーで同じずれの並びが再現され、ホバーのたびに違う崩れ方にはなりません。
function runGlitch() {
if (glitchTimer) { return; }
var elapsed = 0;
glitchTimer = setInterval(function () {
elapsed += TICK;
if (elapsed >= DURATION) {
clearInterval(glitchTimer); glitchTimer = null;
layerA.style.transform = 'translate(0px,0px)';
layerB.style.transform = 'translate(0px,0px)';
return;
}
var ax = (rng() - 0.5) * 10;
var ay = (rng() - 0.5) * 4;
var bx = (rng() - 0.5) * 10;
var by = (rng() - 0.5) * 4;
layerA.style.transform = 'translate(' + ax.toFixed(1) + 'px,' + ay.toFixed(1) + 'px)';
layerB.style.transform = 'translate(' + bx.toFixed(1) + 'px,' + by.toFixed(1) + 'px)';
}, TICK);
}
03破片発散 — クリック地点から複数の破片がそれぞれ異なる角度の値で散ってから消えるボタン
クリックが起きた地点を基準に複数の破片がそれぞれ異なる角度の値と速度の値で広がり、透明度の値が時間とともに下がって消えた状態になります。

<main class="stage">
<div class="ctacfb is-demo" id="ctacfb">
<div class="ctacfb__panel">
<div class="ctacfb__wrap">
<canvas class="ctacfb__canvas" id="ctacfbCanvas" width="220" height="120"></canvas>
<button class="ctacfb__btn" id="ctacfbBtn" type="button">購入完了</button>
</div>
<span class="ctacfb__hint">クリックすると破片が四方に散ります</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(중력·감쇠·회전)은 JS 캔버스에 있다 — 여기는 정적 배치만.
@include stage($stage-orange);
.stage { position: relative; }
.ctacfb__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
padding: $sp-5;
border-radius: $r-card;
background: $stage-paper;
}
.ctacfb__wrap {
position: relative;
width: 220px;
height: 120px;
display: grid;
place-items: center;
}
.ctacfb__canvas {
position: absolute;
inset: 0;
pointer-events: none;
}
.ctacfb__btn {
position: relative;
z-index: 1;
border: 0;
border-radius: $r-control;
background: $subject-blue-deep;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 15px;
padding: $sp-3 $sp-5;
cursor: pointer;
}
.ctacfb__hint {
color: rgba($subject-ink, .72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctacfb__panel { padding: $sp-4; gap: $sp-2; }
.ctacfb__wrap { width: 180px; height: 100px; }
.ctacfb__btn { padding: $sp-2 $sp-4; font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.ctacfb__btn { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.ctacfb__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
border-radius: 16px;
background: #ffffff;
}
.ctacfb__wrap {
position: relative;
width: 220px;
height: 120px;
display: grid;
place-items: center;
}
.ctacfb__canvas {
position: absolute;
inset: 0;
pointer-events: none;
}
.ctacfb__btn {
position: relative;
z-index: 1;
border: 0;
border-radius: 12px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 15px;
padding: 12px 20px;
cursor: pointer;
}
.ctacfb__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctacfb__panel {
padding: 16px;
gap: 8px;
}
.ctacfb__wrap {
width: 180px;
height: 100px;
}
.ctacfb__btn {
padding: 8px 16px;
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.ctacfb__btn {
transition: none !important;
}
}(function () {
var root = document.getElementById('ctacfb');
var btn = document.getElementById('ctacfbBtn');
var canvas = document.getElementById('ctacfbCanvas');
var ctx = canvas.getContext('2d');
var COLORS = ['#5b8cf8', '#ff9ebb', '#4cd4a6', '#b9a0ff'];
// 계산 겹: 조각마다 각도·속도 값을 난수로 부여하고, 매 프레임 중력(GRAVITY)을 속도에
// 더하고 속도에 감쇠(DRAG)를 곱해 궤적을 계산한다. 회전 값도 각자 다른 속도로 누적하고
// 투명도 값은 남은 수명 비율로 계산해 사라진다.
var GRAVITY = 420, DRAG = 0.985, LIFE = 900;
var particles = [];
var raf = null, lastT = 0;
function burst() {
var cx = canvas.width / 2, cy = canvas.height * 0.35;
particles = [];
for (var i = 0; i < 18; i++) {
var angle = (Math.PI * 2 * i) / 18 + (Math.random() - 0.5) * 0.5;
var speed = 90 + Math.random() * 110;
particles.push({
x: cx, y: cy,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 60,
rot: Math.random() * Math.PI,
vrot: (Math.random() - 0.5) * 10,
color: COLORS[i % COLORS.length],
age: 0
});
}
if (!raf) { lastT = performance.now(); raf = requestAnimationFrame(tick); }
}
function tick(now) {
var dt = Math.min((now - lastT) / 1000, 0.05);
lastT = now;
ctx.clearRect(0, 0, canvas.width, canvas.height);
var alive = false;
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
p.age += dt * 1000;
if (p.age >= LIFE) { continue; }
alive = true;
p.vy += GRAVITY * dt;
p.vx *= DRAG; p.vy *= DRAG;
p.x += p.vx * dt; p.y += p.vy * dt;
p.rot += p.vrot * dt;
var op = Math.max(0, 1 - p.age / LIFE);
ctx.save();
ctx.globalAlpha = op;
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillStyle = p.color;
ctx.fillRect(-4, -3, 8, 6);
ctx.restore();
}
if (alive) {
raf = requestAnimationFrame(tick);
} else {
raf = null;
}
}
function wireReal() {
btn.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); burst(); });
}
wireReal();
var autoplayTimer = null;
function stopAutoplay() { if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoplayTimer = setInterval(burst, 1100); }
})();import { useEffect, useRef } from "react";
import styles from "./CtaBtnConfettiBurst.module.scss";
type Particle = {
x: number; y: number; vx: number; vy: number;
rot: number; vrot: number; color: string; age: number;
};
export type CtaBtnConfettiBurstProps = {
color?: string;
};
export default function CtaBtnConfettiBurst({
color = "#0a4fe8",
}: CtaBtnConfettiBurstProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector<HTMLButtonElement>(`.${styles.btn}`);
const canvas = root.querySelector<HTMLCanvasElement>(`.${styles.canvas}`);
if (!btn || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const COLORS = ["#5b8cf8", "#ff9ebb", "#4cd4a6", "#b9a0ff"];
// 계산 겹: 조각마다 각도·속도 값을 난수로 부여하고, 매 프레임 중력(GRAVITY)을 속도에
// 더하고 속도에 감쇠(DRAG)를 곱해 궤적을 계산한다. 회전 값도 각자 다른 속도로 누적하고
// 투명도 값은 남은 수명 비율로 계산해 사라진다.
const GRAVITY = 420, DRAG = 0.985, LIFE = 900;
let particles: Particle[] = [];
let raf: number | null = null;
let lastT = 0;
function burst() {
const cx = canvas!.width / 2, cy = canvas!.height * 0.35;
particles = [];
for (let i = 0; i < 18; i++) {
const angle = (Math.PI * 2 * i) / 18 + (Math.random() - 0.5) * 0.5;
const speed = 90 + Math.random() * 110;
particles.push({
x: cx, y: cy,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 60,
rot: Math.random() * Math.PI,
vrot: (Math.random() - 0.5) * 10,
color: COLORS[i % COLORS.length],
age: 0,
});
}
if (raf === null) { lastT = performance.now(); raf = requestAnimationFrame(tick); }
}
function tick(now: number) {
const dt = Math.min((now - lastT) / 1000, 0.05);
lastT = now;
ctx!.clearRect(0, 0, canvas!.width, canvas!.height);
let alive = false;
for (const p of particles) {
p.age += dt * 1000;
if (p.age >= LIFE) continue;
alive = true;
p.vy += GRAVITY * dt;
p.vx *= DRAG; p.vy *= DRAG;
p.x += p.vx * dt; p.y += p.vy * dt;
p.rot += p.vrot * dt;
const op = Math.max(0, 1 - p.age / LIFE);
ctx!.save();
ctx!.globalAlpha = op;
ctx!.translate(p.x, p.y);
ctx!.rotate(p.rot);
ctx!.fillStyle = p.color;
ctx!.fillRect(-4, -3, 8, 6);
ctx!.restore();
}
raf = alive ? requestAnimationFrame(tick) : null;
}
function onClick() { stopAutoplay(); burst(); }
btn.addEventListener("click", onClick);
let autoplayTimer: number | null = null;
function stopAutoplay() { if (autoplayTimer !== null) { window.clearInterval(autoplayTimer); autoplayTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoplayTimer = window.setInterval(burst, 1100); }
return () => {
stopAutoplay();
if (raf !== null) { cancelAnimationFrame(raf); }
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.panel}>
<div className={styles.wrap}>
<canvas className={styles.canvas} width={220} height={120} />
<button className={styles.btn} type="button" style={{ background: color }}>구매 완료</button>
</div>
<span className={styles.hint}>클릭하면 조각이 사방으로 흩어집니다</span>
</div>
</div>
);
}03 · 破片発散 — クリック地点から複数の破片がそれぞれ異なる角度の値で散ってから消えるボタン — 計測 累積面積 3.59% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレームのうち03は15フレームで動き、累積面積3.59%・平均強度62.8を記録しています — burst()はクリック地点から18個の破片をそれぞれ異なる角度と速度で撃ち出し、tick()は毎フレームGRAVITY(420)とDRAG(0.985)を各破片の速度へ積み上げてLIFE(900ms)が尽きるまで消えていく様子を描いています。
function burst() {
var cx = canvas.width / 2, cy = canvas.height * 0.35;
particles = [];
for (var i = 0; i < 18; i++) {
var angle = (Math.PI * 2 * i) / 18 + (Math.random() - 0.5) * 0.5;
var speed = 90 + Math.random() * 110;
particles.push({
x: cx, y: cy,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 60,
rot: Math.random() * Math.PI,
vrot: (Math.random() - 0.5) * 10,
color: COLORS[i % COLORS.length],
age: 0
});
}
if (!raf) { lastT = performance.now(); raf = requestAnimationFrame(tick); }
}
04周囲進行 — ボタンを囲む周囲の進行の値が決まった時間の間に減っていくボタン
ボタンを囲む周囲の進行の値は決まった時間の間に最初の値から0まで減っていき、その値が0になった状態になると決まった動作に移ります。

<main class="stage">
<div class="ctaprc is-demo" id="ctaprc">
<div class="ctaprc__panel">
<div class="ctaprc__wrap">
<svg class="ctaprc__ring" viewBox="0 0 84 84" id="ctaprcSvg">
<circle class="ctaprc__track" cx="42" cy="42" r="38"></circle>
<circle class="ctaprc__bar" id="ctaprcBar" cx="42" cy="42" r="38"></circle>
</svg>
<button class="ctaprc__btn" id="ctaprcBtn" type="button">次のステップ</button>
</div>
<span class="ctaprc__hint">クリックすると1.5秒後に自動で次のステップに進みます</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(둘레·dashoffset 산술)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-yellow);
.stage { position: relative; }
.ctaprc__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
padding: $sp-5;
border-radius: $r-card;
background: $stage-paper;
}
.ctaprc__wrap {
position: relative;
width: 92px;
height: 92px;
display: grid;
place-items: center;
}
.ctaprc__ring {
position: absolute;
inset: 0;
width: 92px;
height: 92px;
transform: rotate(-90deg);
}
.ctaprc__track {
fill: none;
stroke: rgba($subject-ink, .12);
stroke-width: 6;
}
.ctaprc__bar {
fill: none;
stroke: $subject-blue-deep;
stroke-width: 6;
stroke-linecap: round;
}
.ctaprc__btn {
position: relative;
border: 0;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
width: 68px;
height: 68px;
cursor: pointer;
}
.ctaprc__hint {
color: rgba($subject-ink, .72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctaprc__panel { padding: $sp-4; gap: $sp-3; }
.ctaprc__wrap { width: 76px; height: 76px; }
.ctaprc__ring { width: 76px; height: 76px; }
.ctaprc__btn { width: 56px; height: 56px; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.ctaprc__ring { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.ctaprc__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 20px;
border-radius: 16px;
background: #ffffff;
}
.ctaprc__wrap {
position: relative;
width: 92px;
height: 92px;
display: grid;
place-items: center;
}
.ctaprc__ring {
position: absolute;
inset: 0;
width: 92px;
height: 92px;
transform: rotate(-90deg);
}
.ctaprc__track {
fill: none;
stroke: rgba(23, 20, 26, 0.12);
stroke-width: 6;
}
.ctaprc__bar {
fill: none;
stroke: #0a4fe8;
stroke-width: 6;
stroke-linecap: round;
}
.ctaprc__btn {
position: relative;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
width: 68px;
height: 68px;
cursor: pointer;
}
.ctaprc__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctaprc__panel {
padding: 16px;
gap: 12px;
}
.ctaprc__wrap {
width: 76px;
height: 76px;
}
.ctaprc__ring {
width: 76px;
height: 76px;
}
.ctaprc__btn {
width: 56px;
height: 56px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.ctaprc__ring {
transition: none !important;
}
}(function () {
var root = document.getElementById('ctaprc');
var btn = document.getElementById('ctaprcBtn');
var bar = document.getElementById('ctaprcBar');
// 계산 겹: 반지름(r)으로 둘레(circumference = 2*PI*r)를 구하고, 남은 시간 비율(progress)을
// stroke-dashoffset = circumference * (1 - progress) 산술로 매 프레임 다시 그린다.
var R = 38;
var CIRC = 2 * Math.PI * R;
bar.style.strokeDasharray = CIRC.toFixed(2);
var DURATION = 1500;
var raf = null, startT = 0, running = false;
function frame(now) {
var t = now - startT;
var progress = Math.min(1, t / DURATION);
var offset = CIRC * (1 - progress);
bar.style.strokeDashoffset = offset.toFixed(2);
if (progress < 1) {
raf = requestAnimationFrame(frame);
} else {
running = false; raf = null;
btn.textContent = '完了しました';
setTimeout(function () { btn.textContent = '次のステップ'; bar.style.strokeDashoffset = CIRC.toFixed(2); }, 500);
}
}
function startCountdown() {
if (running) { return; }
running = true;
startT = performance.now();
raf = requestAnimationFrame(frame);
}
function wireReal() {
btn.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); startCountdown(); });
}
wireReal();
bar.style.strokeDashoffset = CIRC.toFixed(2);
var autoplayTimer = null;
function stopAutoplay() { if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { startCountdown(); autoplayTimer = setInterval(startCountdown, 2000); }
})();import { useEffect, useRef } from "react";
import styles from "./CtaBtnProgressRingCountdown.module.scss";
export type CtaBtnProgressRingCountdownProps = {
color?: string;
};
export default function CtaBtnProgressRingCountdown({
color = "#0a4fe8",
}: CtaBtnProgressRingCountdownProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector<HTMLButtonElement>(`.${styles.btn}`);
const bar = root.querySelector<SVGCircleElement>(`.${styles.bar}`);
if (!btn || !bar) return;
// 계산 겹: 반지름(r)으로 둘레(circumference = 2*PI*r)를 구하고, 남은 시간 비율(progress)을
// stroke-dashoffset = circumference * (1 - progress) 산술로 매 프레임 다시 그린다.
const R = 38;
const CIRC = 2 * Math.PI * R;
bar.style.strokeDasharray = CIRC.toFixed(2);
bar.style.strokeDashoffset = CIRC.toFixed(2);
const DURATION = 1500;
let raf: number | null = null;
let startT = 0;
let running = false;
function frame(now: number) {
const t = now - startT;
const progress = Math.min(1, t / DURATION);
const offset = CIRC * (1 - progress);
bar!.style.strokeDashoffset = offset.toFixed(2);
if (progress < 1) {
raf = requestAnimationFrame(frame);
} else {
running = false; raf = null;
btn!.textContent = "완료됨";
window.setTimeout(() => { btn!.textContent = "다음 단계"; bar!.style.strokeDashoffset = CIRC.toFixed(2); }, 500);
}
}
function startCountdown() {
if (running) return;
running = true;
startT = performance.now();
raf = requestAnimationFrame(frame);
}
function onClick() { stopAutoplay(); startCountdown(); }
btn.addEventListener("click", onClick);
let autoplayTimer: number | null = null;
function stopAutoplay() { if (autoplayTimer !== null) { window.clearInterval(autoplayTimer); autoplayTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { startCountdown(); autoplayTimer = window.setInterval(startCountdown, 2000); }
return () => {
stopAutoplay();
if (raf !== null) { cancelAnimationFrame(raf); }
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.panel}>
<div className={styles.wrap}>
<svg className={styles.ring} viewBox="0 0 84 84">
<circle className={styles.track} cx={42} cy={42} r={38} />
<circle className={styles.bar} cx={42} cy={42} r={38} style={{ stroke: color }} />
</svg>
<button className={styles.btn} type="button" style={{ background: color }}>다음 단계</button>
</div>
<span className={styles.hint}>클릭하면 1.5초 뒤 자동으로 다음 단계로 넘어갑니다</span>
</div>
</div>
);
}04 · 周囲進行 — ボタンを囲む周囲の進行の値が決まった時間の間に減っていくボタン — 計測 累積面積 1.598% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレームのうち04は20フレームで動き、累積面積1.60%・平均強度150.9を記録しています — frame()は経過時間をDURATION(1500ms)で割った0から1の進行比率をリングのstrokeDashoffsetに直接反映させて円周から0まで減らしていき、0に達した瞬間に下の完了ラベルへ切り替わります。
function frame(now) {
var t = now - startT;
var progress = Math.min(1, t / DURATION);
var offset = CIRC * (1 - progress);
bar.style.strokeDashoffset = offset.toFixed(2);
if (progress < 1) {
raf = requestAnimationFrame(frame);
} else {
running = false; raf = null;
btn.textContent = '完了しました';
setTimeout(function () { btn.textContent = '次のステップ'; bar.style.strokeDashoffset = CIRC.toFixed(2); }, 500);
}
}
function startCountdown() {
if (running) { return; }
running = true;
startT = performance.now();
raf = requestAnimationFrame(frame);
}
05立体反転 — クリックすると回転の値が180度まで回り反対の面が現れるボタン
クリックが起きると回転の値が180度になるまで回って反対の面が現れ、回転の値が目標を少し超えてから元の位置に戻る状態を経ます。

<main class="stage">
<div class="ctaflp is-demo" id="ctaflp">
<div class="ctaflp__panel">
<div class="ctaflp__scene">
<button class="ctaflp__btn" id="ctaflpBtn" type="button">
<span class="ctaflp__face ctaflp__face--front">この商品</span>
<span class="ctaflp__face ctaflp__face--back">あの商品</span>
</button>
</div>
<span class="ctaflp__hint">クリックするたびに反対の面に反転します</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(오버슈트 감쇠 진동)은 JS 에 있다 — 여기는 정적 배치·원근만.
@include stage($stage-orange);
.stage { position: relative; }
.ctaflp__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
padding: $sp-5;
border-radius: $r-card;
background: $stage-paper;
}
.ctaflp__scene {
perspective: 480px;
}
.ctaflp__btn {
position: relative;
width: 140px;
height: 52px;
border: 0;
border-radius: $r-control;
background: none;
padding: 0;
cursor: pointer;
transform-style: preserve-3d;
}
.ctaflp__face {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: $r-control;
font-family: inherit;
font-weight: 700;
font-size: 15px;
backface-visibility: hidden;
}
.ctaflp__face--front {
background: $subject-blue-deep;
color: $subject-cream;
}
.ctaflp__face--back {
background: $subject-lilac;
color: $subject-ink;
transform: rotateY(180deg);
}
.ctaflp__hint {
color: rgba($subject-ink, .72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctaflp__panel { padding: $sp-4; gap: $sp-3; }
.ctaflp__btn { width: 120px; height: 46px; }
.ctaflp__face { font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.ctaflp__btn { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.ctaflp__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 20px;
border-radius: 16px;
background: #ffffff;
}
.ctaflp__scene {
perspective: 480px;
}
.ctaflp__btn {
position: relative;
width: 140px;
height: 52px;
border: 0;
border-radius: 12px;
background: none;
padding: 0;
cursor: pointer;
transform-style: preserve-3d;
}
.ctaflp__face {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 12px;
font-family: inherit;
font-weight: 700;
font-size: 15px;
backface-visibility: hidden;
}
.ctaflp__face--front {
background: #0a4fe8;
color: #fff7e6;
}
.ctaflp__face--back {
background: #b9a0ff;
color: #17141a;
transform: rotateY(180deg);
}
.ctaflp__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctaflp__panel {
padding: 16px;
gap: 12px;
}
.ctaflp__btn {
width: 120px;
height: 46px;
}
.ctaflp__face {
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.ctaflp__btn {
transition: none !important;
}
}(function () {
var root = document.getElementById('ctaflp');
var btn = document.getElementById('ctaflpBtn');
// 계산 겹: 회전 값이 목표(180도 배수)를 향해 감쇠 진동식으로 접근한다 —
// 매 프레임 목표를 살짝 넘었다가(오버슈트) 스프링 곡선으로 제자리에 앉는다.
var current = 0, base = 0, raf = null, startT = 0;
var OVERSHOOT_DEG = 18, FREQ = 14, DECAY = 9, DUR = 500;
function ease(tSec) {
if (tSec <= 0) { return 0; }
var settle = 1 - Math.exp(-DECAY * tSec);
var wobble = Math.sin(tSec * FREQ) * Math.exp(-DECAY * tSec) * (OVERSHOOT_DEG / 180);
return settle + wobble;
}
function frame(now) {
var t = (now - startT) / 1000;
var p = Math.min(1, ease(t));
current = base + p * 180;
btn.style.transform = 'rotateY(' + current.toFixed(2) + 'deg)';
if (t < DUR / 1000) {
raf = requestAnimationFrame(frame);
} else {
base = base + 180;
current = base;
btn.style.transform = 'rotateY(' + current.toFixed(2) + 'deg)';
raf = null;
}
}
function flip() {
if (raf) { return; }
startT = performance.now();
raf = requestAnimationFrame(frame);
}
function wireReal() {
btn.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); flip(); });
btn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { root.classList.remove('is-demo'); stopAutoplay(); flip(); }
});
}
wireReal();
var autoplayTimer = null;
function stopAutoplay() { if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoplayTimer = setInterval(flip, 700); }
})();import { useEffect, useRef } from "react";
import styles from "./CtaBtnFlip3dFace.module.scss";
export type CtaBtnFlip3dFaceProps = {
color?: string;
};
export default function CtaBtnFlip3dFace({
color = "#0a4fe8",
}: CtaBtnFlip3dFaceProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector<HTMLButtonElement>(`.${styles.btn}`);
if (!btn) return;
// 계산 겹: 회전 값이 목표(180도 배수)를 향해 감쇠 진동식으로 접근한다 —
// 매 프레임 목표를 살짝 넘었다가(오버슈트) 스프링 곡선으로 제자리에 앉는다.
let base = 0;
let raf: number | null = null;
let startT = 0;
const OVERSHOOT_DEG = 18, FREQ = 14, DECAY = 9, DUR = 500;
function ease(tSec: number) {
if (tSec <= 0) return 0;
const settle = 1 - Math.exp(-DECAY * tSec);
const wobble = Math.sin(tSec * FREQ) * Math.exp(-DECAY * tSec) * (OVERSHOOT_DEG / 180);
return settle + wobble;
}
function frame(now: number) {
const t = (now - startT) / 1000;
const p = Math.min(1, ease(t));
const current = base + p * 180;
btn!.style.transform = "rotateY(" + current.toFixed(2) + "deg)";
if (t < DUR / 1000) {
raf = requestAnimationFrame(frame);
} else {
base = base + 180;
btn!.style.transform = "rotateY(" + base.toFixed(2) + "deg)";
raf = null;
}
}
function flip() {
if (raf !== null) return;
startT = performance.now();
raf = requestAnimationFrame(frame);
}
function onClick() { stopAutoplay(); flip(); }
function onKeydown(e: KeyboardEvent) { if (e.key === "Enter" || e.key === " ") { stopAutoplay(); flip(); } }
btn.addEventListener("click", onClick);
btn.addEventListener("keydown", onKeydown);
let autoplayTimer: number | null = null;
function stopAutoplay() { if (autoplayTimer !== null) { window.clearInterval(autoplayTimer); autoplayTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoplayTimer = window.setInterval(flip, 700); }
return () => {
stopAutoplay();
if (raf !== null) { cancelAnimationFrame(raf); }
btn.removeEventListener("click", onClick);
btn.removeEventListener("keydown", onKeydown);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.panel}>
<div className={styles.scene}>
<button className={styles.btn} type="button">
<span className={styles.faceFront} style={{ background: color }}>이 상품</span>
<span className={styles.faceBack}>저 상품</span>
</button>
</div>
<span className={styles.hint}>클릭할 때마다 반대 면으로 뒤집힙니다</span>
</div>
</div>
);
}05 · 立体反転 — クリックすると回転の値が180度まで回り反対の面が現れるボタン — 計測 累積面積 5.267% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレームのうち05は16フレームで動き、累積面積5.27%・平均強度110.7を記録しています — ease()は経過秒数をOVERSHOOT_DEG(18)で拡大しFREQ(14)の周波数で振動しながらDECAY(9)で減衰するサイン項に代入しており、固定のDUR(500ms)の中で面のrotateY()の値が180度を超えてから落ち着きます。
function ease(tSec) {
if (tSec <= 0) { return 0; }
var settle = 1 - Math.exp(-DECAY * tSec);
var wobble = Math.sin(tSec * FREQ) * Math.exp(-DECAY * tSec) * (OVERSHOOT_DEG / 180);
return settle + wobble;
}
function frame(now) {
var t = (now - startT) / 1000;
var p = Math.min(1, ease(t));
current = base + p * 180;
btn.style.transform = 'rotateY(' + current.toFixed(2) + 'deg)';
if (t < DUR / 1000) {
raf = requestAnimationFrame(frame);
} else {
base = base + 180;
current = base;
btn.style.transform = 'rotateY(' + current.toFixed(2) + 'deg)';
raf = null;
}
}
06スライド確定 — 引いた位置の値が基準を超えると確定状態になるボタン
引いた位置の値が決まった基準の値を超えた状態になると確定した状態に変わり、基準に届かないまま離された状態になると位置の値は最初の場所に戻ります。

<main class="stage">
<div class="ctadcs is-demo" id="ctadcs">
<div class="ctadcs__panel">
<div class="ctadcs__track" id="ctadcsTrack">
<span class="ctadcs__trackLabel">スライドして決済確定</span>
<button class="ctadcs__thumb" id="ctadcsThumb" type="button" aria-label="スライドして確定"></button>
</div>
<span class="ctadcs__hint" id="ctadcsHint">基準(70%)を超えないと確定しません</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(임계 판정·스프링 복귀)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-yellow);
.stage { position: relative; }
.ctadcs__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
padding: $sp-5;
border-radius: $r-card;
background: $stage-paper;
}
.ctadcs__track {
position: relative;
width: 220px;
height: 48px;
border-radius: $r-pill;
background: $subject-cream;
display: flex;
align-items: center;
padding: $sp-1;
}
.ctadcs__trackLabel {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: rgba($subject-ink, .5);
font-size: 13px;
font-weight: 700;
pointer-events: none;
}
.ctadcs__thumb {
position: relative;
width: 40px;
height: 40px;
border: 0;
border-radius: $r-pill;
background: $subject-blue-deep;
cursor: grab;
touch-action: none;
}
.ctadcs__hint {
color: rgba($subject-ink, .72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctadcs__panel { padding: $sp-4; gap: $sp-2; }
.ctadcs__track { width: 180px; height: 44px; }
.ctadcs__thumb { width: 36px; height: 36px; }
}
@media (prefers-reduced-motion: reduce) {
.ctadcs__thumb { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.ctadcs__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
border-radius: 16px;
background: #ffffff;
}
.ctadcs__track {
position: relative;
width: 220px;
height: 48px;
border-radius: 999px;
background: #fff7e6;
display: flex;
align-items: center;
padding: 4px;
}
.ctadcs__trackLabel {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: rgba(23, 20, 26, 0.5);
font-size: 13px;
font-weight: 700;
pointer-events: none;
}
.ctadcs__thumb {
position: relative;
width: 40px;
height: 40px;
border: 0;
border-radius: 999px;
background: #0a4fe8;
cursor: grab;
touch-action: none;
}
.ctadcs__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctadcs__panel {
padding: 16px;
gap: 8px;
}
.ctadcs__track {
width: 180px;
height: 44px;
}
.ctadcs__thumb {
width: 36px;
height: 36px;
}
}
@media (prefers-reduced-motion: reduce) {
.ctadcs__thumb {
transition: none !important;
}
}(function () {
var root = document.getElementById('ctadcs');
var track = document.getElementById('ctadcsTrack');
var thumb = document.getElementById('ctadcsThumb');
var hint = document.getElementById('ctadcsHint');
// 계산 겹: 끄는 위치 값을 트랙 폭 대비 비율(0~1)로 clamp 하고, THRESH(0.7)를 넘겼는지로
// 확정 여부를 가른다. 기준 미달로 놓이면 목표(0)까지 매 프레임 lerp(관성 없는 스프링 복귀)한다.
var THRESH = 0.7;
var pos = 0, target = 0, raf = null, confirmed = false;
var THUMB_W = 40;
function maxTravel() { return track.clientWidth - THUMB_W - 8; }
function apply(p) {
var mt = maxTravel();
thumb.style.transform = 'translateX(' + (p * mt).toFixed(1) + 'px)';
}
function settle() {
var diff = target - pos;
pos += diff * 0.3;
if (Math.abs(diff) < 0.002) { pos = target; }
apply(pos);
if (Math.abs(target - pos) > 0.002) {
raf = requestAnimationFrame(settle);
} else {
raf = null;
}
}
function goTo(p) {
target = p;
if (raf === null) { raf = requestAnimationFrame(settle); }
}
function release(p) {
if (p >= THRESH) {
confirmed = true;
goTo(1);
hint.textContent = '確定しました';
setTimeout(function () { confirmed = false; goTo(0); hint.textContent = '基準(70%)を超えないと確定しません'; }, 500);
} else {
goTo(0);
}
}
var dragging = false, startX = 0, startPos = 0;
function onDown(clientX) {
if (confirmed) { return; }
dragging = true; startX = clientX; startPos = pos;
if (raf) { cancelAnimationFrame(raf); raf = null; }
}
function onMove(clientX) {
if (!dragging) { return; }
var mt = maxTravel();
var dp = (clientX - startX) / mt;
pos = Math.max(0, Math.min(1, startPos + dp));
apply(pos);
}
function onUp() {
if (!dragging) { return; }
dragging = false;
release(pos);
}
function wireReal() {
thumb.addEventListener('pointerdown', function (e) { root.classList.remove('is-demo'); stopAutoplay(); onDown(e.clientX); });
window.addEventListener('pointermove', function (e) { onMove(e.clientX); });
window.addEventListener('pointerup', onUp);
}
wireReal();
apply(0);
// 자동 루프: 실제 드래그 대신 목표 값을 단계적으로 옮겨 같은 release() 판정 로직을 탄다.
var autoTarget = 0, autoTimer = null;
function autoTick() {
autoTarget = autoTarget >= 1 ? 0 : Math.min(1, autoTarget + 0.35);
pos = autoTarget;
apply(pos);
if (autoTarget >= 1) { release(1); }
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 220); }
})();import { useEffect, useRef } from "react";
import styles from "./CtaBtnDragConfirmSlide.module.scss";
export type CtaBtnDragConfirmSlideProps = {
color?: string;
};
export default function CtaBtnDragConfirmSlide({
color = "#0a4fe8",
}: CtaBtnDragConfirmSlideProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const track = root.querySelector<HTMLDivElement>(`.${styles.track}`);
const thumb = root.querySelector<HTMLButtonElement>(`.${styles.thumb}`);
const hint = root.querySelector<HTMLSpanElement>(`.${styles.hint}`);
if (!track || !thumb || !hint) return;
// 계산 겹: 끄는 위치 값을 트랙 폭 대비 비율(0~1)로 clamp 하고, THRESH(0.7)를 넘겼는지로
// 확정 여부를 가른다. 기준 미달로 놓이면 목표(0)까지 매 프레임 lerp(스프링 복귀)한다.
const THRESH = 0.7;
const THUMB_W = 40;
let pos = 0, target = 0, raf: number | null = null, confirmed = false;
function maxTravel() { return track!.clientWidth - THUMB_W - 8; }
function apply(p: number) {
const mt = maxTravel();
thumb!.style.transform = "translateX(" + (p * mt).toFixed(1) + "px)";
}
function settle() {
const diff = target - pos;
pos += diff * 0.3;
if (Math.abs(diff) < 0.002) { pos = target; }
apply(pos);
raf = Math.abs(target - pos) > 0.002 ? requestAnimationFrame(settle) : null;
}
function goTo(p: number) {
target = p;
if (raf === null) { raf = requestAnimationFrame(settle); }
}
function release(p: number) {
if (p >= THRESH) {
confirmed = true;
goTo(1);
hint!.textContent = "확정되었습니다";
window.setTimeout(() => { confirmed = false; goTo(0); hint!.textContent = "기준(70%)을 넘겨야 확정됩니다"; }, 500);
} else {
goTo(0);
}
}
let dragging = false, startX = 0, startPos = 0;
function onDown(clientX: number) {
if (confirmed) return;
dragging = true; startX = clientX; startPos = pos;
if (raf !== null) { cancelAnimationFrame(raf); raf = null; }
}
function onMove(clientX: number) {
if (!dragging) return;
const mt = maxTravel();
const dp = (clientX - startX) / mt;
pos = Math.max(0, Math.min(1, startPos + dp));
apply(pos);
}
function onUp() {
if (!dragging) return;
dragging = false;
release(pos);
}
function onPointerDown(e: PointerEvent) { stopAutoplay(); onDown(e.clientX); }
function onPointerMove(e: PointerEvent) { onMove(e.clientX); }
thumb.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onUp);
apply(0);
let autoTarget = 0, autoTimer: number | null = null;
function autoTick() {
autoTarget = autoTarget >= 1 ? 0 : Math.min(1, autoTarget + 0.35);
pos = autoTarget;
apply(pos);
if (autoTarget >= 1) { release(1); }
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 220); }
return () => {
stopAutoplay();
if (raf !== null) { cancelAnimationFrame(raf); }
thumb.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onUp);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.panel}>
<div className={styles.track}>
<span className={styles.trackLabel}>밀어서 결제 확정</span>
<button className={styles.thumb} type="button" aria-label="밀어서 확정" style={{ background: color }} />
</div>
<span className={styles.hint}>기준(70%)을 넘겨야 확정됩니다</span>
</div>
</div>
);
}06 · スライド確定 — 引いた位置の値が基準を超えると確定状態になるボタン — 計測 累積面積 5.158% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレームのうち06は16フレームで動き、累積面積5.16%・平均強度218.7という9つの中で最も高い強度を記録しています — 40pxの取っ手をpointermoveのたびに描き直しているためで、THRESH(0.7)に届かないまま離された場合はこの同じdiff * 0.3の滑らかさが取っ手を最初の位置へ戻し、指を離した場所にそのまま残ることはありません。
function settle() {
var diff = target - pos;
pos += diff * 0.3;
if (Math.abs(diff) < 0.002) { pos = target; }
apply(pos);
if (Math.abs(target - pos) > 0.002) {
raf = requestAnimationFrame(settle);
} else {
raf = null;
}
}
07順次表示 — ホバー状態で2つ目の文言が文字の順番の値に沿って一つずつ現れるボタン
ホバー状態になると2つ目の文言の各文字が自分の順番の値の分だけ遅れて順に現れた状態になり、ホバーが解けると最初の文言に戻ります。

<main class="stage">
<div class="ctatwr is-demo" id="ctatwr">
<div class="ctatwr__panel">
<button class="ctatwr__btn" id="ctatwrBtn" type="button">
<span class="ctatwr__phrase ctatwr__phrase--first" id="ctatwrFirst">詳しく見る</span>
<span class="ctatwr__phrase ctatwr__phrase--second" id="ctatwrSecond" aria-hidden="true"></span>
</button>
<span class="ctatwr__hint">ホバーすると2つ目の文言が一文字ずつ現れます</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(글자별 가변 지연)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-ink);
.stage { position: relative; }
.ctatwr__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
padding: $sp-5;
border-radius: $r-card;
background: $tick-panel;
}
.ctatwr__btn {
position: relative;
border: 0;
border-radius: $r-control;
background: $subject-mint;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 15px;
padding: $sp-4 $sp-5;
min-width: 168px;
cursor: pointer;
display: grid;
}
.ctatwr__phrase {
grid-area: 1 / 1;
}
.ctatwr__phrase--first {
opacity: 1;
transition: opacity $dur-quick $ease-out;
}
.ctatwr__phrase--first.is-hidden {
opacity: 0;
}
.ctatwr__phrase--second {
display: flex;
}
.ctatwr__phrase--second span {
opacity: 0;
transform: translateY(3px);
transition: opacity $dur-instant $ease-out, transform $dur-instant $ease-out;
}
.ctatwr__phrase--second.is-shown span {
opacity: 1;
transform: translateY(0);
}
.ctatwr__hint {
color: $tick-mute;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctatwr__panel { padding: $sp-4; gap: $sp-3; }
.ctatwr__btn { padding: $sp-3 $sp-4; font-size: 14px; min-width: 148px; }
}
@media (prefers-reduced-motion: reduce) {
.ctatwr__phrase--second span { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.ctatwr__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 20px;
border-radius: 16px;
background: #231f28;
}
.ctatwr__btn {
position: relative;
border: 0;
border-radius: 12px;
background: #4cd4a6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 15px;
padding: 16px 20px;
min-width: 168px;
cursor: pointer;
display: grid;
}
.ctatwr__phrase {
grid-area: 1/1;
}
.ctatwr__phrase--first {
opacity: 1;
transition: opacity 220ms ease-out;
}
.ctatwr__phrase--first.is-hidden {
opacity: 0;
}
.ctatwr__phrase--second {
display: flex;
}
.ctatwr__phrase--second span {
opacity: 0;
transform: translateY(3px);
transition: opacity 140ms ease-out, transform 140ms ease-out;
}
.ctatwr__phrase--second.is-shown span {
opacity: 1;
transform: translateY(0);
}
.ctatwr__hint {
color: #9a93a6;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctatwr__panel {
padding: 16px;
gap: 12px;
}
.ctatwr__btn {
padding: 12px 16px;
font-size: 14px;
min-width: 148px;
}
}
@media (prefers-reduced-motion: reduce) {
.ctatwr__phrase--second span {
transition: none !important;
}
}(function () {
var root = document.getElementById('ctatwr');
var btn = document.getElementById('ctatwrBtn');
var first = document.getElementById('ctatwrFirst');
var second = document.getElementById('ctatwrSecond');
var TEXT = '今なら30%オフ';
// 계산 겹: 각 글자의 등장 지연 값을 순서(i) 의 0.82제곱에 비례하게 계산해
// 뒤로 갈수록 지연 간격이 좁아지는(가속하는) 타자기 리듬을 만든다.
var BASE = 70;
TEXT.split('').forEach(function (ch, i) {
var span = document.createElement('span');
span.textContent = ch === ' ' ? ' ' : ch;
span.style.transitionDelay = (Math.pow(i, 0.82) * BASE).toFixed(0) + 'ms';
second.appendChild(span);
});
var maxDelay = Math.pow(TEXT.length - 1, 0.82) * BASE + 260;
function reveal() {
second.classList.add('is-shown');
first.classList.add('is-hidden');
}
function hide() {
second.classList.remove('is-shown');
first.classList.remove('is-hidden');
}
function wireReal() {
btn.addEventListener('mouseenter', function () { root.classList.remove('is-demo'); stopAutoplay(); reveal(); });
btn.addEventListener('mouseleave', hide);
btn.addEventListener('focus', function () { root.classList.remove('is-demo'); stopAutoplay(); reveal(); });
btn.addEventListener('blur', hide);
}
wireReal();
var shown = false, autoplayTimer = null;
function autoplayTick() { shown = !shown; if (shown) { reveal(); } else { hide(); } }
function stopAutoplay() { if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoplayTimer = setInterval(autoplayTick, maxDelay + 500); }
})();import { useEffect, useRef } from "react";
import styles from "./CtaBtnTypewriterReveal.module.scss";
export type CtaBtnTypewriterRevealProps = {
color?: string;
};
export default function CtaBtnTypewriterReveal({
color = "#4cd4a6",
}: CtaBtnTypewriterRevealProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector<HTMLButtonElement>(`.${styles.btn}`);
const first = root.querySelector<HTMLSpanElement>(`.${styles.first}`);
const second = root.querySelector<HTMLSpanElement>(`.${styles.second}`);
if (!btn || !first || !second) return;
const TEXT = "지금 30% 할인중";
// 계산 겹: 각 글자의 등장 지연 값을 순서(i) 의 0.82제곱에 비례하게 계산해
// 뒤로 갈수록 지연 간격이 좁아지는(가속하는) 타자기 리듬을 만든다.
const BASE = 70;
TEXT.split("").forEach((ch, i) => {
const span = document.createElement("span");
span.textContent = ch === " " ? " " : ch;
span.style.transitionDelay = (Math.pow(i, 0.82) * BASE).toFixed(0) + "ms";
second.appendChild(span);
});
const maxDelay = Math.pow(TEXT.length - 1, 0.82) * BASE + 260;
function reveal() { second!.classList.add(styles.isShown); first!.classList.add(styles.isHidden); }
function hide() { second!.classList.remove(styles.isShown); first!.classList.remove(styles.isHidden); }
function onEnter() { stopAutoplay(); reveal(); }
btn.addEventListener("mouseenter", onEnter);
btn.addEventListener("mouseleave", hide);
btn.addEventListener("focus", onEnter);
btn.addEventListener("blur", hide);
let shown = false;
let autoplayTimer: number | null = null;
function autoplayTick() { shown = !shown; if (shown) { reveal(); } else { hide(); } }
function stopAutoplay() { if (autoplayTimer !== null) { window.clearInterval(autoplayTimer); autoplayTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoplayTimer = window.setInterval(autoplayTick, maxDelay + 500); }
return () => {
stopAutoplay();
btn.removeEventListener("mouseenter", onEnter);
btn.removeEventListener("mouseleave", hide);
btn.removeEventListener("focus", onEnter);
btn.removeEventListener("blur", hide);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.panel}>
<button className={styles.btn} type="button" style={{ background: color }}>
<span className={styles.first}>더 알아보기</span>
<span className={styles.second} aria-hidden="true" />
</button>
<span className={styles.hint}>호버하면 두 번째 문구가 한 글자씩 나타납니다</span>
</div>
</div>
);
}07 · 順次表示 — ホバー状態で2つ目の文言が文字の順番の値に沿って一つずつ現れるボタン — 計測 累積面積 0.943% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレームのうち07は10フレームで動き、累積面積0.94%・平均強度64.6を記録しています — 各文字自身のインデックスがMath.pow(i, 0.82) * BASE(70)に代入されて自分のtransitionDelayを決めるため、文言は左から右へ少しずつ速度を落としながら現れ、すべての文字が同じ遅延で一斉に現れることはありません。
TEXT.split('').forEach(function (ch, i) {
var span = document.createElement('span');
span.textContent = ch === ' ' ? ' ' : ch;
span.style.transitionDelay = (Math.pow(i, 0.82) * BASE).toFixed(0) + 'ms';
second.appendChild(span);
});
var maxDelay = Math.pow(TEXT.length - 1, 0.82) * BASE + 260;
function reveal() {
second.classList.add('is-shown');
first.classList.add('is-hidden');
}
function hide() {
second.classList.remove('is-shown');
first.classList.remove('is-hidden');
}
08流れる待機 — コンテンツが準備できるまで明るさの値が一方向へ流れるボタン
コンテンツが準備できていない状態の間、表面の明るさの値は片方の端からもう片方の端まで繰り返し流れ、コンテンツが準備できた状態になるとその流れが止まり実際の内容に変わります。

<main class="stage">
<div class="ctaskm is-demo" id="ctaskm">
<div class="ctaskm__panel">
<button class="ctaskm__btn ctaskm__btn--loading" id="ctaskmBtn" type="button">
<span class="ctaskm__sweep" id="ctaskmSweep"></span>
<span class="ctaskm__label" id="ctaskmLabel">フォームを送信する</span>
</button>
<span class="ctaskm__hint">クリックすると送信待機状態に変わります</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(sweep 위치의 sin 가감속)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-yellow);
.stage { position: relative; }
.ctaskm__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
padding: $sp-5;
border-radius: $r-card;
background: $stage-paper;
}
.ctaskm__btn {
position: relative;
overflow: hidden;
border: 0;
border-radius: $r-control;
background: $subject-sky;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 15px;
padding: $sp-4 $sp-5;
min-width: 168px;
cursor: pointer;
}
.ctaskm__btn--loading {
background: rgba($subject-sky, .45);
}
.ctaskm__sweep {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 34%;
background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, .75) 50%, rgba(255, 255, 255, 0) 100%);
pointer-events: none;
}
.ctaskm__label {
position: relative;
transition: opacity $dur-quick $ease-out;
}
.ctaskm__hint {
color: rgba($subject-ink, .72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctaskm__panel { padding: $sp-4; gap: $sp-3; }
.ctaskm__btn { padding: $sp-3 $sp-4; font-size: 14px; min-width: 148px; }
}
@media (prefers-reduced-motion: reduce) {
.ctaskm__label { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.ctaskm__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 20px;
border-radius: 16px;
background: #ffffff;
}
.ctaskm__btn {
position: relative;
overflow: hidden;
border: 0;
border-radius: 12px;
background: #6ec8ff;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 15px;
padding: 16px 20px;
min-width: 168px;
cursor: pointer;
}
.ctaskm__btn--loading {
background: rgba(110, 200, 255, 0.45);
}
.ctaskm__sweep {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 34%;
background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.75) 50%, rgba(255, 255, 255, 0) 100%);
pointer-events: none;
}
.ctaskm__label {
position: relative;
transition: opacity 220ms ease-out;
}
.ctaskm__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctaskm__panel {
padding: 16px;
gap: 12px;
}
.ctaskm__btn {
padding: 12px 16px;
font-size: 14px;
min-width: 148px;
}
}
@media (prefers-reduced-motion: reduce) {
.ctaskm__label {
transition: none !important;
}
}(function () {
var root = document.getElementById('ctaskm');
var btn = document.getElementById('ctaskmBtn');
var sweep = document.getElementById('ctaskmSweep');
var label = document.getElementById('ctaskmLabel');
// 계산 겹: sweep 의 가로 위치 값을 ease-in-out(사인 곡선)으로 매 프레임 계산해
// 한쪽 끝(-BAND)에서 반대쪽 끝(100+BAND)까지 흐르게 한다 — 등속 CSS keyframe 이 아니라
// 위치 자체를 sin() 로 가감속시킨다.
var BAND = 34, CYCLE = 1100;
var raf = null, startT = 0, loading = false;
function easeInOutSine(t) { return -(Math.cos(Math.PI * t) - 1) / 2; }
function frame(now) {
var t = ((now - startT) % CYCLE) / CYCLE;
var e = easeInOutSine(t);
var pos = -BAND + e * (100 + BAND * 2);
sweep.style.transform = 'translateX(' + pos.toFixed(1) + '%)';
if (loading) { raf = requestAnimationFrame(frame); } else { raf = null; }
}
function startLoading() {
if (loading) { return; }
loading = true;
btn.classList.add('ctaskm__btn--loading');
label.style.opacity = '0';
startT = performance.now();
raf = requestAnimationFrame(frame);
setTimeout(function () {
loading = false;
btn.classList.remove('ctaskm__btn--loading');
label.style.opacity = '1';
label.textContent = '送信完了';
setTimeout(function () { label.textContent = 'フォームを送信する'; }, 500);
}, 1400);
}
function wireReal() {
btn.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); startLoading(); });
}
wireReal();
var autoplayTimer = null;
function stopAutoplay() { if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { startLoading(); autoplayTimer = setInterval(startLoading, 2000); }
})();import { useEffect, useRef } from "react";
import styles from "./CtaBtnSkeletonShimmer.module.scss";
export type CtaBtnSkeletonShimmerProps = {
color?: string;
};
export default function CtaBtnSkeletonShimmer({
color = "#8fd3ff",
}: CtaBtnSkeletonShimmerProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector<HTMLButtonElement>(`.${styles.btn}`);
const sweep = root.querySelector<HTMLSpanElement>(`.${styles.sweep}`);
const label = root.querySelector<HTMLSpanElement>(`.${styles.label}`);
if (!btn || !sweep || !label) return;
// 계산 겹: sweep 의 가로 위치 값을 ease-in-out(사인 곡선)으로 매 프레임 계산해
// 한쪽 끝(-BAND)에서 반대쪽 끝(100+BAND)까지 흐르게 한다 — 등속 CSS keyframe 이 아니라
// 위치 자체를 sin() 로 가감속시킨다.
const BAND = 34, CYCLE = 1100;
let raf: number | null = null, startT = 0, loading = false;
function easeInOutSine(t: number) { return -(Math.cos(Math.PI * t) - 1) / 2; }
function frame(now: number) {
const t = ((now - startT) % CYCLE) / CYCLE;
const e = easeInOutSine(t);
const pos = -BAND + e * (100 + BAND * 2);
sweep!.style.transform = "translateX(" + pos.toFixed(1) + "%)";
raf = loading ? requestAnimationFrame(frame) : null;
}
function startLoading() {
if (loading) return;
loading = true;
btn!.classList.add(styles.isLoading);
label!.style.opacity = "0";
startT = performance.now();
raf = requestAnimationFrame(frame);
window.setTimeout(() => {
loading = false;
btn!.classList.remove(styles.isLoading);
label!.style.opacity = "1";
label!.textContent = "제출 완료";
window.setTimeout(() => { label!.textContent = "양식 제출하기"; }, 500);
}, 1400);
}
function onClick() { stopAutoplay(); startLoading(); }
btn.addEventListener("click", onClick);
let autoplayTimer: number | null = null;
function stopAutoplay() { if (autoplayTimer !== null) { window.clearInterval(autoplayTimer); autoplayTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { startLoading(); autoplayTimer = window.setInterval(startLoading, 2000); }
return () => {
stopAutoplay();
if (raf !== null) cancelAnimationFrame(raf);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.panel}>
<button className={`${styles.btn} ${styles.isLoading}`} type="button" style={{ background: color }}>
<span className={styles.sweep} />
<span className={styles.label}>양식 제출하기</span>
</button>
<span className={styles.hint}>클릭하면 제출 대기 상태로 바뀝니다</span>
</div>
</div>
);
}08 · 流れる待機 — コンテンツが準備できるまで明るさの値が一方向へ流れるボタン — 計測 累積面積 6.108% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレームのうち08は20フレームで動き、累積面積6.11%・平均強度38.7という9つの中で最も低い強度を記録しています — easeInOutSine()は幅34%のハイライト帯を固定のCYCLE(1100ms)の中で行き来させており、コントラストの低い流れながらもボタン表面を覆う範囲では9つの中で最も広く、それでいて鋭い変化には見えません。
function easeInOutSine(t) { return -(Math.cos(Math.PI * t) - 1) / 2; }
function frame(now) {
var t = ((now - startT) % CYCLE) / CYCLE;
var e = easeInOutSine(t);
var pos = -BAND + e * (100 + BAND * 2);
sweep.style.transform = 'translateX(' + pos.toFixed(1) + '%)';
if (loading) { raf = requestAnimationFrame(frame); } else { raf = null; }
}
09バー反応 — クリックのたびに複数のバーの高さの値がそれぞれ異なって跳ねてから沈むボタン
クリックが起きるたびに並んだ複数のバーそれぞれの高さの値が互いに異なる大きさで一瞬跳ね上がり、時間が経つにつれ各バーの高さの値が減って最初の高さに沈んだ状態になります。

<main class="stage">
<div class="ctavum is-demo" id="ctavum">
<div class="ctavum__panel">
<button class="ctavum__btn" id="ctavumBtn" type="button">
<span class="ctavum__bars" id="ctavumBars"></span>
<span class="ctavum__label">通知音をオンにする</span>
</button>
<span class="ctavum__hint">クリックするたびにバーが跳ねてから沈みます</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(피크-감쇠 곡선)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-ink);
.stage { position: relative; }
.ctavum__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
padding: $sp-5;
border-radius: $r-card;
background: $tick-panel;
}
.ctavum__btn {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
border: 0;
border-radius: $r-control;
background: $subject-ink;
padding: $sp-4 $sp-5;
cursor: pointer;
}
.ctavum__bars {
display: flex;
align-items: flex-end;
gap: 4px;
height: 40px;
}
.ctavum__bar {
width: 6px;
height: 14%;
border-radius: $r-xs;
background: $tick-up;
}
.ctavum__bar:nth-child(4),
.ctavum__bar:nth-child(5) {
background: $tick-down;
}
.ctavum__label {
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 13px;
}
.ctavum__hint {
color: $tick-mute;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctavum__panel { padding: $sp-4; gap: $sp-3; }
.ctavum__btn { padding: $sp-3 $sp-4; }
.ctavum__bars { height: 34px; }
}
@media (prefers-reduced-motion: reduce) {
.ctavum__bar { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.ctavum__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 20px;
border-radius: 16px;
background: #231f28;
}
.ctavum__btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
border: 0;
border-radius: 12px;
background: #17141a;
padding: 16px 20px;
cursor: pointer;
}
.ctavum__bars {
display: flex;
align-items: flex-end;
gap: 4px;
height: 40px;
}
.ctavum__bar {
width: 6px;
height: 14%;
border-radius: 4px;
background: #3ddc97;
}
.ctavum__bar:nth-child(4),
.ctavum__bar:nth-child(5) {
background: #ff7a5e;
}
.ctavum__label {
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 13px;
}
.ctavum__hint {
color: #9a93a6;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctavum__panel {
padding: 16px;
gap: 12px;
}
.ctavum__btn {
padding: 12px 16px;
}
.ctavum__bars {
height: 34px;
}
}
@media (prefers-reduced-motion: reduce) {
.ctavum__bar {
transition: none !important;
}
}(function () {
var root = document.getElementById('ctavum');
var btn = document.getElementById('ctavumBtn');
var barsWrap = document.getElementById('ctavumBars');
var N = 5;
var bars = [];
for (var i = 0; i < N; i++) {
var el = document.createElement('span');
el.className = 'ctavum__bar';
barsWrap.appendChild(el);
bars.push({ el: el, h: 0.14 });
}
// 계산 겹: 클릭마다 막대별 목표 높이 값을 서로 다른 난수로 정하고, 매 프레임
// 현재 높이에서 목표까지 감쇠(DECAY)시켜 실제 VU 미터의 피크-감쇠 곡선을 흉내낸다.
var DECAY = 0.14, MIN_H = 0.14;
var raf = null;
function render() {
for (var i = 0; i < N; i++) {
bars[i].el.style.height = (bars[i].h * 100).toFixed(1) + '%';
}
}
function tick() {
var moving = false;
for (var i = 0; i < N; i++) {
var b = bars[i];
var diff = MIN_H - b.h;
if (b.h > MIN_H) {
b.h += diff * DECAY;
if (b.h - MIN_H > 0.004) { moving = true; } else { b.h = MIN_H; }
}
}
render();
if (moving) { raf = requestAnimationFrame(tick); } else { raf = null; }
}
function hit() {
for (var i = 0; i < N; i++) {
bars[i].h = Math.max(bars[i].h, MIN_H + Math.random() * 0.82);
}
render();
if (!raf) { raf = requestAnimationFrame(tick); }
}
function wireReal() {
btn.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); hit(); });
}
wireReal();
render();
var autoplayTimer = null;
function stopAutoplay() { if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { hit(); autoplayTimer = setInterval(hit, 420); }
})();import { useEffect, useRef } from "react";
import styles from "./CtaBtnVuMeterTap.module.scss";
export type CtaBtnVuMeterTapProps = {
color?: string;
};
const N = 5;
export default function CtaBtnVuMeterTap({
color = "#4cd4a6",
}: CtaBtnVuMeterTapProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector<HTMLButtonElement>(`.${styles.btn}`);
const barsWrap = root.querySelector<HTMLSpanElement>(`.${styles.bars}`);
const hint = root.querySelector<HTMLSpanElement>(`.${styles.hint}`);
if (!btn || !barsWrap || !hint) return;
const bars: { el: HTMLSpanElement; h: number }[] = [];
for (let i = 0; i < N; i++) {
const el = document.createElement("span");
el.className = styles.bar;
barsWrap.appendChild(el);
bars.push({ el, h: 0.14 });
}
// 계산 겹: 클릭마다 막대별 목표 높이 값을 서로 다른 난수로 정하고, 매 프레임
// 현재 높이에서 목표까지 감쇠(DECAY)시켜 실제 VU 미터의 피크-감쇠 곡선을 흉내낸다.
const DECAY = 0.14;
const MIN_H = 0.14;
let raf: number | null = null;
function render() {
for (let i = 0; i < N; i++) {
bars[i].el.style.height = (bars[i].h * 100).toFixed(1) + "%";
}
}
function tick() {
let moving = false;
for (let i = 0; i < N; i++) {
const b = bars[i];
const diff = MIN_H - b.h;
if (b.h > MIN_H) {
b.h += diff * DECAY;
if (b.h - MIN_H > 0.004) { moving = true; } else { b.h = MIN_H; }
}
}
render();
raf = moving ? requestAnimationFrame(tick) : null;
}
function hit() {
for (let i = 0; i < N; i++) {
bars[i].h = Math.max(bars[i].h, MIN_H + Math.random() * 0.82);
}
render();
if (raf === null) { raf = requestAnimationFrame(tick); }
}
function onClick() { stopAutoplay(); hit(); }
btn.addEventListener("click", onClick);
render();
let autoplayTimer: number | null = null;
function stopAutoplay() { if (autoplayTimer !== null) { window.clearInterval(autoplayTimer); autoplayTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { hit(); autoplayTimer = window.setInterval(hit, 420); }
return () => {
stopAutoplay();
if (raf !== null) cancelAnimationFrame(raf);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.panel}>
<button className={styles.btn} type="button" style={{ background: color }}>
<span className={styles.bars} />
<span className={styles.label}>알림음 켜기</span>
</button>
<span className={styles.hint}>클릭할 때마다 막대가 튀었다가 가라앉습니다</span>
</div>
</div>
);
}09 · バー反応 — クリックのたびに複数のバーの高さの値がそれぞれ異なって跳ねてから沈むボタン — 計測 累積面積 0.293% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレームすべてで09は動いており、累積面積0.29%・平均強度170.7を記録しています — hit()はクリックのたびに各バーをMIN_H(0.14)より上のランダムな高さまで跳ね上げ、tick()のdiff * DECAY(0.14)による沈み込みは計測した範囲内では完全には収まらないため、9つの中でこのパターンだけが静止したフレームに至りません。
function tick() {
var moving = false;
for (var i = 0; i < N; i++) {
var b = bars[i];
var diff = MIN_H - b.h;
if (b.h > MIN_H) {
b.h += diff * DECAY;
if (b.h - MIN_H > 0.004) { moving = true; } else { b.h = MIN_H; }
}
}
render();
if (moving) { raf = requestAnimationFrame(tick); } else { raf = null; }
}
function hit() {
for (var i = 0; i < N; i++) {
bars[i].h = Math.max(bars[i].h, MIN_H + Math.random() * 0.82);
}
render();
if (!raf) { raf = requestAnimationFrame(tick); }
}
値がパターンを変える場所 — 長さ・進行・位置のしきい値
3つの定数はパターンが何をするかを変えないまま感触だけを変えます — 値を入れ替えても同じホバー・クリック・ドラッグは同じ最終状態へたどり着き、そこまでの経路だけが変わります。
06のTHRESHが決めるのはドラッグがどれだけ進む必要があるかで、ドラッグ中の感触ではない理由
onUp()は離されたposをTHRESH(0.7)と比較するだけで、その上なら確定し、その下ならsettle()が同じdiff * 0.3の係数で取っ手を0へ戻します。THRESHを0.4に下げると同じドラッグはトラックの半分にも届かないうちに確定し、0.7では端近くまで強く引く必要があったのが、軽く押すだけで済むようになります。
09のDECAYが決めるのはバーの跳ね返りがどれだけ長く残るかで、どれだけ高く跳ねるかではない理由
hit()はDECAYに関係なく毎回同じ上限までバーを跳ね上げ、DECAYはその後tick()がどれだけ速く沈めるかだけを左右します。0.14では叩かれたバーが数フレームにわたってゆっくり沈み残響のように残りますが、0.45に上げると同じ高さまで跳ねても2〜3フレームで沈み、より硬く即座な打感になります。
05のOVERSHOOT_DEGが決めるのは反転が180度をどれだけ超えて揺れるかで、反転にかかる時間ではない理由
ease()の揺れの項はOVERSHOOT_DEG / 180をサインに掛けたものであり、反転全体の時間はDECAYとは別のDUR(500ms)だけが決めています。18では面が180度を超えて198度近くまで回ってから戻り、4ではほとんど超えずに止まり、ばね感の薄い機械的な回転に近づきます。
どこで壊れるか — 高速クリック・短いホバー・途中で離す
高速でクリックするとどうなるか
03のburst()はクリックのたびにparticles配列をまるごと置き換えるため、破裂の途中でもう一度クリックすると、前回のまだ落下中の破片は積み重なるのではなく消えてしまいます。04のstartCountdown()はrunningが真の間は早期リターンで守られているため、カウントダウン中の2回目のクリックはリングが終わるまで何も起こしません。09のhit()はまさにこの場合のために作られており、タップのたびにバーの現在の高さと新しいランダムな目標のどちらか大きい方を取るので、沈み切る前の素早い連続タップはまだMIN_Hに届いていないバーをさらに引き上げるだけです。
トランジションより短いホバーではどうなるか
01のstep()はホバーが終わった時点で最後に設定されたtarget(ホバーが解けた場合は0)へlenを近づけ続けるため、途中で切られたホバーは即座にリセットされるのではなく方向を反転させるだけです。07で最も遅い文字(10文字目、インデックス9)は最初の文字よりおよそ420ms余り遅れてtransitionDelayが発火するため、それより短いホバーが終わってhide()が呼ばれる時点では2つ目の文言の一部の文字がまだ現れていないことがあります。
基準に届く前にドラッグを離すとどうなるか
06のonUp()は離した瞬間のposだけをTHRESHと比較しており、届いていなければsettle()がドラッグ自体には一切かかっていない滑らかさでposを0へ戻します — pointermoveのハンドラは指の生の位置をそのまま反映するだけで一切イージングをかけないので、進行中のドラッグは指の位置に正確に追従し、離されて未確定になったドラッグだけが自分で動いて戻ります。
この系列が【コピペOK】CSS ボタン アニメーション 9選(SCSS + React)、css ボタン ホバー アニメーション 9選 — 受賞級の定番テク、css ボタン 押したら変わる 9選 — 状態遷移の実装と違うのは、それぞれの系列がどの瞬間に応じて何かを計算し直すかという点です。1つ目の系列はホバー・押下・待機・失敗・確定という1つの瞬間にひもづいた単一のトランスフォームや不透明度の変化を再生するだけで、引いた距離やクリック回数をアニメーション自体に織り込むことはありません。ホバー系列は9つの合図を入口と出口で何を伝えるかによって整理していますが、そのどれも範囲のある進行比率や訪問者が途中で越えられる基準値は持っていません。状態フィードバック系列はボタンが固定・回転・解決していく状態を単一のARIA属性に書き込んでおり、これは04のカウントダウンが内部で行っていることに近いのですが、06や09のようにドラッグやクリックを繰り返した値からアニメーションを導くことはありません。この系列の9つのソースファイルはすべて、パスワードt6t4u9ypで解除した先にあり、上の仮の文言を実際の商品コピーに置き換えるだけで本物のCTAへそのまま組み込めます。
3つのバリエーションとアクセシビリティ
この9つのcta ボタン デザインのうちバリエーションとして価値のある定数を持つのは3つだけです — 残る6つに調整可能な数値がないからではなく、THRESH・DECAY・OVERSHOOT_DEGの3つが、値をひとつ変えるだけで再生速度ではなく操作の感触そのものが最も目に見えて変わる箇所だからです。ボタン クリック エフェクトを比べるうえでも、この3つが最初に触るべき値になります。
| バリエーション | 何が変わるか | 何と引き換えになるか |
|---|---|---|
| 06のTHRESHを0.7から0.4へ | 確定までに越える必要のあるトラックの割合が減る | 0.7ではトラックの大半を越えないと確定を確信できないのに対し、0.4では3分の1を過ぎた時点で確定し、その確信を軽い操作感と引き換えにする |
| 09のDECAYを0.14から0.45へ | 叩かれたバーが元の高さへ戻る速さが上がる | 0.14では跳ね返りが数フレームにわたり柔らかい打感として残るが、0.45では同じ跳ね上がりがほぼ即座に沈み、より硬く打楽器的なタップとして感じられる |
| 05のOVERSHOOT_DEGを18から4へ | 反転した面が180度を超えて揺れる幅が小さくなる | 18ではばねのようなやや緩い感触の反転になるが、4では同じ反転がほとんど跳ねない硬く機械的な回転に近づく |
9つとも自分の自動再生プレビューを始める前にwindow.matchMedia('(prefers-reduced-motion: reduce)').matchesを確認しています — 01・02・03・05・06・07はそのループの最初のインターバルが回るまで一切再生せずに待つのに対し、04・08・09はチェックを通過した直後にstartCountdown()・startLoading()・hit()をそれぞれ一度即座に呼ぶため、リング・流れ・バーはインターバルを待たずページ読み込みの瞬間から動き出します。wireReal()自体にはこの系列のどこにも縮小モーションの確認が入っていないため、設定に関わらずホバー・クリック・ドラッグは手で操作すれば必ずフルのアニメーションを再生します。02の2つ目のグリッチ層と07の2つ目の表示文言はどちらもaria-hiddenが付いており、それぞれが可視レイヤーを通じてスクリーンリーダーがすでに読み上げているテキストを重複させているだけだからです。06の取っ手だけがこの9つの中で自前のaria-labelを持ち、それ以外はボタン自体の可視テキストをアクセシブルネームとして頼っています。
FAQ
このマイクロインタラクション ボタンは何かアニメーションライブラリを使っていますか
いいえ — 9つともrequestAnimationFrame・setInterval・CSSのtransitionだけで動いており外部依存は一切ないため、HTML・SCSS・JSをそのまま貼り付けるだけで同じ動きを手元で確認できます。
9つのうちどれかが実際の決済・分析・通知APIを呼び出していますか
いいえ — ここにあるすべての値はローカルで計算されそのまま画面に反映されるだけです。09のメーターはタップに対する擬似的な反応であって実際のWeb Audioの読み取りではなく、この中にfetchや外部サービスへ到達する処理は一つもありません。
これは読むだけの参考例ですか、それともボタンへ貼り付けて使える実装ですか
貼り付けて使うことを前提に作られています — 上に載せたコードはどれも25行以内の核心部分だけで、仮の文言や配色こそが実際の商品自身のコピーやパレットへ置き換えるべき部分です。
Sources: MDN — CSS transition · MDN — Window.requestAnimationFrame · MDN — transform-function: rotateY() · MDN — prefers-reduced-motion









