css ボタン デザイン 9選 — キャンディグラデ・押し込み・ローディング
css ボタン デザインとは、ボタンの色・形、マウスを乗せたときと押したときの反応までを、コード数行で決める作業です。どこかのランディングページで見たキャンディのようなグラデーションボタンや、指でぐっと押せる立体ボタンを自分のサイトにそのまま移したいとき、この9種類は1ファイル貼るだけで済みます。
01 gradient-flow
02 shine-sweep
03 gooey-blob
04 border-draw
06 duotone-wipe
07 ripple-ring
08 push-3d
09 pill-loader
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 キャンディグラデーションボタン
- 02 光の帯スイープボタン
- 03 もちもちブロブボタン
- 04 枠が描かれるボタン
- 05 矢印スライドボタン
- 06 デュオトーンワイプボタン
- 07 リングリップルボタン
- 08 3D押し込みボタン
- 09 ピルローダーボタン
9個は人気順ではなく、ボタンが実際の画面でたどる順に並べてあります。まず目を引くキャンディグラデーション(01)・光の帯(02)・もちもちブロブ(03)。次に css ボタン ホバー デザインとして反応する枠の描画(04)・矢印スライド(05)・デュオトーンワイプ(06)。そしてクリックの瞬間のリングリップル(07)・3D押し込み(08)を経て、押したあとの待ち時間を示すピルローダー(09)で終わります。同じ土俵で最初の一歩だけを集めたのがCSS ボタン アニメーション 9選で、受賞作が繰り返し使うホバーの技法だけを拾ったのがcss ボタン ホバー アニメーション 9選です。
01キャンディグラデーションボタン
css ボタン デザイン コピペで何が見えるか
ボタンの3倍の幅があるキャンディ色の布を下に敷き、それを毎回ちょうど1周期ぶん左へ押し出します。動くのはtranslateXひとつだけなのでレイアウト計算はなく、周期がきっちり240pxなので、ストライプのつなぎ目は見えません。

<main class="stage is-demo">
<button class="btn" type="button">
<span class="btn__bg" aria-hidden="true"></span>
<span class="btn__label">メンバーになる</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 그라데이션 한 주기
$easing: linear; // 흐름은 일정한 속도로
$color: #ff9ecb; // 캔디 핑크 (시작색)
// 한 주기 240px — 세 색이 6px 여백을 두고 스며든다
$candy-b: #d9d4ea; // 바이올렛
$candy-c: #cddfe6; // 스카이
@include stage($stage-ink);
.btn {
box-sizing: border-box;
position: relative;
width: 240px;
height: 64px;
border: none;
border-radius: $r-pill;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 18px;
cursor: pointer;
overflow: hidden; // 3배 그라데이션이 밖으로 새지 않게
box-shadow: $shadow-raised;
transition: transform $dur-quick $ease-spring, box-shadow $dur-quick $ease-spring;
}
// 3배 폭 그라데이션 층 — 한 주기(240px)씩 translateX 로 흐른다
.btn__bg {
position: absolute;
top: 0; bottom: 0; left: 0;
width: 720px;
background: repeating-linear-gradient(to right,
$color 0 58px, $candy-b 64px 118px, $candy-c 124px 178px, $color 184px 240px);
}
.btn__label { position: relative; z-index: 1; }
// 실사용: 마우스를 올리면 살짝 들리고 흐름은 그대로
.btn:hover { transform: translateY(-2px); box-shadow: 0 18px 28px rgba(23, 20, 26, .4); }
.btn:focus-visible { outline: 3px solid $stage-yellow; outline-offset: 4px; }
// 격자 미리보기: transform 만 이동 (레이아웃 재계산 없음)
.stage.is-demo .btn__bg {
animation: gradient-flow $duration $easing infinite;
}
@keyframes gradient-flow {
0% { transform: translateX(0); }
100% { transform: translateX(-240px); } // 정확히 한 주기 = 이음새 없는 루프
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .btn__bg { animation: none; transform: translateX(-120px); }
}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);
}
.btn {
box-sizing: border-box;
position: relative;
width: 240px;
height: 64px;
border: none;
border-radius: 999px;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 18px;
cursor: pointer;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.btn__bg {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 720px;
background: repeating-linear-gradient(to right, #ff9ecb 0 58px, #d9d4ea 64px 118px, #cddfe6 124px 178px, #ff9ecb 184px 240px);
}
.btn__label {
position: relative;
z-index: 1;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 18px 28px rgba(23, 20, 26, 0.4);
}
.btn:focus-visible {
outline: 3px solid #ffd23f;
outline-offset: 4px;
}
.stage.is-demo .btn__bg {
animation: gradient-flow 2s linear infinite;
}
@keyframes gradient-flow {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-240px);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .btn__bg {
animation: none;
transform: translateX(-120px);
}
}import type { CSSProperties } from "react";
import styles from "./GradientFlowButton.module.scss";
export type GradientFlowButtonProps = {
duration?: number;
easing?: string;
color?: string;
candyB?: string;
candyC?: string;
label?: string;
};
export default function GradientFlowButton({
duration = 2000,
easing = "linear",
color = "#ff9ecb",
candyB = "#a78bfa",
candyC = "#7cd8ff",
label = "멤버 되기",
}: GradientFlowButtonProps) {
const vars: CSSProperties = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--candy-b": candyB,
"--candy-c": candyC,
} as CSSProperties;
return (
<button className={styles.btn} style={vars} type="button">
<span className={styles.bg} aria-hidden="true" />
<span className={styles.label}>{label}</span>
</button>
);
}01 · キャンディグラデーションボタン — 計測 累積面積 9.899% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
ランディングページの最初の画面の登録ボタンに合います。
.btn__bg {
position: absolute;
top: 0; bottom: 0; left: 0;
width: 720px;
background: repeating-linear-gradient(to right,
$color 0 58px, $candy-b 64px 118px, $candy-c 124px 178px, $color 184px 240px);
}
.stage.is-demo .btn__bg { animation: gradient-flow $duration $easing infinite; }
@keyframes gradient-flow {
0% { transform: translateX(0); }
100% { transform: translateX(-240px); }
}
02光の帯スイープボタン
画面で起きること
暗いボタンの上を、斜めに傾けた白い光の帯が一度横切り、ガラスに光が差すようなハイライトを残します。帯は::afterの疑似要素で、skewX(-20deg)で斜めに寝かせてtranslateXだけで動かすので、ボタンの幅が変わってもそのまま動きます。

