css ボタン ホバー アニメーション 9選 — 受賞級の定番テク
css ボタン ホバー アニメーションとは、ポインターが近づいたときにボタンが返す反応を、合図の種類ごとに整理したものです。9 つのうち 1 つは、押すとボタンが円になるまで縮んでスピナーが回り、チェックを描いてから元の幅へ戻ります。
01 spotlight-follow
02 conic-glow-runner
03 magnet-pull
04 liquid-fill
05 split-text-slide
07 squash-press
08 corner-brackets
09 state-morph-check
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 カーソル スポットライト
- 02 ボーダーを走る光
- 03 マグネット吸着 + ラベル逆視差
- 04 リキッド充填(波の水面)
- 05 文字分割スライド
- 06 アイコン回転入れ替え
- 07 押し込みの反動(スクワッシュ)
- 08 コーナーブラケットの照準
- 09 ローディングモーフ(ボタン→スピナー→チェック)
デザイン賞を受賞したサイトをよく見ると、ボタンの演出はばらばらに見えても、使われる場所はいつも同じです。そこで人気順ではなく、ボタンがユーザーと出会う順番で並べました。まず離れた場所から気づかれ(01・02)、css ボタン ホバー エフェクトとして近づくポインターに応え(03)、狙っている間に表面・文字・アイコンが変わり(04・05・06)、押した瞬間に手応えを返し(07・08)、最後に待っている間の状態を知らせます(09)。特定のブランドやサイトをまねたものではなく技法だけを取り出しているので、9 つとも自社の色とテキストに差し替えて使えます。ホバー前の基本の見た目を決める話はcss ボタン デザインが先で、指を離した後に状態が変わる区間はcss ボタン 押したら変わるが引き継ぎます。
01カーソル スポットライト
css ボタン ホバー デザインとして何が見えるか
丸い光がポインターを追って、ボタンの表面をなぞります。動かしているのは光の中心の横位置だけで、その値を --mx として渡せば、あとはグラデーションが描いてくれます。

<main class="stage">
<button class="spot is-demo" type="button"><span class="spot__label">料金プランを見る</span></button>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형은 이 세 줄만 바꾼다
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
// 커스텀 속성을 등록해야 브라우저가 퍼센트로 보간한다. 등록 안 하면 0% → 100% 로 튄다
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@include stage($stage-ink);
.spot {
position: relative;
isolation: isolate;
overflow: hidden;
border: 0;
cursor: pointer;
padding: $sp-5 $sp-10;
border-radius: $r-pill;
background: rgba($subject-cream, .10);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .30);
color: $subject-cream;
font-family: inherit;
font-size: clamp(16px, 5vw, 20px);
font-weight: 700;
}
.spot::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background: radial-gradient(circle 96px at var(--mx) 50%, rgba($color, .95), rgba($color, 0) 72%);
}
.spot__label { position: relative; }
.spot.is-demo { animation: spot-sweep $duration $easing infinite; }
// 8% → 92% → 8% 로 되돌아와 끝난다. 92% 에서 끊고 처음으로 점프하면 빛이 순간이동한다
@keyframes spot-sweep {
0% { --mx: 8%; }
50% { --mx: 92%; }
100% { --mx: 8%; }
}
@media (prefers-reduced-motion: reduce) {
.spot.is-demo { animation: none; }
.spot::before { background: radial-gradient(circle 96px at 50% 50%, rgba($color, .95), rgba($color, 0) 72%); }
}@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
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);
}
.spot {
position: relative;
isolation: isolate;
overflow: hidden;
border: 0;
cursor: pointer;
padding: 20px 40px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.1);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.3);
color: #fff7e6;
font-family: inherit;
font-size: clamp(16px, 5vw, 20px);
font-weight: 700;
}
.spot::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background: radial-gradient(circle 96px at var(--mx) 50%, rgba(47, 109, 246, 0.95), rgba(47, 109, 246, 0) 72%);
}
.spot__label {
position: relative;
}
.spot.is-demo {
animation: spot-sweep 2s ease-out infinite;
}
@keyframes spot-sweep {
0% {
--mx: 8%;
}
50% {
--mx: 92%;
}
100% {
--mx: 8%;
}
}
@media (prefers-reduced-motion: reduce) {
.spot.is-demo {
animation: none;
}
.spot::before {
background: radial-gradient(circle 96px at 50% 50%, rgba(47, 109, 246, 0.95), rgba(47, 109, 246, 0) 72%);
}
}// 실제 동작: 커서가 버튼 위에 오면 빛의 중심(--mx)이 커서를 따라가고, 손을 떼면 데모 루프로 돌아간다.
var spot = document.querySelector('.spot');
spot.addEventListener('pointermove', function (e) {
var r = spot.getBoundingClientRect();
spot.classList.remove('is-demo');
spot.style.setProperty('--mx', ((e.clientX - r.left) / r.width * 100) + '%');
});
spot.addEventListener('pointerleave', function () {
spot.style.removeProperty('--mx');
spot.classList.add('is-demo');
});import { useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./SpotlightButton.module.scss";
export type SpotlightButtonProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--mx"?: string };
export default function SpotlightButton({
label = "See pricing",
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
}: SpotlightButtonProps) {
const [mx, setMx] = useState<string | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
if (mx) { vars["--mx"] = mx; }
function move(e: PointerEvent<HTMLButtonElement>) {
const r = e.currentTarget.getBoundingClientRect();
setMx(`${((e.clientX - r.left) / r.width) * 100}%`);
}
return (
<button
type="button"
className={`${styles.spot} ${mx ? "" : styles.loop}`}
style={vars}
onPointerMove={move}
onPointerLeave={() => setMx(null)}
>
<span className={styles.label}>{label}</span>
</button>
);
}01 · カーソル スポットライト — 計測 累積面積 7.528% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
この変数を @property で登録しておくのがポイントです。
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
.spot::before {
background: radial-gradient(circle 96px at var(--mx) 50%, rgba($color, .95), rgba($color, 0) 72%);
}
@keyframes spot-sweep {
0% { --mx: 8%; }
50% { --mx: 92%; }
100% { --mx: 8%; }
}
02ボーダーを走る光
画面で起きること
明るい点が 1 つ、縁を継ぎ目なく何周も回ります。

<main class="stage">
<button class="glow" type="button">
<span class="glow__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="m194.82 151.43l-55.09 20.3l-20.3 55.09a7.92 7.92 0 0 1-14.86 0l-20.3-55.09l-55.09-20.3a7.92 7.92 0 0 1 0-14.86l55.09-20.3l20.3-55.09a7.92 7.92 0 0 1 14.86 0l20.3 55.09l55.09 20.3a7.92 7.92 0 0 1 0 14.86" opacity=".2"/><path d="M197.58 129.06L146 110l-19-51.62a15.92 15.92 0 0 0-29.88 0L78 110l-51.62 19a15.92 15.92 0 0 0 0 29.88L78 178l19 51.62a15.92 15.92 0 0 0 29.88 0L146 178l51.62-19a15.92 15.92 0 0 0 0-29.88ZM137 164.22a8 8 0 0 0-4.74 4.74L112 223.85L91.78 169a8 8 0 0 0-4.78-4.78L32.15 144L87 123.78a8 8 0 0 0 4.78-4.78L112 64.15L132.22 119a8 8 0 0 0 4.74 4.74L191.85 144ZM144 40a8 8 0 0 1 8-8h16V16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0V48h-16a8 8 0 0 1-8-8m104 48a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8"/></g></svg></span>
<span class="glow__label">無料トライアル</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형은 이 세 줄만 바꾼다
$duration: $dur-loop;
$easing: linear;
$color: $subject-pink;
// 각도를 등록하지 않으면 conic-gradient 의 from 값이 보간되지 않아 러너가 아예 안 돈다
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@include stage($stage-ink);
.glow {
position: relative;
display: inline-flex;
align-items: center;
gap: $sp-2;
border: 0;
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: $r-pill;
background: rgba($subject-cream, .06);
color: $subject-cream;
font-family: inherit;
font-size: clamp(15px, 4.5vw, 19px);
font-weight: 700;
}
// 테두리만 남기는 마스크: 바깥 사각형에서 content-box 를 빼면 테두리 굵기만 남는다
.glow::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: $sp-1;
background: conic-gradient(from var(--angle),
rgba($color, 0) 0deg,
rgba($color, 0) 232deg,
rgba($color, 1) 300deg,
rgba($color, .2) 330deg,
rgba($color, 0) 360deg);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: glow-run $duration $easing infinite;
}
.glow__ic { display: inline-flex; color: $color; }
// 0deg → 360deg 는 같은 그림이라 끝과 처음이 맞물린다. 340deg 에서 끊으면 매 바퀴 튄다
@keyframes glow-run {
to { --angle: 360deg; }
}
@media (prefers-reduced-motion: reduce) {
.glow::before { animation: none; background: rgba($color, .7); }
}@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
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);
}
.glow {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
border: 0;
cursor: pointer;
padding: 16px 32px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.06);
color: #fff7e6;
font-family: inherit;
font-size: clamp(15px, 4.5vw, 19px);
font-weight: 700;
}
.glow::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: 4px;
background: conic-gradient(from var(--angle), rgba(255, 158, 187, 0) 0deg, rgba(255, 158, 187, 0) 232deg, #ff9ebb 300deg, rgba(255, 158, 187, 0.2) 330deg, rgba(255, 158, 187, 0) 360deg);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: glow-run 2s linear infinite;
}
.glow__ic {
display: inline-flex;
color: #ff9ebb;
}
@keyframes glow-run {
to {
--angle: 360deg;
}
}
@media (prefers-reduced-motion: reduce) {
.glow::before {
animation: none;
background: rgba(255, 158, 187, 0.7);
}
}import type { CSSProperties } from "react";
import styles from "./GlowRunnerButton.module.scss";
export type GlowRunnerButtonProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function GlowRunnerButton({
label = "Start free trial",
duration = 2000,
easing = "linear",
color = "#ff9ebb",
}: GlowRunnerButtonProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button type="button" className={styles.glow} style={vars}>
<span className={styles.label}>{label}</span>
</button>
);
}02 · ボーダーを走る光 — 計測 累積面積 1.381% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
conic-gradient 全体を回転させ、枠線の太さの分だけマスクで残す作りなので、角丸でも長方形でも同じ記述のまま形に追従します。
.glow::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: $sp-1;
background: conic-gradient(from var(--angle),
rgba($color, 0) 0deg,
rgba($color, 0) 232deg,
rgba($color, 1) 300deg,
rgba($color, .2) 330deg,
rgba($color, 0) 360deg);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: glow-run $duration $easing infinite;
}
03マグネット吸着 + ラベル逆視差
どんな動きか
ボタンはポインターに引き寄せられ、中の文字は同じ座標に負の数を掛けて逆方向へずれます。

