css 버튼 디자인 9종 — 캔디 그라데이션·눌림·로딩 버튼 코드
css 버튼 디자인은 버튼의 색과 모양, 마우스를 올렀을 때와 눌렀을 때의 반응까지 코드 몇 줄로 정하는 일이다. 어느 랜딩 페이지에서 본 캔디 같은 그라데이션 버튼, 손끝에 꾹 눌리는 입체 버튼을 내 사이트에 그대로 옮기고 싶을 때 이 아홉 가지는 SCSS 한 파일 복붙으로 끝난다.
01 gradient-flow
02 shine-sweep
03 gooey-blob
04 border-draw
06 duotone-wipe
07 ripple-ring
08 push-3d
09 pill-loader
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 캔디 그라데이션 흐름 버튼
- 02 빛줄기 스윕 버튼
- 03 말랑 블롭 버튼
- 04 테두리 그려지는 버튼
- 05 화살표 슬라이드 버튼
- 06 듀오톤 와이프 버튼
- 07 링 리플 버튼
- 08 3D 눌림 버튼
- 09 버튼 안 필 로더
아홉 개는 인기순이 아니라 버튼이 실제 화면에서 겪는 순서로 놓았다. 먼저 시선을 붙잡는 캔디 그라데이션(01)·빛줄기(02)·말랑 블롭(03)을 지나, css 버튼 호버 자리에서 반응하는 테두리 그리기(04)·화살표 슬라이드(05)·듀오톤 와이프(06)가 따르고, 실제로 누르는 순간의 링 리플(07)·3D 눌림(08)을 거쳐 누른 뒤 기다리는 필 로더(09)로 끝난다. 같은 자리에서 움직임의 첫 걸음만 걷는 편이 CSS 버튼 애니메이션 9종이고, 수상작이 반복해서 쓰는 호버 기법만 추린 편이 버튼 hover 효과 모음 9종이다.
01캔디 그라데이션 흐름 버튼
css 버튼 만들기에서 무엇이 보이나
버튼보다 세 배 넓은 캔디색 천을 밑에 깔고 그것을 왼쪽으로 240px씩 밀어내는 방식이다. 움직이는 건 통째로 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
랜딩 첫 화면의 가입 버튼에 어울린다.
.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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
결제·구매처럼 한 번 더 눌러주고 싶은 버튼에 쓴다.
.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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
키즈샵의 장바구니 담기 같은 귀여운 자리에 맞다.
.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테두리 그려지는 버튼
무엇이 보이나
버튼 둘레를 손으로 그린 것처럼 한 획씩 그려 넣는다. SVG 네모 테두리에 pathLength="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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
다 그려진 뒤 옅은 물감이 차오르는 것까지가 한 주기다.
.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화살표 슬라이드 버튼
화면에서 일어나는 일
라벨만 있는 칸과 라벨에 화살표가 붙은 칸, 두 칸을 나란히 붙여 놓고 통째로 한 칸씩 민다. 마우스를 올리면 글자가 왼쪽으로 밀리면서 화살표가 오른쪽에서 제자리로 미끄러져 들어오고, 트랙의 폭을 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
블로그의 더보기처럼 다음 내용으로 데려가는 버튼에 좋다.
.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듀오톤 와이프 버튼
어떤 움직임인가
버튼을 크림과 피치 두 색으로 쪼개고, 아래 색이 왼쪽에서 scaleX 로 펼쳐지며 물감이 번지듯 통째로 바뀐다. 글자도 바탕색에 맞춰 두 벌을 겹쳐 놓고 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
요금제 카드의 시작하기처럼 골라 누르는 버튼에 어울린다.
.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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
같은 순간 하트가 튀어올랐다 살짝 눌리므로 좋아요·북마크 같은 토글에 맞다.
.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 에 두 줄만 있으면 된다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
게임 시작이나 아이들 앱의 큰 버튼에 잘 맞는다.
$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버튼 안 필 로더
어떤 움직임인가
파일을 보내는 순간 라벨이 서서히 사라지고 버튼 색이 진한 남색으로 잠긴 뒤, 그 안의 알약 모양 트랙에서 짧은 막대가 왼쪽에서 오른쪽으로 두 번 미끄러진다. 막대는 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
실제 사용에서는 제출 함수 동안 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번 빛 띠의 이동 거리도 밴드 폭의 몇 배로 잡았다. 버튼이 아니라 화면 전체가 기다리는 장면은 로딩페이지 디자인 9종이 떠안는다. 아홉 개 파일의 압축 비밀번호는 3vxxw8yh 이고, 틀리면 열리지 않으니 그대로 입력한다. 그라데이션 한 주기(240px)와 이동 거리가 정확히 같아야 루프가 이어지는 것도 이 항목에서 직접 확인했다 — 4px만 어긋나도 한 바퀴마다 무늬가 튄다.
접근성
아홉 개 전부 prefers-reduced-motion: reduce 에서 애니메이션이 꺼지고, 꺼진 뒤에도 각각 무엇인지 알 수 있는 정지 장면을 따로 정해 뒀다. 01은 줄무늬가 보이는 위치에 멈추고, 04는 테두리가 다 그려진 완성 상태로 멈추며, 05는 라벨과 화살표가 함께 보이는 칸에 멈춘다. 09의 막대는 가운데에 멈춰 로딩 중임을 색과 모양으로 알린다. 키보드로 접근할 때는 모든 버튼에 :focus-visible 테두리가 나타난다. 설정을 끄고 켜는 방법은 MDN 의 prefers-reduced-motion 문서 에 있다.
FAQ
자바스크립트를 몰라도 쓸 수 있나요?
네, 이 아홉 개는 전부 자바스크립트 한 줄 없이 CSS(SCSS)만으로 돌아간다. 09번도 실제 사용에서는 버튼에 is-busy 라는 클래스를 붙였다 떼는 것만으로 충분하다. 기본기부터 보고 싶다면 버튼 애니메이션 9종 글 에 리플·호버 같은 첫 걸음들이 있다.
css 버튼 효과의 색을 내 브랜드 색으로 바꾸려면 어디를 고치나요?
각 항목 SCSS 맨 위의 $color 하나가 기본 색이다. 01번은 $color 외에 보조색 두 개가 더 있고, 08번은 어두운 두께 색이 darken($color, 22%) 로 자동으로 따라온다. 06번은 아래에 깔리는 색이 $color 라서 위 색을 바꾸려면 배경색 줄을 함께 고친다.
zip 안에는 무엇이 들어있나요?
아홉 개 항목의 index.html 과 style.scss, 컴파일된 style.css, 그리고 React 프로젝트에 바로 넣을 수 있는 .tsx + .module.scss 아홉 쌍이 들어 있다. 변수는 기본적으로 duration·easing·color 세 개가 props 로 빠져 있고(01번은 보조색 두 개, 09번은 대기색 하나가 추가) 값을 바꾸면 그대로 변형으로 쓸 수 있다. 다른 버튼 소재는 사이트 검색 에서 버튼으로 찾아볼 수 있다. 아이콘 하나로 성립하는 조각들은 플로팅·아이콘 버튼 패턴 9종에 모여 있다.