<main class="stage is-demo">
<button class="btn" type="button"><span class="btn__label">今すぐ購入</span></button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 스윕 한 주기
$easing: $ease-out;
$color: $subject-ink; // 버튼 바탕
@include stage($stage-yellow);
.btn {
box-sizing: border-box;
position: relative;
width: 240px;
height: 64px;
border: none;
border-radius: $r-control;
background: $color;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 17px;
cursor: pointer;
overflow: hidden; // 빛 띠가 밖으로 새지 않게
box-shadow: $shadow-raised;
}
.btn__label { position: relative; z-index: 1; }
// 기울어진 흰 빛 띠 — transform 만으로 통과시킨다
.btn::after {
content: "";
position: absolute;
top: 0; bottom: 0;
left: -60%;
width: 60%;
background: linear-gradient(115deg,
transparent 0%, rgba(255, 255, 255, .85) 50%, transparent 100%);
transform: skewX(-20deg) translateX(-10%);
}
// 실사용: 올릴 때마다 한 번 쓸고 지나감
.btn:hover::after { animation: sweep $dur-enter $easing 1; }
.btn:focus-visible { outline: 3px solid $subject-ink; outline-offset: 4px; }
// 격자 미리보기: 2초 루프로 강제 발동
.stage.is-demo .btn::after { animation: sweep $duration $easing infinite; }
@keyframes sweep {
0%, 10% { transform: skewX(-20deg) translateX(-10%); }
55% { transform: skewX(-20deg) translateX(310%); }
100% { transform: skewX(-20deg) translateX(310%); }
}
@media (prefers-reduced-motion: reduce) {
.btn:hover::after, .stage.is-demo .btn::after { animation: none; transform: skewX(-20deg) translateX(-10%); }
}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);
}
.btn {
box-sizing: border-box;
position: relative;
width: 240px;
height: 64px;
border: none;
border-radius: 12px;
background: #17141a;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 17px;
cursor: pointer;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.btn__label {
position: relative;
z-index: 1;
}
.btn::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: -60%;
width: 60%;
background: linear-gradient(115deg, transparent 0%, rgba(255, 255, 255, 0.85) 50%, transparent 100%);
transform: skewX(-20deg) translateX(-10%);
}
.btn:hover::after {
animation: sweep 600ms ease-out 1;
}
.btn:focus-visible {
outline: 3px solid #17141a;
outline-offset: 4px;
}
.stage.is-demo .btn::after {
animation: sweep 2s ease-out infinite;
}
@keyframes sweep {
0%, 10% {
transform: skewX(-20deg) translateX(-10%);
}
55% {
transform: skewX(-20deg) translateX(310%);
}
100% {
transform: skewX(-20deg) translateX(310%);
}
}
@media (prefers-reduced-motion: reduce) {
.btn:hover::after, .stage.is-demo .btn::after {
animation: none;
transform: skewX(-20deg) translateX(-10%);
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./ShineSweepButton.module.scss";
export type ShineSweepButtonProps = {
duration?: number;
easing?: string;
color?: string;
children?: ReactNode;
};
export default function ShineSweepButton({
duration = 600,
easing = "ease-out",
color = "#17141a",
children = "지금 구매하기",
}: ShineSweepButtonProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<button className={styles.btn} style={vars} type="button">
<span className={styles.label}>{children}</span>
</button>
);
}02 · 光の帯スイープボタン — 計測 累積面積 10.349% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
もう一度目を引きたい決済・購入ボタンにぴったりです。
.btn::after {
content: "";
position: absolute;
top: 0; bottom: 0; left: -60%;
width: 60%;
background: linear-gradient(115deg,
transparent 0%, rgba(255, 255, 255, .85) 50%, transparent 100%);
transform: skewX(-20deg) translateX(-10%);
}
@keyframes sweep {
0%, 10% { transform: skewX(-20deg) translateX(-10%); }
55% { transform: skewX(-20deg) translateX(310%); }
100% { transform: skewX(-20deg) translateX(310%); }
}
03もちもちブロブボタン
どんな動きか
ラベルの裏に隠れていたミント色の丸が左右に膨らみ、ボタンの輪郭がゼリーのように伸びたり縮んだりします。丸はボタンと同じ色なので境界がひとつに溶け、膨らみはscaleひとつで表します。

<main class="stage is-demo">
<button class="btn" type="button"><span class="btn__label">入れる</span></button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 부풀기 한 주기
$easing: $ease-pop; // 튕기듯 말랑하게
$color: #bcded4; // 민트
@include stage($stage-orange);
.btn {
box-sizing: border-box;
position: relative;
width: 240px;
height: 64px;
border: none;
border-radius: $r-pill;
background: $color;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 18px;
cursor: pointer;
}
// 라벨은 블롭 위에
.btn__label { position: relative; z-index: 1; }
// 좌우 블롭 — 버튼과 같은 색이라 윤곽이 합쳐져 말랑하게 늘어나 보인다
.btn::before,
.btn::after {
content: "";
position: absolute;
top: 50%;
width: 56px;
height: 56px;
border-radius: 50%;
background: $color;
transform: translateY(-50%) scale(.3);
}
.btn::before { left: -4px; }
.btn::after { right: -4px; }
// 실사용: 올리면 좌우로 부풀고, 크기가 커질수록 말랑함이 커진다
.btn:hover::before { transform: translateY(-50%) scale(1.25) translateX(-8px); }
.btn:hover::after { transform: translateY(-50%) scale(1.25) translateX(8px); }
.btn::before, .btn::after { transition: transform $dur-base $easing; }
.btn:focus-visible { outline: 3px solid $subject-ink; outline-offset: 4px; }
// 격자 미리보기: 말랑-주물럭 루프
.stage.is-demo .btn::before { animation: blob-l $duration $easing infinite; }
.stage.is-demo .btn::after { animation: blob-r $duration $easing infinite; }
@keyframes blob-l {
0%, 12% { transform: translateY(-50%) scale(.3); }
40% { transform: translateY(-50%) scale(1.3) translateX(-8px); }
68%, 82% { transform: translateY(-50%) scale(1.15) translateX(-4px); }
100% { transform: translateY(-50%) scale(.3); }
}
@keyframes blob-r {
0%, 12% { transform: translateY(-50%) scale(.3); }
40% { transform: translateY(-50%) scale(1.3) translateX(8px); }
68%, 82% { transform: translateY(-50%) scale(1.15) translateX(4px); }
100% { transform: translateY(-50%) scale(.3); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .btn::before, .stage.is-demo .btn::after { 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);
}
.btn {
box-sizing: border-box;
position: relative;
width: 240px;
height: 64px;
border: none;
border-radius: 999px;
background: #bcded4;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 18px;
cursor: pointer;
}
.btn__label {
position: relative;
z-index: 1;
}
.btn::before,
.btn::after {
content: "";
position: absolute;
top: 50%;
width: 56px;
height: 56px;
border-radius: 50%;
background: #bcded4;
transform: translateY(-50%) scale(0.3);
}
.btn::before {
left: -4px;
}
.btn::after {
right: -4px;
}
.btn:hover::before {
transform: translateY(-50%) scale(1.25) translateX(-8px);
}
.btn:hover::after {
transform: translateY(-50%) scale(1.25) translateX(8px);
}
.btn::before, .btn::after {
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.btn:focus-visible {
outline: 3px solid #17141a;
outline-offset: 4px;
}
.stage.is-demo .btn::before {
animation: blob-l 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.stage.is-demo .btn::after {
animation: blob-r 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes blob-l {
0%, 12% {
transform: translateY(-50%) scale(0.3);
}
40% {
transform: translateY(-50%) scale(1.3) translateX(-8px);
}
68%, 82% {
transform: translateY(-50%) scale(1.15) translateX(-4px);
}
100% {
transform: translateY(-50%) scale(0.3);
}
}
@keyframes blob-r {
0%, 12% {
transform: translateY(-50%) scale(0.3);
}
40% {
transform: translateY(-50%) scale(1.3) translateX(8px);
}
68%, 82% {
transform: translateY(-50%) scale(1.15) translateX(4px);
}
100% {
transform: translateY(-50%) scale(0.3);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .btn::before, .stage.is-demo .btn::after {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./GooeyBlobButton.module.scss";
export type GooeyBlobButtonProps = {
duration?: number;
easing?: string;
color?: string;
label?: string;
};
export default function GooeyBlobButton({
duration = 300,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#7fe3c4",
label = "담기",
}: GooeyBlobButtonProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<button className={styles.btn} style={vars} type="button">
<span className={styles.label}>{label}</span>
</button>
);
}03 · もちもちブロブボタン — 計測 累積面積 3.052% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
子ども向けショップのカート追加ボタンによく合います。
.btn::before, .btn::after {
content: "";
position: absolute;
top: 50%;
width: 56px; height: 56px;
border-radius: 50%;
background: $color;
transform: translateY(-50%) scale(.3);
}
.stage.is-demo .btn::before { animation: blob-l $duration $easing infinite; }
@keyframes blob-l {
0%, 12% { transform: translateY(-50%) scale(.3); }
40% { transform: translateY(-50%) scale(1.3) translateX(-8px); }
100% { transform: translateY(-50%) scale(.3); }
}
04枠が描かれるボタン
何が見えるか
ボタンの周りを手描きのように1ストロークずつ描き込みます。SVGの四角にpathLength="100"属性を付けると、実際の長さが何ピクセルでも100目盛りの定規で数えられるので、stroke-dashoffsetを100から0へ減らすだけで描画が完成します。

<main class="stage is-demo">
<button class="btn" type="button">
<span class="btn__fill" aria-hidden="true"></span>
<span class="btn__label">プロジェクト相談</span>
<svg class="btn__frame" viewBox="0 0 240 64" aria-hidden="true" focusable="false">
<rect class="btn__rect" x="1.5" y="1.5" width="237" height="61" rx="12"
pathLength="100" vector-effect="non-scaling-stroke" />
</svg>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 그리기 한 주기
$easing: $ease-out;
$color: $subject-blue; // 테두리 색
@include stage($stage-paper);
.btn {
box-sizing: border-box;
position: relative;
width: 240px;
height: 64px;
border: none;
background: none;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 17px;
cursor: pointer;
}
// 그려진 뒤 채워지는 옅은 물감
.btn__fill {
position: absolute;
inset: 0;
border-radius: $r-control;
background: rgba($color, .14);
opacity: 0;
}
.btn__label { position: relative; z-index: 1; }
.btn__frame { position: absolute; inset: 0; width: 100%; height: 100%; }
// pathLength=100 — 실제 길이와 무관하게 100 칸으로 정규화
.btn__rect {
fill: none;
stroke: $color;
stroke-width: 3;
stroke-dasharray: 100;
stroke-dashoffset: 100;
}
// 실사용: 올리면 한 번 그려지고 물감이 따라 채워진다
.btn:hover .btn__rect { animation: draw-hover $dur-enter $easing 1 forwards; }
.btn__fill { transition: opacity $dur-base $easing $dur-quick; }
.btn:hover .btn__fill { opacity: 1; }
.btn:focus-visible { outline: 3px solid $color; outline-offset: 4px; }
// 격자 미리보기: 그리기 → 채움 → 지우기 루프
.stage.is-demo .btn__rect { animation: draw $duration $easing infinite; }
.stage.is-demo .btn__fill { animation: fill-in $duration $easing infinite; }
@keyframes draw {
0%, 12% { stroke-dashoffset: 100; }
55% { stroke-dashoffset: 0; }
92% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: 100; }
}
@keyframes draw-hover {
0% { stroke-dashoffset: 100; }
100% { stroke-dashoffset: 0; }
}
@keyframes fill-in {
0%, 55% { opacity: 0; }
72%, 92% { opacity: 1; }
100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.btn:hover .btn__rect, .stage.is-demo .btn__rect { animation: none; stroke-dashoffset: 0; }
.btn:hover .btn__fill, .stage.is-demo .btn__fill { animation: none; opacity: 1; }
}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);
}
.btn {
box-sizing: border-box;
position: relative;
width: 240px;
height: 64px;
border: none;
background: none;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 17px;
cursor: pointer;
}
.btn__fill {
position: absolute;
inset: 0;
border-radius: 12px;
background: rgba(47, 109, 246, 0.14);
opacity: 0;
}
.btn__label {
position: relative;
z-index: 1;
}
.btn__frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.btn__rect {
fill: none;
stroke: #2f6df6;
stroke-width: 3;
stroke-dasharray: 100;
stroke-dashoffset: 100;
}
.btn:hover .btn__rect {
animation: draw-hover 600ms ease-out 1 forwards;
}
.btn__fill {
transition: opacity 300ms ease-out 220ms;
}
.btn:hover .btn__fill {
opacity: 1;
}
.btn:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 4px;
}
.stage.is-demo .btn__rect {
animation: draw 2s ease-out infinite;
}
.stage.is-demo .btn__fill {
animation: fill-in 2s ease-out infinite;
}
@keyframes draw {
0%, 12% {
stroke-dashoffset: 100;
}
55% {
stroke-dashoffset: 0;
}
92% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: 100;
}
}
@keyframes draw-hover {
0% {
stroke-dashoffset: 100;
}
100% {
stroke-dashoffset: 0;
}
}
@keyframes fill-in {
0%, 55% {
opacity: 0;
}
72%, 92% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.btn:hover .btn__rect, .stage.is-demo .btn__rect {
animation: none;
stroke-dashoffset: 0;
}
.btn:hover .btn__fill, .stage.is-demo .btn__fill {
animation: none;
opacity: 1;
}
}import type { CSSProperties } from "react";
import styles from "./BorderDrawButton.module.scss";
export type BorderDrawButtonProps = {
duration?: number;
easing?: string;
color?: string;
label?: string;
};
export default function BorderDrawButton({
duration = 600,
easing = "ease-out",
color = "#2f6df6",
label = "프로젝트 문의",
}: BorderDrawButtonProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<button className={styles.btn} style={vars} type="button">
<span className={styles.fill} aria-hidden="true" />
<span className={styles.label}>{label}</span>
<svg className={styles.frame} viewBox="0 0 240 64" aria-hidden="true" focusable="false">
<rect className={styles.rect} x="1.5" y="1.5" width="237" height="61" rx="12"
pathLength="100" vector-effect="non-scaling-stroke" />
</svg>
</button>
);
}04 · 枠が描かれるボタン — 計測 累積面積 10.201% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
描き終わったあと淡い絵の具が満ちるまでが1周期です。
.btn__rect {
fill: none;
stroke: $color;
stroke-width: 3;
stroke-dasharray: 100;
stroke-dashoffset: 100;
}
.stage.is-demo .btn__rect { animation: draw $duration $easing infinite; }
@keyframes draw {
0%, 12% { stroke-dashoffset: 100; }
55% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: 100; }
}
05矢印スライドボタン
画面で起きること
ラベルだけのマスと、ラベルに矢印が付いたマスの2つを並べ、まとめて半マスぶん押し出します。マウスを乗せると文字が左へ押し出され、矢印が右から定位置へ滑り込みます。トラックの幅を200%に固定してあるので、どんな文字の長さでもずれません。
<main class="stage is-demo">
<button class="btn" type="button">
<span class="btn__track">
<span class="btn__cell">続きを読む</span>
<span class="btn__cell">
続きを読む
<svg class="btn__arrow" viewBox="0 0 256 256" aria-hidden="true" focusable="false">
<g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g>
</svg>
</span>
</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 슬라이드 한 주기
$easing: $ease-spring;
$color: $stage-orange; // 화살표 포인트색
@include stage($stage-ink);
.btn {
box-sizing: border-box;
position: relative;
width: 240px;
height: 64px;
border: none;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 17px;
cursor: pointer;
overflow: hidden; // 두 칸 중 보이는 칸은 늘 하나
}
// 두 칸 트랙 — transform 으로 좌우 이동만 (레이아웃 고정)
.btn__track {
display: flex;
width: 200%;
height: 100%;
transition: transform $dur-base $easing;
}
.btn__cell {
box-sizing: border-box;
flex: 0 0 50%;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-2;
white-space: nowrap;
}
.btn__arrow {
width: 20px;
height: 20px;
color: $color; // Phosphor duotone: g fill="currentColor" 가 이 값을 물려받는다
}
// 실사용: 올리면 두 번째 칸(라벨+화살표)이 오른쪽에서 들어온다
.btn:hover .btn__track { transform: translateX(-50%); }
.btn:focus-visible { outline: 3px solid $stage-yellow; outline-offset: 4px; }
// 격자 미리보기: 밀었다 되돌아오는 루프
.stage.is-demo .btn__track { animation: slide-in $duration $easing infinite; }
@keyframes slide-in {
0%, 18% { transform: translateX(0); }
55%, 72% { transform: translateX(-50%); }
88% { transform: translateX(-4%); } // 살짝 되튕김
100% { transform: translateX(0); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .btn__track { animation: none; transform: translateX(-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);
}
.btn {
box-sizing: border-box;
position: relative;
width: 240px;
height: 64px;
border: none;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 17px;
cursor: pointer;
overflow: hidden;
}
.btn__track {
display: flex;
width: 200%;
height: 100%;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.btn__cell {
box-sizing: border-box;
flex: 0 0 50%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
white-space: nowrap;
}
.btn__arrow {
width: 20px;
height: 20px;
color: #ff4d1f;
}
.btn:hover .btn__track {
transform: translateX(-50%);
}
.btn:focus-visible {
outline: 3px solid #ffd23f;
outline-offset: 4px;
}
.stage.is-demo .btn__track {
animation: slide-in 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes slide-in {
0%, 18% {
transform: translateX(0);
}
55%, 72% {
transform: translateX(-50%);
}
88% {
transform: translateX(-4%);
}
100% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .btn__track {
animation: none;
transform: translateX(-50%);
}
}import type { CSSProperties } from "react";
import styles from "./IconSlideButton.module.scss";
export type IconSlideButtonProps = {
duration?: number;
easing?: string;
color?: string;
label?: string;
};
export default function IconSlideButton({
duration = 300,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#ff4d1f",
label = "소개 더보기",
}: IconSlideButtonProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<button className={styles.btn} style={vars} type="button">
<span className={styles.track}>
<span className={styles.cell}>{label}</span>
<span className={styles.cell}>
{label}
<svg className={styles.arrow} viewBox="0 0 256 256" aria-hidden="true" focusable="false">
<g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2" /><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z" /></g>
</svg>
</span>
</span>
</button>
);
}05 · 矢印スライドボタン — 計測 累積面積 2.354% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
次の内容へ連れて行く「続きを読む」に向いています。
.btn__track { display: flex; width: 200%; height: 100%; }
.btn__cell { flex: 0 0 50%; display: flex; align-items: center; }
.stage.is-demo .btn__track { animation: slide-in $duration $easing infinite; }
@keyframes slide-in {
0%, 18% { transform: translateX(0); }
55%, 72% { transform: translateX(-50%); }
88% { transform: translateX(-4%); }
100% { transform: translateX(0); }
}
06デュオトーンワイプボタン
どんな動きか
ボタンをクリームとピーチの2色に分け、下の色が左からscaleXで広がり、絵の具がにじむように全面が入れ替わります。文字も背景色に合わせて2枚重ねてopacityで交代するので、色が移る瞬間も読めたままです。

<main class="stage is-demo">
<button class="btn" type="button">
<span class="btn__wash" aria-hidden="true"></span>
<span class="btn__labels">
<span class="btn__t-on-dark">はじめる</span>
<span class="btn__t-on-light">はじめる</span>
</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 와이프 한 주기
$easing: $ease-spring;
$color: #e2cec4; // 피치 (아래에 깔리는 색)
@include stage($stage-yellow);
.btn {
box-sizing: border-box;
position: relative;
width: 240px;
height: 64px;
border: none;
border-radius: $r-pill;
background: $subject-cream; // 위에 보이는 색
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 18px;
cursor: pointer;
overflow: hidden;
box-shadow: $shadow-raised;
}
// 아래 색 레이어 — scaleX 로 왼쪽에서 펼쳐진다
.btn__wash {
position: absolute;
inset: 0;
background: $color;
transform: scaleX(0);
transform-origin: left; // 규칙8: 듀오톤 와이프 좌측 전개
transition: transform $dur-base $easing;
}
// 글자 두 벌 — 바탕색에 따라 보이는 쪽이 바뀐다
.btn__labels {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.btn__t-on-dark,
.btn__t-on-light {
grid-area: 1 / 1;
transition: opacity $dur-quick $easing;
}
.btn__t-on-dark { color: $subject-cream; opacity: 0; }
.btn__t-on-light { color: $subject-ink; }
// 실사용: 올리면 피치가 좌에서 우로 번지고 글자도 뒤집힌다
.btn:hover .btn__wash { transform: scaleX(1); }
.btn:hover .btn__t-on-dark { opacity: 1; }
.btn:hover .btn__t-on-light { opacity: 0; }
.btn:focus-visible { outline: 3px solid $subject-ink; outline-offset: 4px; }
// 격자 미리보기: 번짐 → 물러남 루프
.stage.is-demo .btn__wash { animation: wash $duration $easing infinite; }
.stage.is-demo .btn__t-on-dark { animation: swap-in $duration $easing infinite; }
.stage.is-demo .btn__t-on-light { animation: swap-out $duration $easing infinite; }
@keyframes wash {
0%, 12% { transform: scaleX(0); }
48%, 70% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
@keyframes swap-in {
0%, 18% { opacity: 0; }
48%, 70% { opacity: 1; }
88%,100% { opacity: 0; }
}
@keyframes swap-out {
0%, 18% { opacity: 1; }
48%, 70% { opacity: 0; }
88%,100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .btn__wash { animation: none; transform: scaleX(1); }
.stage.is-demo .btn__t-on-dark { animation: none; opacity: 1; }
.stage.is-demo .btn__t-on-light { animation: none; opacity: 0; }
}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);
}
.btn {
box-sizing: border-box;
position: relative;
width: 240px;
height: 64px;
border: none;
border-radius: 999px;
background: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 18px;
cursor: pointer;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.btn__wash {
position: absolute;
inset: 0;
background: #e2cec4;
transform: scaleX(0);
transform-origin: left;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.btn__labels {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.btn__t-on-dark,
.btn__t-on-light {
grid-area: 1/1;
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.btn__t-on-dark {
color: #fff7e6;
opacity: 0;
}
.btn__t-on-light {
color: #17141a;
}
.btn:hover .btn__wash {
transform: scaleX(1);
}
.btn:hover .btn__t-on-dark {
opacity: 1;
}
.btn:hover .btn__t-on-light {
opacity: 0;
}
.btn:focus-visible {
outline: 3px solid #17141a;
outline-offset: 4px;
}
.stage.is-demo .btn__wash {
animation: wash 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .btn__t-on-dark {
animation: swap-in 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .btn__t-on-light {
animation: swap-out 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes wash {
0%, 12% {
transform: scaleX(0);
}
48%, 70% {
transform: scaleX(1);
}
100% {
transform: scaleX(0);
}
}
@keyframes swap-in {
0%, 18% {
opacity: 0;
}
48%, 70% {
opacity: 1;
}
88%, 100% {
opacity: 0;
}
}
@keyframes swap-out {
0%, 18% {
opacity: 1;
}
48%, 70% {
opacity: 0;
}
88%, 100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .btn__wash {
animation: none;
transform: scaleX(1);
}
.stage.is-demo .btn__t-on-dark {
animation: none;
opacity: 1;
}
.stage.is-demo .btn__t-on-light {
animation: none;
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./DuotoneWipeButton.module.scss";
export type DuotoneWipeButtonProps = {
duration?: number;
easing?: string;
color?: string;
label?: string;
};
export default function DuotoneWipeButton({
duration = 300,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#ff9d6f",
label = "시작하기",
}: DuotoneWipeButtonProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<button className={styles.btn} style={vars} type="button">
<span className={styles.wash} aria-hidden="true" />
<span className={styles.labels}>
<span className={styles.tOnDark}>{label}</span>
<span className={styles.tOnLight}>{label}</span>
</span>
</button>
);
}06 · デュオトーンワイプボタン — 計測 累積面積 10.103% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
料金プランカードの「はじめる」に合います。
.btn__wash {
position: absolute;
inset: 0;
background: $color;
transform: scaleX(0);
transform-origin: left;
}
@keyframes wash {
0%, 12% { transform: scaleX(0); }
48%, 70% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
.stage.is-demo .btn__t-on-dark { animation: swap-in $duration $easing infinite; }
07リングリップルボタン
何が見えるか
押した瞬間、波ではなく鐘の音のように枠線のリングが外へ広がります。::afterに3pxの枠線の円を重ね、scaleで大きくしながらopacityで消していくだけなので、色を変えればどんなアイコンボタンにも乗せられます。

<main class="stage is-demo">
<button class="btn" type="button" aria-label="いいね">
<svg class="btn__heart" viewBox="0 0 256 256" aria-hidden="true" focusable="false">
<g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g>
</svg>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 핑 두 번
$easing: $ease-out;
$color: $subject-blue; // 링·하트 색
@include stage($stage-orange);
.btn {
box-sizing: border-box;
position: relative;
width: 112px;
height: 112px;
border: none;
border-radius: 50%;
background: $stage-paper;
cursor: pointer;
display: grid;
place-items: center;
box-shadow: $shadow-raised;
}
.btn__heart {
width: 44px;
height: 44px;
color: $color; // Phosphor duotone: g fill="currentColor" 가 이 값을 물려받는다
}
// 테두리 링 — 채워진 원이 아니라 굵은 선이 퍼진다
.btn::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid $color;
opacity: 0;
transform: scale(1);
}
// 실사용: 누르는 순간 한 번
.btn:active .btn__heart { animation: heart-pop $dur-base $ease-pop 1; }
.btn:active::after { animation: ring $dur-base $easing 1; }
.btn:focus-visible { outline: 3px solid $subject-ink; outline-offset: 4px; }
// 격자 미리보기: 2초에 두 번 종소리처럼 퍼진다
.stage.is-demo .btn__heart { animation: heart-pop $duration $ease-pop infinite; }
.stage.is-demo .btn::after { animation: ring $duration $easing infinite; }
@keyframes ring {
0% { transform: scale(1); opacity: .9; }
34% { transform: scale(1.8); opacity: 0; }
50% { transform: scale(1); opacity: .9; }
84% { transform: scale(1.8); opacity: 0; }
100% { transform: scale(1); opacity: 0; }
}
@keyframes heart-pop {
0%, 50% { transform: scale(1); }
8%, 58% { transform: scale(1.14); }
16%, 66% { transform: scale(.96); }
22%, 72% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .btn__heart, .stage.is-demo .btn::after { animation: none; }
.stage.is-demo .btn::after { opacity: 0; }
}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);
}
.btn {
box-sizing: border-box;
position: relative;
width: 112px;
height: 112px;
border: none;
border-radius: 50%;
background: #ffffff;
cursor: pointer;
display: grid;
place-items: center;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.btn__heart {
width: 44px;
height: 44px;
color: #2f6df6;
}
.btn::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid #2f6df6;
opacity: 0;
transform: scale(1);
}
.btn:active .btn__heart {
animation: heart-pop 300ms cubic-bezier(0.2, 1.4, 0.4, 1) 1;
}
.btn:active::after {
animation: ring 300ms ease-out 1;
}
.btn:focus-visible {
outline: 3px solid #17141a;
outline-offset: 4px;
}
.stage.is-demo .btn__heart {
animation: heart-pop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.stage.is-demo .btn::after {
animation: ring 2s ease-out infinite;
}
@keyframes ring {
0% {
transform: scale(1);
opacity: 0.9;
}
34% {
transform: scale(1.8);
opacity: 0;
}
50% {
transform: scale(1);
opacity: 0.9;
}
84% {
transform: scale(1.8);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@keyframes heart-pop {
0%, 50% {
transform: scale(1);
}
8%, 58% {
transform: scale(1.14);
}
16%, 66% {
transform: scale(0.96);
}
22%, 72% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .btn__heart, .stage.is-demo .btn::after {
animation: none;
}
.stage.is-demo .btn::after {
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./RippleRingButton.module.scss";
export type RippleRingButtonProps = {
duration?: number;
easing?: string;
color?: string;
label?: string;
};
export default function RippleRingButton({
duration = 300,
easing = "ease-out",
color = "#2f6df6",
label = "좋아요",
}: RippleRingButtonProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<button className={styles.btn} style={vars} type="button" aria-label={label}>
<svg className={styles.heart} viewBox="0 0 256 256" aria-hidden="true" focusable="false">
<g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2" /><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8" /></g>
</svg>
</button>
);
}07 · リングリップルボタン — 計測 累積面積 13.521% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
同じ瞬間ハートがぐっと膨らんで弾むので、いいね・ブックマークのトグルに合います。
.btn::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid $color;
opacity: 0;
}
@keyframes ring {
0% { transform: scale(1); opacity: .9; }
34% { transform: scale(1.8); opacity: 0; }
50% { transform: scale(1); opacity: .9; }
84% { transform: scale(1.8); opacity: 0; }
}
083D押し込みボタン
画面で起きること
ボタンの下に8pxの硬い影を敷いて物理的なキーのように見せ、押した瞬間その厚みぶんtranslateYで沈み込み、影が消えます。離すとき小さく跳ね返るまでをループに入れ、実使用では:activeに2行書くだけです。

<main class="stage is-demo">
<button class="btn" type="button">ゲームスタート</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 눌림 한 주기
$easing: $ease-out;
$color: #e7cfd8; // 캔디 핑크
$depth: 8px; // 두께 — 눌림 거리와 같다
@include stage($stage-paper);
.btn {
box-sizing: border-box;
width: 240px;
height: 64px;
border: none;
border-radius: $r-control;
background: $color;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 800;
font-size: 18px;
cursor: pointer;
// 두께(box-shadow) + 받침 그림자 — 누르면 둘 다 사라진다
box-shadow: 0 $depth 0 darken($color, 22%),
0 #{$depth + 6px} #{$sp-3} rgba(23, 20, 26, .22);
transition: transform $dur-quick $easing, box-shadow $dur-quick $easing;
}
// 실사용: 누르는 동안 꾹 들어간다
.btn:active {
transform: translateY($depth);
box-shadow: 0 0 0 darken($color, 22%),
0 2px 4px rgba(23, 20, 26, .22);
}
.btn:focus-visible { outline: 3px solid $subject-blue; outline-offset: 4px; }
// 격자 미리보기: 눌림 → 유지 → 튕겨 복귀 루프
.stage.is-demo .btn { animation: press $duration $easing infinite; }
@keyframes press {
0%, 20% {
transform: translateY(0);
box-shadow: 0 $depth 0 darken($color, 22%),
0 #{$depth + 6px} #{$sp-3} rgba(23, 20, 26, .22);
}
35%, 55% {
transform: translateY($depth);
box-shadow: 0 0 0 darken($color, 22%),
0 2px 4px rgba(23, 20, 26, .22);
}
70% {
transform: translateY(-3px);
box-shadow: 0 #{$depth + 3px} 0 darken($color, 22%),
0 #{$depth + 9px} #{$sp-3} rgba(23, 20, 26, .22);
}
85%, 100% {
transform: translateY(0);
box-shadow: 0 $depth 0 darken($color, 22%),
0 #{$depth + 6px} #{$sp-3} rgba(23, 20, 26, .22);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .btn { 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);
}
.btn {
box-sizing: border-box;
width: 240px;
height: 64px;
border: none;
border-radius: 12px;
background: #e7cfd8;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 800;
font-size: 18px;
cursor: pointer;
box-shadow: 0 8px 0 rgb(75.9215686275%, 51.8431372549%, 60.8725490196%), 0 14px 12px rgba(23, 20, 26, 0.22);
transition: transform 220ms ease-out, box-shadow 220ms ease-out;
}
.btn:active {
transform: translateY(8px);
box-shadow: 0 0 0 rgb(75.9215686275%, 51.8431372549%, 60.8725490196%), 0 2px 4px rgba(23, 20, 26, 0.22);
}
.btn:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 4px;
}
.stage.is-demo .btn {
animation: press 2s ease-out infinite;
}
@keyframes press {
0%, 20% {
transform: translateY(0);
box-shadow: 0 8px 0 rgb(75.9215686275%, 51.8431372549%, 60.8725490196%), 0 14px 12px rgba(23, 20, 26, 0.22);
}
35%, 55% {
transform: translateY(8px);
box-shadow: 0 0 0 rgb(75.9215686275%, 51.8431372549%, 60.8725490196%), 0 2px 4px rgba(23, 20, 26, 0.22);
}
70% {
transform: translateY(-3px);
box-shadow: 0 11px 0 rgb(75.9215686275%, 51.8431372549%, 60.8725490196%), 0 17px 12px rgba(23, 20, 26, 0.22);
}
85%, 100% {
transform: translateY(0);
box-shadow: 0 8px 0 rgb(75.9215686275%, 51.8431372549%, 60.8725490196%), 0 14px 12px rgba(23, 20, 26, 0.22);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .btn {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./Push3dButton.module.scss";
export type Push3dButtonProps = {
duration?: number;
easing?: string;
color?: string;
label?: string;
};
export default function Push3dButton({
duration = 220,
easing = "ease-out",
color = "#ff7fae",
label = "게임 시작",
}: Push3dButtonProps) {
const vars: CSSProperties = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--depth": "8px",
} as CSSProperties;
return (
<button className={styles.btn} style={vars} type="button">{label}</button>
);
}08 · 3D押し込みボタン — 計測 累積面積 5.983% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
ゲームのスタートや子ども向けアプリの大きなボタンに合います。
$depth: 8px;
.btn {
box-shadow: 0 $depth 0 darken($color, 22%),
0 #{$depth + 6px} #{$sp-3} rgba(23, 20, 26, .22);
}
.btn:active { transform: translateY($depth); }
@keyframes press {
0%, 20% { transform: translateY(0); }
35%, 55% { transform: translateY($depth); }
70% { transform: translateY(-3px); }
85%,100% { transform: translateY(0); }
}
09ピルローダーボタン
どんな動きか
ファイルを送る瞬間、ラベルがゆっくり消え、ボタンが濃い紺にロックされ、ピルの中の短いバーが左から右へ2回滑ります。バーはtranslateXだけで動き、待ち時間が終わればラベルが戻ります。

<main class="stage is-demo">
<button class="btn" type="button">
<span class="btn__label">ファイルを送信</span>
<span class="btn__loader" aria-hidden="true">
<span class="btn__track"><span class="btn__bar"></span></span>
</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 기다림 한 주기
$easing: ease-in-out;
$color: $subject-blue; // 버튼 색
$busy: #6f67b4; // 기다리는 동안 잠기는 진한 색
@include stage($stage-ink);
.btn {
box-sizing: border-box;
position: relative;
width: 240px;
height: 64px;
border: none;
border-radius: $r-pill;
background: $color;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 17px;
cursor: pointer;
transition: background-color $dur-quick $easing;
}
.btn__label {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
// 로더 — 라벨이 사라진 자리에 나타난다
.btn__loader {
position: absolute;
inset: 0;
display: grid;
place-items: center;
opacity: 0;
}
.btn__track {
position: relative;
width: 168px; // 버튼의 70%
height: 8px;
border-radius: $r-pill;
background: rgba($subject-cream, .25);
overflow: hidden;
}
.btn__bar {
position: absolute;
top: 0; bottom: 0;
width: 60px;
border-radius: $r-pill;
background: $subject-cream;
transform: translateX(-60px);
}
// 실사용: 보내는 동안 is-busy 클래스를 붙인다
.btn.is-busy { background: $busy; }
.btn.is-busy .btn__label { opacity: 0; }
.btn.is-busy .btn__loader { opacity: 1; }
.btn__label, .btn__loader { transition: opacity $dur-quick $easing; }
.btn.is-busy .btn__bar { animation: bar-run 1s $easing infinite; }
.btn:focus-visible { outline: 3px solid $stage-yellow; outline-offset: 4px; }
// 격자 미리보기: 라벨 사라짐 → 잠김 → 막대 두 번 왕복 → 복귀
.stage.is-demo .btn { animation: busy-bg $duration $easing infinite; }
.stage.is-demo .btn__label { animation: label-out $duration $easing infinite; }
.stage.is-demo .btn__loader { animation: loader-in $duration $easing infinite; }
.stage.is-demo .btn__bar { animation: bar-run 1s $easing infinite; }
@keyframes busy-bg {
0%, 15% { background: $color; }
25%, 85% { background: $busy; }
95%,100% { background: $color; }
}
@keyframes label-out {
0%, 15% { opacity: 1; }
25%, 85% { opacity: 0; }
95%,100% { opacity: 1; }
}
@keyframes loader-in {
0%, 15% { opacity: 0; }
25%, 85% { opacity: 1; }
95%,100% { opacity: 0; }
}
@keyframes bar-run {
0% { transform: translateX(-60px); }
100% { transform: translateX(168px); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .btn, .stage.is-demo .btn__label,
.stage.is-demo .btn__loader, .stage.is-demo .btn__bar { animation: none; }
.stage.is-demo .btn__bar { transform: translateX(54px); } // 가운데 정지
}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);
}
.btn {
box-sizing: border-box;
position: relative;
width: 240px;
height: 64px;
border: none;
border-radius: 999px;
background: #2f6df6;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 17px;
cursor: pointer;
transition: background-color 220ms ease-in-out;
}
.btn__label {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.btn__loader {
position: absolute;
inset: 0;
display: grid;
place-items: center;
opacity: 0;
}
.btn__track {
position: relative;
width: 168px;
height: 8px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.25);
overflow: hidden;
}
.btn__bar {
position: absolute;
top: 0;
bottom: 0;
width: 60px;
border-radius: 999px;
background: #fff7e6;
transform: translateX(-60px);
}
.btn.is-busy {
background: #6f67b4;
}
.btn.is-busy .btn__label {
opacity: 0;
}
.btn.is-busy .btn__loader {
opacity: 1;
}
.btn__label, .btn__loader {
transition: opacity 220ms ease-in-out;
}
.btn.is-busy .btn__bar {
animation: bar-run 1s ease-in-out infinite;
}
.btn:focus-visible {
outline: 3px solid #ffd23f;
outline-offset: 4px;
}
.stage.is-demo .btn {
animation: busy-bg 2s ease-in-out infinite;
}
.stage.is-demo .btn__label {
animation: label-out 2s ease-in-out infinite;
}
.stage.is-demo .btn__loader {
animation: loader-in 2s ease-in-out infinite;
}
.stage.is-demo .btn__bar {
animation: bar-run 1s ease-in-out infinite;
}
@keyframes busy-bg {
0%, 15% {
background: #2f6df6;
}
25%, 85% {
background: #6f67b4;
}
95%, 100% {
background: #2f6df6;
}
}
@keyframes label-out {
0%, 15% {
opacity: 1;
}
25%, 85% {
opacity: 0;
}
95%, 100% {
opacity: 1;
}
}
@keyframes loader-in {
0%, 15% {
opacity: 0;
}
25%, 85% {
opacity: 1;
}
95%, 100% {
opacity: 0;
}
}
@keyframes bar-run {
0% {
transform: translateX(-60px);
}
100% {
transform: translateX(168px);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .btn, .stage.is-demo .btn__label,
.stage.is-demo .btn__loader, .stage.is-demo .btn__bar {
animation: none;
}
.stage.is-demo .btn__bar {
transform: translateX(54px);
}
}import type { CSSProperties } from "react";
import styles from "./PillLoaderButton.module.scss";
export type PillLoaderButtonProps = {
duration?: number;
easing?: string;
color?: string;
busyColor?: string;
busy?: boolean;
label?: string;
};
export default function PillLoaderButton({
duration = 1000,
easing = "ease-in-out",
color = "#2f6df6",
busyColor = "#3a2bb8",
busy = false,
label = "파일 보내기",
}: PillLoaderButtonProps) {
const vars: CSSProperties = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--busy": busyColor,
} as CSSProperties;
return (
<button className={styles.btn} style={vars} data-busy={busy || undefined} type="button">
<span className={styles.label}>{label}</span>
<span className={styles.loader} aria-hidden="true">
<span className={styles.track}><span className={styles.bar} /></span>
</span>
</button>
);
}09 · ピルローダーボタン — 計測 累積面積 10.092% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
実使用では送信中にis-busyクラスを付けるだけです。
.btn__bar {
position: absolute;
top: 0; bottom: 0;
width: 60px;
border-radius: $r-pill;
background: $subject-cream;
transform: translateX(-60px);
}
@keyframes bar-run {
0% { transform: translateX(-60px); }
100% { transform: translateX(168px); }
}
@keyframes busy-bg {
0%, 15% { background: $color; }
25%, 85% { background: $busy; }
95%,100% { background: $color; }
}
どこで壊れるか — 柔らかいパステルは機械に見えない
01番は最初、今と逆の作りでした。背景グラデーションそのものの位置をbackground-positionで動かし、色もにじむパステルの組み合わせです。目には明らかに流れているのに、レンダリングされたフレームを機械で測ると強度0.0・動いたフレーム0枚でした。フレーム間でグラデーションが進む距離は10pxで、その間の色の変化が測定のしきい値を下回り、どのピクセルも変わっていないと数えられたのです。境目がはっきりしたストライプに描き直し、層ごとtranslateXで動かしてようやく測定を通しました。02番の光の帯の移動距離を帯の幅の数倍にしたのも同じ理由です。ボタンではなく画面全体が待つ場面はローディング 画面 デザイン CSSだけ 9選が引き受けます。9個のフォルダの圧縮パスワードは3vxxw8yhで、そのまま入力しないと開きません。グラデーションの周期(240px)と移動距離が正確に一致しなければループがつながらないことも、この項目で直接確かめました — 4pxずれただけで1周ごとに模様がずれます。
アクセシビリティ
9個すべてprefers-reduced-motion: reduceでanimation: noneに落ち、止まった後もそれが何なのか分かる静止画をそれぞれ決めてあります。01はストライプが見える位置で止まり、04は枠が描き終わった完成状態で止まり、05はラベルと矢印が一緒に見えるマスで止まります。09のバーは中央に停まり、色と形だけで「ロード中」を伝えます。すべてのボタンはキーボード操作時に:focus-visibleの枠を出します。設定の切り替え方はMDNのprefers-reduced-motionのページにあります。
FAQ
JavaScriptが分からなくても使えますか?
はい、この9個はすべてCSS(SCSS)だけで動き、JavaScriptは1行もありません。09も実使用ではボタンにis-busyクラスを付け外しするだけで済みます。最初の一歩のリップルやホバーはCSSボタンアニメーション9種の記事にあります。
css ボタン デザイン シンプルに保ったまま色を自分のブランド色に変えるにはどこを直しますか?
各項目のSCSS先頭の$color1つが基本色です。01番は$colorのほかに補助色が2つ、08番は濃い縁の色がdarken($color, 22%)で自動的に付いてきます。06番は下に敷く色が$colorなので、上の色を変えるときは背景の行も一緒に直します。
zipの中には何が入っていますか?
9項目それぞれのindex.html・style.scss・コンパイル済みのstyle.css、そしてReactプロジェクトにそのまま置ける.tsxと.module.scssの組です。duration・easing・colorがpropsとして抜けているので、値を変えればそのまま変種として使えます。他のボタン素材はサイト内検索から探せます。アイコン1つで成立する部品たちはフローティング ボタン アイコン 9選に集まっています。