<main class="stage">
<div class="mag__field">
<button class="mag is-demo" type="button"><span class="mag__label">プロジェクトを見る</span></button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형은 이 세 줄만 바꾼다
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.mag__field {
display: grid;
place-items: center;
padding: $sp-8;
}
.mag {
border: 0;
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: $r-control;
background: $color;
color: $subject-cream;
font-family: inherit;
font-size: clamp(15px, 4.5vw, 19px);
font-weight: 700;
box-shadow: $shadow-press;
transition: translate $dur-quick $easing;
}
.mag__label {
display: inline-block;
transition: translate $dur-quick $easing;
}
.mag.is-demo { animation: mag-pull $duration $easing infinite; }
.mag.is-demo .mag__label { animation: mag-drift $duration $easing infinite; }
// 네 지점을 돌고 0 0 으로 돌아온다. 마지막 키프레임을 빼면 한 바퀴마다 제자리로 튄다
@keyframes mag-pull {
0% { translate: 0 0; }
25% { translate: 22px -8px; }
50% { translate: 0 6px; }
75% { translate: -22px -8px; }
100% { translate: 0 0; }
}
// 같은 좌표에 -0.4 를 곱한 값. 부호가 반대라 층이 갈라져 보인다
@keyframes mag-drift {
0% { translate: 0 0; }
25% { translate: -9px 3px; }
50% { translate: 0 -2px; }
75% { translate: 9px 3px; }
100% { translate: 0 0; }
}
@media (prefers-reduced-motion: reduce) {
.mag.is-demo,
.mag.is-demo .mag__label { animation: none; }
.mag,
.mag__label { 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);
}
.mag__field {
display: grid;
place-items: center;
padding: 32px;
}
.mag {
border: 0;
cursor: pointer;
padding: 16px 32px;
border-radius: 12px;
background: #2f6df6;
color: #fff7e6;
font-family: inherit;
font-size: clamp(15px, 4.5vw, 19px);
font-weight: 700;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: translate 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mag__label {
display: inline-block;
transition: translate 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mag.is-demo {
animation: mag-pull 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.mag.is-demo .mag__label {
animation: mag-drift 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes mag-pull {
0% {
translate: 0 0;
}
25% {
translate: 22px -8px;
}
50% {
translate: 0 6px;
}
75% {
translate: -22px -8px;
}
100% {
translate: 0 0;
}
}
@keyframes mag-drift {
0% {
translate: 0 0;
}
25% {
translate: -9px 3px;
}
50% {
translate: 0 -2px;
}
75% {
translate: 9px 3px;
}
100% {
translate: 0 0;
}
}
@media (prefers-reduced-motion: reduce) {
.mag.is-demo,
.mag.is-demo .mag__label {
animation: none;
}
.mag,
.mag__label {
transition: none;
}
}// 실제 동작: 커서가 감지 영역에 들어오면 버튼은 커서 쪽으로, 라벨은 그 절반만큼 반대로 움직인다.
var field = document.querySelector('.mag__field');
var btn = document.querySelector('.mag');
var label = document.querySelector('.mag__label');
field.addEventListener('pointermove', function (e) {
var r = btn.getBoundingClientRect();
var dx = (e.clientX - (r.left + r.width / 2)) * 0.35;
var dy = (e.clientY - (r.top + r.height / 2)) * 0.35;
btn.classList.remove('is-demo');
btn.style.translate = dx + 'px ' + dy + 'px';
label.style.translate = (-dx * 0.4) + 'px ' + (-dy * 0.4) + 'px';
});
field.addEventListener('pointerleave', function () {
btn.style.translate = '';
label.style.translate = '';
btn.classList.add('is-demo');
});import { useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./MagnetButton.module.scss";
export type MagnetButtonProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function MagnetButton({
label = "View projects",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: MagnetButtonProps) {
const [pull, setPull] = useState<{ x: number; y: number } | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function move(e: PointerEvent<HTMLDivElement>) {
const btn = e.currentTarget.firstElementChild as HTMLElement | null;
if (!btn) { return; }
const r = btn.getBoundingClientRect();
setPull({
x: (e.clientX - (r.left + r.width / 2)) * 0.35,
y: (e.clientY - (r.top + r.height / 2)) * 0.35,
});
}
return (
<div className={styles.field} style={vars} onPointerMove={move} onPointerLeave={() => setPull(null)}>
<button
type="button"
className={`${styles.mag} ${pull ? "" : styles.loop}`}
style={pull ? { translate: `${pull.x}px ${pull.y}px` } : undefined}
>
<span
className={`${styles.label} ${pull ? "" : styles.labelLoop}`}
style={pull ? { translate: `${-pull.x * 0.4}px ${-pull.y * 0.4}px` } : undefined}
>
{label}
</span>
</button>
</div>
);
}03 · マグネット吸着 + ラベル逆視差 — 計測 累積面積 10.698% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
この符号 1 つで 2 つのレイヤーが分かれ、平面的なボタンがわずかに浮き上がって見えます。
field.addEventListener('pointermove', function (e) {
var r = btn.getBoundingClientRect();
var dx = (e.clientX - (r.left + r.width / 2)) * 0.35;
var dy = (e.clientY - (r.top + r.height / 2)) * 0.35;
btn.classList.remove('is-demo');
btn.style.translate = dx + 'px ' + dy + 'px';
label.style.translate = (-dx * 0.4) + 'px ' + (-dy * 0.4) + 'px';
});
04リキッド充填(波の水面)
何が見えるか
下から色が満ちていきますが、水面は平らにならず、水のように傾きます。

<main class="stage">
<button class="liq is-demo" type="button">
<span class="liq__wave" aria-hidden="true"></span>
<span class="liq__label">カートに入れる</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형은 이 세 줄만 바꾼다
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-yellow);
.liq {
position: relative;
overflow: hidden;
isolation: isolate;
border: 0;
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: $r-pill;
background: $subject-cream;
box-shadow: inset 0 0 0 2px $subject-ink;
color: $subject-ink;
font-family: inherit;
font-size: clamp(15px, 4.5vw, 19px);
font-weight: 800;
}
// 수면이 평평하지 않은 이유: 완전한 원이 아니라 네 모서리를 42% 둥글린 정사각형을 통째로 돌린다
.liq__wave {
position: absolute;
left: 50%;
bottom: 0;
width: 260px;
height: 260px;
border-radius: 42%;
background: $color;
transform: translate(-50%, 100%);
}
.liq__label { position: relative; }
.liq.is-demo .liq__wave { animation: liq-rise $duration $easing infinite; }
// rotate 가 0deg 에서 360deg 로 끝나 마지막 프레임과 첫 프레임의 그림이 같다
@keyframes liq-rise {
0% { transform: translate(-50%, 100%) rotate(0deg); }
52% { transform: translate(-50%, 68%) rotate(180deg); }
66% { transform: translate(-50%, 68%) rotate(230deg); }
100% { transform: translate(-50%, 100%) rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.liq.is-demo .liq__wave { animation: none; transform: translate(-50%, 68%); }
}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);
}
.liq {
position: relative;
overflow: hidden;
isolation: isolate;
border: 0;
cursor: pointer;
padding: 16px 32px;
border-radius: 999px;
background: #fff7e6;
box-shadow: inset 0 0 0 2px #17141a;
color: #17141a;
font-family: inherit;
font-size: clamp(15px, 4.5vw, 19px);
font-weight: 800;
}
.liq__wave {
position: absolute;
left: 50%;
bottom: 0;
width: 260px;
height: 260px;
border-radius: 42%;
background: #4cd4a6;
transform: translate(-50%, 100%);
}
.liq__label {
position: relative;
}
.liq.is-demo .liq__wave {
animation: liq-rise 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes liq-rise {
0% {
transform: translate(-50%, 100%) rotate(0deg);
}
52% {
transform: translate(-50%, 68%) rotate(180deg);
}
66% {
transform: translate(-50%, 68%) rotate(230deg);
}
100% {
transform: translate(-50%, 100%) rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.liq.is-demo .liq__wave {
animation: none;
transform: translate(-50%, 68%);
}
}import type { CSSProperties } from "react";
import styles from "./LiquidFillButton.module.scss";
export type LiquidFillButtonProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function LiquidFillButton({
label = "Add to cart",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#4cd4a6",
}: LiquidFillButtonProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button type="button" className={styles.liq} style={vars}>
<span className={styles.wave} aria-hidden="true" />
<span className={styles.label}>{label}</span>
</button>
);
}04 · リキッド充填(波の水面) — 計測 累積面積 6.315% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
円ではなく、四隅を 42% だけ丸めた 260px の正方形を回しながら同時に持ち上げているため、水面の角度が一周のあいだ変わり続けます。
.liq__wave {
position: absolute;
left: 50%;
bottom: 0;
width: 260px;
height: 260px;
border-radius: 42%;
background: $color;
transform: translate(-50%, 100%);
}
@keyframes liq-rise {
0% { transform: translate(-50%, 100%) rotate(0deg); }
52% { transform: translate(-50%, 68%) rotate(180deg); }
66% { transform: translate(-50%, 68%) rotate(230deg); }
100% { transform: translate(-50%, 100%) rotate(360deg); }
}
05文字分割スライド
画面で起きること
文字が 1 つずつ上へ抜け、同じ文字が下から上がってきて入れ替わります。

<main class="stage">
<button class="split is-demo" type="button" aria-label="詳しく見る"><span class="split__in" aria-hidden="true"></span></button>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형은 이 세 줄만 바꾼다
$duration: $dur-loop;
$easing: $ease-spring;
$stagger: 70ms;
@include stage($stage-orange);
.split {
border: 0;
cursor: pointer;
padding: $sp-3 $sp-6;
border-radius: $r-control;
background: $subject-cream;
color: $stage-orange;
font-family: inherit;
font-size: clamp(18px, 6vw, 26px);
font-weight: 800;
}
.split__in { display: inline-flex; }
// 창(1.25em)은 고정이고 그 안의 두 겹이 굴러간다
.split__ch {
display: inline-block;
overflow: hidden;
height: 1.25em;
line-height: 1.25;
}
.split__ch b { display: block; }
.split__ch i { display: block; font-style: normal; }
.split.is-demo .split__ch b {
animation: split-roll $duration $easing infinite;
animation-delay: calc(var(--i) * #{$stagger});
}
// 두 겹이라 -50% 가 정확히 한 줄. 0 으로 돌아와 끝나므로 되감기가 안 보인다
@keyframes split-roll {
0%, 12% { translate: 0 0; }
42%, 68% { translate: 0 -50%; }
100% { translate: 0 0; }
}
@media (prefers-reduced-motion: reduce) {
.split.is-demo .split__ch b { animation: 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);
}
.split {
border: 0;
cursor: pointer;
padding: 12px 24px;
border-radius: 12px;
background: #fff7e6;
color: #ff4d1f;
font-family: inherit;
font-size: clamp(18px, 6vw, 26px);
font-weight: 800;
}
.split__in {
display: inline-flex;
}
.split__ch {
display: inline-block;
overflow: hidden;
height: 1.25em;
line-height: 1.25;
}
.split__ch b {
display: block;
}
.split__ch i {
display: block;
font-style: normal;
}
.split.is-demo .split__ch b {
animation: split-roll 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: calc(var(--i) * 70ms);
}
@keyframes split-roll {
0%, 12% {
translate: 0 0;
}
42%, 68% {
translate: 0 -50%;
}
100% {
translate: 0 0;
}
}
@media (prefers-reduced-motion: reduce) {
.split.is-demo .split__ch b {
animation: none;
}
}// 실제 동작: aria-label 의 글자를 하나씩 이중으로 쌓고, 글자 번호(--i)를 지연 시간으로 쓴다.
var split = document.querySelector('.split');
var box = split.querySelector('.split__in');
var text = split.getAttribute('aria-label');
for (var i = 0; i < text.length; i++) {
var cell = document.createElement('span');
var roll = document.createElement('b');
cell.className = 'split__ch';
cell.style.setProperty('--i', i);
for (var k = 0; k < 2; k++) {
var face = document.createElement('i');
face.textContent = text[i] === ' ' ? ' ' : text[i];
roll.appendChild(face);
}
cell.appendChild(roll);
box.appendChild(cell);
}import type { CSSProperties } from "react";
import styles from "./SplitTextButton.module.scss";
export type SplitTextButtonProps = {
label?: string;
duration?: number;
easing?: string;
stagger?: number;
};
type CellVars = CSSProperties & { "--i"?: number };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--stagger"?: string };
export default function SplitTextButton({
label = "See details",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
stagger = 70,
}: SplitTextButtonProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--stagger": `${stagger}ms` };
const chars = label.split("");
return (
<button type="button" className={styles.split} style={vars} aria-label={label}>
<span className={styles.inner} aria-hidden="true">
{chars.map((ch, i) => {
const cell: CellVars = { "--i": i };
return (
<span className={styles.ch} style={cell} key={`${ch}-${i}`}>
<b className={styles.roll}>
<i>{ch === " " ? " " : ch}</i>
<i>{ch === " " ? " " : ch}</i>
</b>
</span>
);
})}
</span>
</button>
);
}05 · 文字分割スライド — 計測 累積面積 2.35% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
文字ごとに overflow: hidden の窓を作り、その中に同じ文字を 2 枚重ねておき、遅延を文字番号に比例させることで、文字ごとの別々の動きが 1 つの波になります。
.split__ch {
display: inline-block;
overflow: hidden;
height: 1.25em;
line-height: 1.25;
}
.split.is-demo .split__ch b {
animation: split-roll $duration $easing infinite;
animation-delay: calc(var(--i) * #{$stagger});
}
@keyframes split-roll {
0%, 12% { translate: 0 0; }
42%, 68% { translate: 0 -50%; }
100% { translate: 0 0; }
}
06アイコン回転入れ替え
どんな動きか
アイコンが Y 軸まわりに半回転し、裏にあった別のアイコンへ入れ替わります。
<main class="stage">
<button class="swap is-demo" type="button">
<span class="swap__ic" aria-hidden="true">
<span class="swap__face swap__face--front"><svg viewBox="0 0 256 256" width="34" height="34"><g fill="currentColor"><path d="m232 72l-25.63 92.28A16 16 0 0 1 191 176H92.16a16 16 0 0 1-15.41-11.72L51.11 72Z" opacity=".2"/><path d="M104 216a16 16 0 1 1-16-16a16 16 0 0 1 16 16m88-16a16 16 0 1 0 16 16a16 16 0 0 0-16-16m47.71-125.86l-25.64 92.28A24.06 24.06 0 0 1 191 184H92.16A24.06 24.06 0 0 1 69 166.42L33.92 40H16a8 8 0 0 1 0-16h24a8 8 0 0 1 7.71 5.86L57.19 64H232a8 8 0 0 1 7.71 10.14M221.47 80H61.64l22.81 82.14a8 8 0 0 0 7.71 5.86H191a8 8 0 0 0 7.71-5.86Z"/></g></svg></span>
<span class="swap__face swap__face--back"><svg viewBox="0 0 256 256" width="34" height="34"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span>
</span>
<span class="swap__label">追加</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형은 이 세 줄만 바꾼다
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.swap {
display: inline-flex;
align-items: center;
gap: $sp-3;
border: 0;
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: $r-control;
background: rgba($color, .10);
color: $color;
font-family: inherit;
font-size: clamp(18px, 5.5vw, 24px);
font-weight: 800;
box-shadow: inset 0 0 0 2px rgba($color, .35);
}
// 두 아이콘이 같은 자리에 앞뒤로 겹친다. 뒷면을 가려야 한 장만 보인다
.swap__ic {
position: relative;
width: 34px;
height: 34px;
transform-style: preserve-3d;
}
.swap__face {
position: absolute;
inset: 0;
display: flex;
backface-visibility: hidden;
}
.swap__face--back { transform: rotateY(180deg); }
.swap.is-demo .swap__ic { animation: swap-spin $duration $easing infinite; }
// 360deg 로 끝내야 다음 바퀴의 0deg 와 이어진다. 180deg 에서 끊고 0 으로 돌리면 되감기가 보인다
@keyframes swap-spin {
0%, 22% { transform: rotateY(0deg); }
50%, 72% { transform: rotateY(180deg); }
100% { transform: rotateY(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.swap.is-demo .swap__ic { animation: 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);
}
.swap {
display: inline-flex;
align-items: center;
gap: 12px;
border: 0;
cursor: pointer;
padding: 16px 32px;
border-radius: 12px;
background: rgba(47, 109, 246, 0.1);
color: #2f6df6;
font-family: inherit;
font-size: clamp(18px, 5.5vw, 24px);
font-weight: 800;
box-shadow: inset 0 0 0 2px rgba(47, 109, 246, 0.35);
}
.swap__ic {
position: relative;
width: 34px;
height: 34px;
transform-style: preserve-3d;
}
.swap__face {
position: absolute;
inset: 0;
display: flex;
backface-visibility: hidden;
}
.swap__face--back {
transform: rotateY(180deg);
}
.swap.is-demo .swap__ic {
animation: swap-spin 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes swap-spin {
0%, 22% {
transform: rotateY(0deg);
}
50%, 72% {
transform: rotateY(180deg);
}
100% {
transform: rotateY(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.swap.is-demo .swap__ic {
animation: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./IconSwapButton.module.scss";
export type IconSwapButtonProps = {
label?: string;
front?: ReactNode;
back?: ReactNode;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const cart = (
<svg viewBox="0 0 256 256" width="34" height="34">
<g fill="currentColor">
<path d="m232 72l-25.63 92.28A16 16 0 0 1 191 176H92.16a16 16 0 0 1-15.41-11.72L51.11 72Z" opacity=".2" />
<path d="M104 216a16 16 0 1 1-16-16a16 16 0 0 1 16 16m88-16a16 16 0 1 0 16 16a16 16 0 0 0-16-16m47.71-125.86l-25.64 92.28A24.06 24.06 0 0 1 191 184H92.16A24.06 24.06 0 0 1 69 166.42L33.92 40H16a8 8 0 0 1 0-16h24a8 8 0 0 1 7.71 5.86L57.19 64H232a8 8 0 0 1 7.71 10.14M221.47 80H61.64l22.81 82.14a8 8 0 0 0 7.71 5.86H191a8 8 0 0 0 7.71-5.86Z" />
</g>
</svg>
);
const check = (
<svg viewBox="0 0 256 256" width="34" height="34">
<g fill="currentColor">
<path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2" />
<path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32" />
</g>
</svg>
);
export default function IconSwapButton({
label = "Add",
front = cart,
back = check,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: IconSwapButtonProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button type="button" className={styles.swap} style={vars}>
<span className={styles.ic} aria-hidden="true">
<span className={styles.face}>{front}</span>
<span className={`${styles.face} ${styles.back}`}>{back}</span>
</span>
<span>{label}</span>
</button>
);
}06 · アイコン回転入れ替え — 計測 累積面積 0.485% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
2 つのアイコンを同じ枠に重ね、それぞれの裏面だけを隠す作りなので、「追加」から「追加済み」へ意味が変わるときもラベルを書き換えずに済みます。
.swap__ic {
position: relative;
width: 34px;
height: 34px;
transform-style: preserve-3d;
}
.swap__face { position: absolute; inset: 0; display: flex; backface-visibility: hidden; }
.swap__face--back { transform: rotateY(180deg); }
@keyframes swap-spin {
0%, 22% { transform: rotateY(0deg); }
50%, 72% { transform: rotateY(180deg); }
100% { transform: rotateY(360deg); }
}
07押し込みの反動(スクワッシュ)
何が見えるか
押した瞬間に縦へ潰れて横へ広がり、離すと大きく跳ね戻り、小さくもう一度揺れてから収まります。

<main class="stage">
<button class="squash is-demo" type="button"><span class="squash__label">送信する</span></button>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형은 이 세 줄만 바꾼다
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-pink;
@include stage($stage-yellow);
.squash {
border: 0;
cursor: pointer;
padding: $sp-4 $sp-10;
border-radius: $r-control;
background: $color;
color: $subject-ink;
font-family: inherit;
font-size: clamp(17px, 5vw, 22px);
font-weight: 800;
box-shadow: $shadow-raised;
transform-origin: center bottom;
}
.squash.is-demo { animation: squash-press $duration $easing infinite; }
// 눌림(scale 1.06/.88) → 반동(.97/1.06) → 안착. 100% 가 0% 와 같은 값이라 되감기가 없다
@keyframes squash-press {
0%, 34% { transform: scale(1, 1); box-shadow: $shadow-raised; }
46% { transform: scale(1.06, .88); box-shadow: $shadow-press; }
60% { transform: scale(.97, 1.06); box-shadow: $shadow-raised; }
72% { transform: scale(1.02, .98); box-shadow: $shadow-raised; }
86%, 100% { transform: scale(1, 1); box-shadow: $shadow-raised; }
}
// 손으로 눌렀을 때도 같은 눌림 값
.squash:active {
animation: none;
transform: scale(1.06, .88);
box-shadow: $shadow-press;
}
@media (prefers-reduced-motion: reduce) {
.squash.is-demo { animation: none; }
.squash:active { transform: 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);
}
.squash {
border: 0;
cursor: pointer;
padding: 16px 40px;
border-radius: 12px;
background: #ff9ebb;
color: #17141a;
font-family: inherit;
font-size: clamp(17px, 5vw, 22px);
font-weight: 800;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: center bottom;
}
.squash.is-demo {
animation: squash-press 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes squash-press {
0%, 34% {
transform: scale(1, 1);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
46% {
transform: scale(1.06, 0.88);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
60% {
transform: scale(0.97, 1.06);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
72% {
transform: scale(1.02, 0.98);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
86%, 100% {
transform: scale(1, 1);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
}
.squash:active {
animation: none;
transform: scale(1.06, 0.88);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
@media (prefers-reduced-motion: reduce) {
.squash.is-demo {
animation: none;
}
.squash:active {
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./SquashPressButton.module.scss";
export type SquashPressButtonProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SquashPressButton({
label = "Submit",
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#ff9ebb",
}: SquashPressButtonProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button type="button" className={styles.squash} style={vars}>
{label}
</button>
);
}07 · 押し込みの反動(スクワッシュ) — 計測 累積面積 5.59% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
同じタイミングで影を弱めるのが残り半分の仕掛けで、拡大縮小だけではバグのように見え、影を添えるとゴムのようにたわんで見えます。
.squash { transform-origin: center bottom; }
@keyframes squash-press {
0%, 34% { transform: scale(1, 1); box-shadow: $shadow-raised; }
46% { transform: scale(1.06, .88); box-shadow: $shadow-press; }
60% { transform: scale(.97, 1.06); box-shadow: $shadow-raised; }
72% { transform: scale(1.02, .98); box-shadow: $shadow-raised; }
86%, 100% { transform: scale(1, 1); box-shadow: $shadow-raised; }
}
.squash:active {
animation: none;
transform: scale(1.06, .88);
box-shadow: $shadow-press;
}
08コーナーブラケットの照準
画面で起きること
四隅の L 字パーツが外から集まって枠を完成させます。

<main class="stage">
<button class="brk is-demo" type="button">
<span class="brk__c brk__c--tl" aria-hidden="true"></span>
<span class="brk__c brk__c--tr" aria-hidden="true"></span>
<span class="brk__c brk__c--bl" aria-hidden="true"></span>
<span class="brk__c brk__c--br" aria-hidden="true"></span>
<span class="brk__label">設定を開く</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형은 이 세 줄만 바꾼다
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
.brk {
position: relative;
border: 0;
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: 0;
background: transparent;
color: $color;
font-family: inherit;
font-size: clamp(16px, 5vw, 21px);
font-weight: 700;
}
// 조각 넷은 같은 크기·같은 이동 거리. 다른 건 어느 쪽 변을 지우느냐뿐이다
.brk__c {
position: absolute;
width: 22px;
height: 22px;
border: 3px solid $color;
--tx: -18px;
--ty: -18px;
}
.brk__c--tl { top: -8px; left: -8px; border-right: 0; border-bottom: 0; --i: 0; }
.brk__c--tr { top: -8px; right: -8px; border-left: 0; border-bottom: 0; --tx: 18px; --i: 1; }
.brk__c--bl { bottom: -8px; left: -8px; border-right: 0; border-top: 0; --ty: 18px; --i: 2; }
.brk__c--br { bottom: -8px; right: -8px; border-left: 0; border-top: 0; --tx: 18px; --ty: 18px; --i: 3; }
.brk.is-demo .brk__c {
animation: brk-close $duration $easing infinite;
animation-delay: calc(var(--i) * 60ms);
}
// 모였다가 다시 흩어져 시작 위치로 돌아온다. 모인 상태에서 끝내면 매 바퀴 조각이 순간이동한다
@keyframes brk-close {
0% { translate: var(--tx) var(--ty); opacity: 0; }
38%, 66% { translate: 0 0; opacity: 1; }
100% { translate: var(--tx) var(--ty); opacity: 0; }
}
// 키보드 포커스에도 같은 조준선
.brk:focus-visible {
outline: none;
.brk__c { animation: none; translate: 0 0; opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.brk.is-demo .brk__c { animation: none; translate: 0 0; opacity: 1; }
}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);
}
.brk {
position: relative;
border: 0;
cursor: pointer;
padding: 16px 32px;
border-radius: 0;
background: transparent;
color: #fff7e6;
font-family: inherit;
font-size: clamp(16px, 5vw, 21px);
font-weight: 700;
}
.brk__c {
position: absolute;
width: 22px;
height: 22px;
border: 3px solid #fff7e6;
--tx: -18px;
--ty: -18px;
}
.brk__c--tl {
top: -8px;
left: -8px;
border-right: 0;
border-bottom: 0;
--i: 0;
}
.brk__c--tr {
top: -8px;
right: -8px;
border-left: 0;
border-bottom: 0;
--tx: 18px;
--i: 1;
}
.brk__c--bl {
bottom: -8px;
left: -8px;
border-right: 0;
border-top: 0;
--ty: 18px;
--i: 2;
}
.brk__c--br {
bottom: -8px;
right: -8px;
border-left: 0;
border-top: 0;
--tx: 18px;
--ty: 18px;
--i: 3;
}
.brk.is-demo .brk__c {
animation: brk-close 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: calc(var(--i) * 60ms);
}
@keyframes brk-close {
0% {
translate: var(--tx) var(--ty);
opacity: 0;
}
38%, 66% {
translate: 0 0;
opacity: 1;
}
100% {
translate: var(--tx) var(--ty);
opacity: 0;
}
}
.brk:focus-visible {
outline: none;
}
.brk:focus-visible .brk__c {
animation: none;
translate: 0 0;
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.brk.is-demo .brk__c {
animation: none;
translate: 0 0;
opacity: 1;
}
}import type { CSSProperties } from "react";
import styles from "./CornerBracketButton.module.scss";
export type CornerBracketButtonProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const corners = ["tl", "tr", "bl", "br"] as const;
export default function CornerBracketButton({
label = "Open settings",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: CornerBracketButtonProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button type="button" className={styles.brk} style={vars}>
{corners.map((c) => (
<span key={c} className={`${styles.c} ${styles[c]}`} aria-hidden="true" />
))}
<span className={styles.label}>{label}</span>
</button>
);
}08 · コーナーブラケットの照準 — 計測 累積面積 2.501% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
隅ごとに違うのは削る 2 辺と進む方向だけで、移動距離は 4 つとも 18px に揃えてあるため、枠を広げるのも狭めるのも数値 1 つで済みます。
.brk__c {
position: absolute;
width: 22px;
height: 22px;
border: 3px solid $color;
--tx: -18px;
--ty: -18px;
}
.brk__c--tr { top: -8px; right: -8px; border-left: 0; border-bottom: 0; --tx: 18px; --i: 1; }
.brk.is-demo .brk__c {
animation: brk-close $duration $easing infinite;
animation-delay: calc(var(--i) * 60ms);
}
@keyframes brk-close {
0% { translate: var(--tx) var(--ty); opacity: 0; }
38%, 66% { translate: 0 0; opacity: 1; }
100% { translate: var(--tx) var(--ty); opacity: 0; }
}
09ローディングモーフ(ボタン→スピナー→チェック)
どんな動きか
押すと文字が消え、ボタンが円になるまで縮んでスピナーが回り、チェックを描いてから元の幅へ戻ります。

<main class="stage">
<button class="morph is-demo" type="button">
<span class="morph__label">支払う</span>
<span class="morph__spin" aria-hidden="true"></span>
<svg class="morph__check" viewBox="0 0 24 24" aria-hidden="true"><polyline points="5 12.5 10 17.5 19 7.5"/></svg>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형은 이 세 줄만 바꾼다
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
.morph {
position: relative;
display: grid;
place-items: center;
width: 200px;
height: 56px;
border: 0;
cursor: pointer;
padding: 0;
border-radius: $r-pill;
background: $color;
color: $stage-orange;
font-family: inherit;
font-size: 18px;
font-weight: 800;
overflow: hidden;
}
.morph__label,
.morph__spin,
.morph__check { grid-area: 1 / 1; }
.morph__spin {
width: 24px;
height: 24px;
border: 3px solid rgba($stage-orange, .25);
border-top-color: $stage-orange;
border-radius: 50%;
opacity: 0;
}
.morph__check {
width: 26px;
height: 26px;
fill: none;
stroke: $stage-orange;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 26;
stroke-dashoffset: 26;
opacity: 0;
}
.morph.is-demo { animation: morph-box $duration $easing infinite; }
.morph.is-demo .morph__label { animation: morph-label $duration $easing infinite; }
.morph.is-demo .morph__spin { animation: morph-spin-in $duration $easing infinite, morph-turn 800ms linear infinite; }
.morph.is-demo .morph__check { animation: morph-draw $duration $easing infinite; }
// 200 → 56 → 200. 마지막이 처음과 같은 폭이라 한 바퀴가 이어진다
@keyframes morph-box {
0%, 10% { width: 200px; }
28%, 62% { width: 56px; }
84%, 100% { width: 200px; }
}
@keyframes morph-label {
0%, 8% { opacity: 1; }
20%, 80% { opacity: 0; }
92%, 100% { opacity: 1; }
}
@keyframes morph-spin-in {
0%, 22% { opacity: 0; }
30%, 56% { opacity: 1; }
62%, 100% { opacity: 0; }
}
// 360deg 로 끝나 다음 바퀴와 맞물린다
@keyframes morph-turn {
to { rotate: 360deg; }
}
@keyframes morph-draw {
0%, 60% { stroke-dashoffset: 26; opacity: 0; }
64% { stroke-dashoffset: 26; opacity: 1; }
76%, 86% { stroke-dashoffset: 0; opacity: 1; }
94%, 100% { stroke-dashoffset: 26; opacity: 0; }
}
// 실제 클릭 상태 — 데모 루프가 아니라 사람이 눌렀을 때
.morph.is-busy { width: 56px; }
.morph.is-busy .morph__label { opacity: 0; }
.morph.is-busy .morph__spin { opacity: 1; animation: morph-turn 800ms linear infinite; }
.morph.is-done .morph__check { opacity: 1; stroke-dashoffset: 0; transition: stroke-dashoffset $dur-enter $easing; }
.morph.is-done .morph__label { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
.morph.is-demo,
.morph.is-demo .morph__label,
.morph.is-demo .morph__spin,
.morph.is-demo .morph__check { animation: none; }
.morph.is-busy .morph__spin { animation: 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);
}
.morph {
position: relative;
display: grid;
place-items: center;
width: 200px;
height: 56px;
border: 0;
cursor: pointer;
padding: 0;
border-radius: 999px;
background: #fff7e6;
color: #ff4d1f;
font-family: inherit;
font-size: 18px;
font-weight: 800;
overflow: hidden;
}
.morph__label,
.morph__spin,
.morph__check {
grid-area: 1/1;
}
.morph__spin {
width: 24px;
height: 24px;
border: 3px solid rgba(255, 77, 31, 0.25);
border-top-color: #ff4d1f;
border-radius: 50%;
opacity: 0;
}
.morph__check {
width: 26px;
height: 26px;
fill: none;
stroke: #ff4d1f;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 26;
stroke-dashoffset: 26;
opacity: 0;
}
.morph.is-demo {
animation: morph-box 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.morph.is-demo .morph__label {
animation: morph-label 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.morph.is-demo .morph__spin {
animation: morph-spin-in 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite, morph-turn 800ms linear infinite;
}
.morph.is-demo .morph__check {
animation: morph-draw 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes morph-box {
0%, 10% {
width: 200px;
}
28%, 62% {
width: 56px;
}
84%, 100% {
width: 200px;
}
}
@keyframes morph-label {
0%, 8% {
opacity: 1;
}
20%, 80% {
opacity: 0;
}
92%, 100% {
opacity: 1;
}
}
@keyframes morph-spin-in {
0%, 22% {
opacity: 0;
}
30%, 56% {
opacity: 1;
}
62%, 100% {
opacity: 0;
}
}
@keyframes morph-turn {
to {
rotate: 360deg;
}
}
@keyframes morph-draw {
0%, 60% {
stroke-dashoffset: 26;
opacity: 0;
}
64% {
stroke-dashoffset: 26;
opacity: 1;
}
76%, 86% {
stroke-dashoffset: 0;
opacity: 1;
}
94%, 100% {
stroke-dashoffset: 26;
opacity: 0;
}
}
.morph.is-busy {
width: 56px;
}
.morph.is-busy .morph__label {
opacity: 0;
}
.morph.is-busy .morph__spin {
opacity: 1;
animation: morph-turn 800ms linear infinite;
}
.morph.is-done .morph__check {
opacity: 1;
stroke-dashoffset: 0;
transition: stroke-dashoffset 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.morph.is-done .morph__label {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.morph.is-demo,
.morph.is-demo .morph__label,
.morph.is-demo .morph__spin,
.morph.is-demo .morph__check {
animation: none;
}
.morph.is-busy .morph__spin {
animation: none;
}
}// 실제 동작: 누르면 aria-busy 로 상태를 알리고 1.2초 뒤 체크로 끝난다. 그동안 두 번 눌리지 않는다.
var morph = document.querySelector('.morph');
morph.addEventListener('click', function () {
if (morph.classList.contains('is-busy')) { return; }
morph.classList.remove('is-demo');
morph.classList.add('is-busy');
morph.setAttribute('aria-busy', 'true');
setTimeout(function () {
morph.classList.remove('is-busy');
morph.classList.add('is-done');
morph.removeAttribute('aria-busy');
}, 1200);
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StateMorphButton.module.scss";
export type StateMorphButtonProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
onSubmit?: () => Promise<void>;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function StateMorphButton({
label = "Pay now",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
onSubmit,
}: StateMorphButtonProps) {
const [state, setState] = useState<"idle" | "busy" | "done">("idle");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function run() {
if (state === "busy") { return; }
setState("busy");
const job = onSubmit ? onSubmit() : new Promise<void>((r) => setTimeout(r, 1200));
job.then(() => setState("done"));
}
const cls = state === "busy" ? styles.busy : state === "done" ? styles.done : styles.loop;
return (
<button
type="button"
className={`${styles.morph} ${cls}`}
style={vars}
aria-busy={state === "busy"}
onClick={run}
>
<span className={styles.label}>{label}</span>
<span className={styles.spin} aria-hidden="true" />
<svg className={styles.check} viewBox="0 0 24 24" aria-hidden="true">
<polyline points="5 12.5 10 17.5 19 7.5" />
</svg>
</button>
);
}09 · ローディングモーフ(ボタン→スピナー→チェック) — 計測 累積面積 6.331% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
3 つの状態が 1 つの要素の中でつながるので、別のローディング画面を出す必要がなく、処理中の二重送信も防げます。
@keyframes morph-box {
0%, 10% { width: 200px; }
28%, 62% { width: 56px; }
84%, 100% { width: 200px; }
}
@keyframes morph-draw {
0%, 60% { stroke-dashoffset: 26; opacity: 0; }
64% { stroke-dashoffset: 26; opacity: 1; }
76%, 86% { stroke-dashoffset: 0; opacity: 1; }
94%, 100% { stroke-dashoffset: 26; opacity: 0; }
}
.morph.is-busy { width: 56px; }
.morph.is-busy .morph__label { opacity: 0; }
.morph.is-busy .morph__spin { opacity: 1; animation: morph-turn 800ms linear infinite; }
どこで壊れるか — 落とし穴
9 つの中でいちばん手こずったのは、01 と 02 がまったく動かなかったことです。--mx や --angle をキーフレームに書けば滑らかに変わるはずだと思っていましたが、ブラウザは登録されていないカスタムプロパティをただの文字列として扱います。文字列には中間値がないので値は途中を飛ばし、24 フレーム書き出してみると光が 2 か所に止まったまま消えていました。@property で syntax: "<percentage>" や "<angle>" と型を宣言して、ようやくランナーが一周しました。
2 つ目は、ループの終わりと始まりがずれる継ぎ目です。02 のランナーを 340deg で切って 0deg に戻したところ、一周ごとに光が飛んで見えたため、角度をきっちり 360deg まで回して次の周の 0deg とつなげました。06 のアイコンも 180deg で止めて巻き戻すと裏面が手前へ滑って見えたので、こちらも 360deg まで続けています。04 は逆に速すぎました。最初は 320px の水滴を使っていたため、レンダリングの 5 フレーム目にはボタンが満ちてしまい波打つ水面が写らず、260px に縮めて上昇の区間を全体の 52% まで伸ばして、ようやく曲がった水面が文字を横切る場面をフレームに残せました。05 は窓の高さを 1.1em にしたせいで文字の下が切れてしまい、1.25em に上げています。ここまで直して、9 つとも 320px 幅の検査をはみ出し 0 で通りました。意図的な例外は 09 だけで、ここは transform ではなく width を動かします。レイアウトを再計算させる値なので普段は避けるべきですが、ピル型のボタンを本当の円にするには幅を縮めるしかないため、overflow: hidden と 1 マスのグリッドで文字が歪まないように押さえています。ボタン一つが待ち時間を示し始めると、画面全体がその役を担うローディング 画面 デザインと同じ問題に移ります。解凍パスワード cpm3zu6e で開く zip の中身にも、ここまでの修正がそのまま入っているので、ダウンロードしたファイルを開けば記事に載せたコードと同じものが読めます。
アクセシビリティ
prefers-reduced-motion: reduce では 9 つとも装飾のループを止めますが、合図は残します。光は中央で止まり、ランナーは落ち着いた枠線 1 本になり、液体は満ちた状態、4 つのパーツは閉じた状態で静止します。08 はマウスなしでも使えるように :focus-visible にも同じ形を用意し、09 は待っている間 aria-busy="true" で状態を伝え、終わったら外します。
| 状態 | 動きが有効なとき | reduced-motion のとき |
|---|---|---|
| 狙う(01・02・04・05・06) | 光・液体・文字が 2 秒周期で回る | 最終形で静止し、色のコントラストはそのまま |
| 押す(03・07) | 吸着と押し込みの反動が続く | 移動も拡大縮小もなく、色と影だけ |
| フォーカス・待機(08・09) | パーツが集まりチェックが描かれる | パーツは閉じたまま、チェックは即座に表示 |
カスタムプロパティをアニメーションさせるには先に型の登録が必要だというルールは、MDN の @property 解説にまとまっています。同じトリガーで動く効果はホバーのカテゴリに、申し込みボタン向けのものはCTA のカテゴリに集めてあります。
FAQ
ボタン アニメーション css のうち JavaScript なしで使えるのはいくつですか
9 つのうち 7 つは CSS だけで完結します。実際のポインター座標が必要なのは 01 と 03 だけで、どちらも短いバニラ JavaScript を数行書くだけです。その数行がしているのも CSS 変数に値を入れることだけなので、アニメーションライブラリもビルド工程も必要ありません。受賞級のテクを一行まで削った版はマイクロインタラクション CSSに集めてあります。
タッチ端末にはホバーがありませんが、そのまま使えますか
ホバーが起点になる 5 つ(01・02・04・05・06)はタップでは何も起きないため、タッチ主体の画面では 07 のように :active でも同じ結果が得られるものと組み合わせるのが安全です。07 と 09 は押下と待機の状態を使うので指でも同じように動き、08 は :focus-visible でキーボード操作にも対応します。
色と速度を自社ブランドに合わせるにはどこを直しますか
各 SCSS ファイル冒頭の 3 行、$duration・$easing・$color(05 だけは $color の代わりに $stagger)が調整ポイントのすべてです。React 版は同じ 3 つを props で受け取って CSS カスタムプロパティへ渡すので、呼び出すときに duration={1200} のように書くだけで済み、ファイルの他の部分に触れる必要はありません。









