9 css button design ideas — candy, push, loading
css button design means deciding a button's colors, shape, and mouse reaction in a few lines of code.
01 gradient-flow
02 shine-sweep
03 gooey-blob
04 border-draw
06 duotone-wipe
07 ripple-ring
08 push-3d
09 pill-loader
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Candy gradient flow button
- 02 Shine sweep button
- 03 Gooey blob button
- 04 Border-draw button
- 05 Icon slide button
- 06 Duotone wipe button
- 07 Ripple ring button
- 08 3D push-down button
- 09 Pill loader button
The nine are not ranked by popularity; they follow the life of a button on a real screen. First the ones that catch the eye — candy gradient (01), shine sweep (02), gooey blob (03). Then the css button hover ones — border draw (04), icon slide (05), duotone wipe (06). Then the moment of the click — ripple ring (07), 3D push (08) — and finally the wait after the click, the pill loader (09). The first-steps-only cut of this same ground is 9 CSS Button Animations, and the hover techniques award sites keep reusing are quarried in 9 button hover effects css.
01Candy gradient flow button
What this css button design code draws
A candy-colored sheet three times wider than the button slides left by exactly one stripe period per loop. The only thing moving is a single translateX, so no layout is ever recalculated, and because the period is exactly 240px you never see the seam where the pattern repeats.

<main class="stage is-demo">
<button class="btn" type="button">
<span class="btn__bg" aria-hidden="true"></span>
<span class="btn__label">Join now</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 · Candy gradient flow button — Measured area 9.899% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
It fits the sign-up button on a landing-page hero.
.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); }
}
02Shine sweep button
What happens on screen
A tilted white band crosses the dark button once, like light glancing across glass. The band is an ::after pseudo-element, laid at an angle with skewX(-20deg) and moved only with translateX, so it keeps working no matter what width the button becomes.

<main class="stage is-demo">
<button class="btn" type="button"><span class="btn__label">Buy now</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 · Shine sweep button — Measured area 10.349% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Use it on checkout and buy buttons that deserve one more look.
.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%); }
}
03Gooey blob button
What moves
Two mint circles hidden behind the label swell left and right, so the button's silhouette stretches and shrinks like jelly. The circles share the button's exact color, so their edges merge into one outline, and swelling is a single scale.

<main class="stage is-demo">
<button class="btn" type="button"><span class="btn__label">Add</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 · Gooey blob button — Measured area 3.052% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
A natural fit for an add-to-cart button on a kids' shop.
.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); }
}
04Border-draw button
What you see
The button's outline draws itself in, stroke by stroke, as if by hand. Giving the SVG rectangle a pathLength="100" attribute turns any real length into a hundred-unit ruler, so dropping stroke-dashoffset from 100 to 0 is all it takes to draw the border.

<main class="stage is-demo">
<button class="btn" type="button">
<span class="btn__fill" aria-hidden="true"></span>
<span class="btn__label">Project inquiry</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 · Border-draw button — Measured area 10.201% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
A pale wash fills in right after, and that completes one cycle.
.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; }
}
05Icon slide button
What happens on screen
Two cells sit side by side — one with the label, one with the label plus an arrow — and the whole track shifts by half its width. On hover the text slides out to the left while the arrow glides into its place from the right, and since the track is fixed at 200% width, no label length can break the alignment.
<main class="stage is-demo">
<button class="btn" type="button">
<span class="btn__track">
<span class="btn__cell">Read more</span>
<span class="btn__cell">
Read more
<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 · Icon slide button — Measured area 2.354% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Good for read-more links that promise a next step.
.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); }
}
06Duotone wipe button
What moves
The button splits into cream and peach, and the lower color unfolds from the left with scaleX, spreading across the whole face like paint. The label exists twice, stacked, and swaps with opacity, so it stays readable through the moment of the wipe.

<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">Get started</span>
<span class="btn__t-on-light">Get started</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 · Duotone wipe button — Measured area 10.103% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Suits the get-started button on pricing cards.
.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; }
07Ripple ring button
What you see
At the click, instead of a wave, an outlined ring spreads outward the way a bell's sound carries. A 3px ring sits in ::after and grows with scale while opacity erases it, so changing one color value drops it onto any icon button.

<main class="stage is-demo">
<button class="btn" type="button" aria-label="Like">
<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 · Ripple ring button — Measured area 13.521% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The heart squashes and pops at the same instant, which makes it right for like and bookmark toggles.
.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 push-down button
What happens on screen
A hard 8px shadow is laid under the button so it reads as a physical key, and pressing it pushes the face down by that same 8px with translateY while the shadow vanishes. The loop adds a small rebound when it releases; in real use :active needs just two lines.

<main class="stage is-demo">
<button class="btn" type="button">Start game</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 push-down button — Measured area 5.983% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
It belongs on game-start buttons and big tap targets in kids' apps.
$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); }
}
09Pill loader button
What moves
The moment you send a file, the label fades, the button deepens to a locked indigo, and inside its pill a short bar slides across twice. The bar moves only with translateX, and when the wait ends the label returns.

<main class="stage is-demo">
<button class="btn" type="button">
<span class="btn__label">Send file</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 · Pill loader button — Measured area 10.092% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
In real use, toggling an is-busy class during your submit handler is all it takes.
.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; }
}
Where it breaks — soft pastels are invisible to the machine
Item 01 was originally built the other way around: the background gradient itself was moved with background-position, and the palette was a softly blending pastel set. To the eye it clearly flowed, yet machine-measuring the rendered frames returned intensity 0.0 and zero moving frames. Between frames the gradient traveled 10px, and the color change across that distance stayed under the measurement threshold, so no pixel counted as changed. Redrawing it with crisp-edged stripes and moving the whole layer with translateX is what finally passed. The sweep distance in 02 is likewise set to several times the band width for the same reason. The archive passWhen it is the whole screen that waits instead of one button, 9 Loading Screen Designs takes over. The password word for the nine folders is 3vxxw8yh — type it exactly or the zip will not open. The gradient's stripe period (240px) must also match the travel distance exactly, which this item proved the hard way: off by as little as 4px and the pattern jumps once per cycle.
Accessibility
All nine fall back to animation: none under prefers-reduced-motion: reduce, and each one gets a still frame that still explains itself. Item 01 stops at a position where the stripes show, item 04 stops with the border fully drawn, and item 05 stops on the cell where label and arrow sit together. Item 09 parks the bar in the middle so color and shape still say "loading". Every button shows a :focus-visible outline for keyboard users. How to toggle the setting is documented in MDN's prefers-reduced-motion page.
FAQ
Can I use these without knowing JavaScript?
Yes — all nine run on CSS (SCSS) alone, without a single line of JavaScript. Even 09 only needs an is-busy class added and removed on the button in real use. If you want the basics like ripples and hovers, the 9 CSS button animations post covers them.
Where do I change these css button styles to my brand color?
The $color variable at the top of each item's SCSS is the base. Item 01 adds two helper colors besides $color, and item 08 derives its dark edge automatically with darken($color, 22%). In item 06, $color is the under-layer peach, so change the button's background line too if you want to replace the top color.
What is inside the zip?
Each of the nine items ships its index.html, style.scss, and compiled style.css, plus a React-ready .tsx and .module.scss pair. Duration, easing, and color are exposed as props, so tweaking a value turns any of them into a variant. You can find more button material through site search. The pieces that stand on a single icon are assembled in 9 Icon Button CSS Patterns.









