css 버튼 hover 효과 모음 9종 — 수상작에서 반복되는 기법
css 버튼 hover 효과 모음은 마우스를 올렸을 때 버튼이 어떻게 반응하는지를 기법별로 나눠 정리한 것입니다. 아홉 개 중 하나는 누르는 순간 버튼 폭이 원으로 줄어 스피너가 돌고, 끝나면 체크가 그려진 뒤 원래 폭으로 돌아옵니다.
01 spotlight-follow
02 conic-glow-runner
03 magnet-pull
04 liquid-fill
05 split-text-slide
07 squash-press
08 corner-brackets
09 state-morph-check
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 커서 스포트라이트
- 02 테두리 빛 러너
- 03 마그네틱 당김 + 라벨 역시차
- 04 액체 채움 (물결 수면)
- 05 글자 분할 슬라이드
- 06 아이콘 회전 교체
- 07 눌림 반동 (스쿼시·스트레치)
- 08 모서리 괄호 조준
- 09 로딩 모프 (버튼→스피너→체크)
디자인상을 받은 사이트를 뜯어보면 버튼 효과가 제각각인 것 같아도 쓰이는 자리는 늘 같습니다. 그래서 인기순이 아니라 버튼이 사람을 만나는 순서대로 놓았습니다. 먼저 멀리서 눈에 띄고(01·02), 버튼 호버 효과로 커서가 다가오며 반응하고(03), 가리키는 동안 표면·글자·아이콘이 바뀌고(04·05·06), 누르기 직전에 조준이 모이고 누르는 순간 반응이 손끝에 돌아오고(07·08), 마지막으로 기다리는 동안 상태를 알립니다(09). 브랜드 이름이 아니라 기법만 가져왔으므로 아홉 개 전부 자기 색·자기 글자로 바꿔 쓰면 됩니다. 호버 전의 기본 생김새를 고르는 일은 css 버튼 디자인이 먼저고, 손을 뗀 다음 상태가 바뀌는 구간은 css 버튼 클릭 효과가 이어받습니다.
01커서 스포트라이트
css 버튼 디자인에서 무엇이 보이나
커서가 지나간 자리를 따라 둥근 빛무리가 버튼 표면을 훑습니다. 빛의 중심 가로 위치만 --mx 한 줄로 넘기면 나머지는 그라디언트가 그립니다.

<main class="stage">
<button class="spot is-demo" type="button"><span class="spot__label">요금제 보기</span></button>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형은 이 세 줄만 바꾼다
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
// 커스텀 속성을 등록해야 브라우저가 퍼센트로 보간한다. 등록 안 하면 0% → 100% 로 튄다
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@include stage($stage-ink);
.spot {
position: relative;
isolation: isolate;
overflow: hidden;
border: 0;
cursor: pointer;
padding: $sp-5 $sp-10;
border-radius: $r-pill;
background: rgba($subject-cream, .10);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .30);
color: $subject-cream;
font-family: inherit;
font-size: clamp(16px, 5vw, 20px);
font-weight: 700;
}
.spot::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background: radial-gradient(circle 96px at var(--mx) 50%, rgba($color, .95), rgba($color, 0) 72%);
}
.spot__label { position: relative; }
.spot.is-demo { animation: spot-sweep $duration $easing infinite; }
// 8% → 92% → 8% 로 되돌아와 끝난다. 92% 에서 끊고 처음으로 점프하면 빛이 순간이동한다
@keyframes spot-sweep {
0% { --mx: 8%; }
50% { --mx: 92%; }
100% { --mx: 8%; }
}
@media (prefers-reduced-motion: reduce) {
.spot.is-demo { animation: none; }
.spot::before { background: radial-gradient(circle 96px at 50% 50%, rgba($color, .95), rgba($color, 0) 72%); }
}@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.spot {
position: relative;
isolation: isolate;
overflow: hidden;
border: 0;
cursor: pointer;
padding: 20px 40px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.1);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.3);
color: #fff7e6;
font-family: inherit;
font-size: clamp(16px, 5vw, 20px);
font-weight: 700;
}
.spot::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background: radial-gradient(circle 96px at var(--mx) 50%, rgba(47, 109, 246, 0.95), rgba(47, 109, 246, 0) 72%);
}
.spot__label {
position: relative;
}
.spot.is-demo {
animation: spot-sweep 2s ease-out infinite;
}
@keyframes spot-sweep {
0% {
--mx: 8%;
}
50% {
--mx: 92%;
}
100% {
--mx: 8%;
}
}
@media (prefers-reduced-motion: reduce) {
.spot.is-demo {
animation: none;
}
.spot::before {
background: radial-gradient(circle 96px at 50% 50%, rgba(47, 109, 246, 0.95), rgba(47, 109, 246, 0) 72%);
}
}// 실제 동작: 커서가 버튼 위에 오면 빛의 중심(--mx)이 커서를 따라가고, 손을 떼면 데모 루프로 돌아간다.
var spot = document.querySelector('.spot');
spot.addEventListener('pointermove', function (e) {
var r = spot.getBoundingClientRect();
spot.classList.remove('is-demo');
spot.style.setProperty('--mx', ((e.clientX - r.left) / r.width * 100) + '%');
});
spot.addEventListener('pointerleave', function () {
spot.style.removeProperty('--mx');
spot.classList.add('is-demo');
});import { useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./SpotlightButton.module.scss";
export type SpotlightButtonProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--mx"?: string };
export default function SpotlightButton({
label = "See pricing",
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
}: SpotlightButtonProps) {
const [mx, setMx] = useState<string | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
if (mx) { vars["--mx"] = mx; }
function move(e: PointerEvent<HTMLButtonElement>) {
const r = e.currentTarget.getBoundingClientRect();
setMx(`${((e.clientX - r.left) / r.width) * 100}%`);
}
return (
<button
type="button"
className={`${styles.spot} ${mx ? "" : styles.loop}`}
style={vars}
onPointerMove={move}
onPointerLeave={() => setMx(null)}
>
<span className={styles.label}>{label}</span>
</button>
);
}01 · 커서 스포트라이트 — 측정 누적면적 7.528% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
이 속성을 @property로 등록해 두는 것이 핵심입니다.
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
.spot::before {
background: radial-gradient(circle 96px at var(--mx) 50%, rgba($color, .95), rgba($color, 0) 72%);
}
@keyframes spot-sweep {
0% { --mx: 8%; }
50% { --mx: 92%; }
100% { --mx: 8%; }
}
02테두리 빛 러너
화면에서 일어나는 일
밝은 점 하나가 테두리를 따라 끊김 없이 한 바퀴씩 돕니다.

<main class="stage">
<button class="glow" type="button">
<span class="glow__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="m194.82 151.43l-55.09 20.3l-20.3 55.09a7.92 7.92 0 0 1-14.86 0l-20.3-55.09l-55.09-20.3a7.92 7.92 0 0 1 0-14.86l55.09-20.3l20.3-55.09a7.92 7.92 0 0 1 14.86 0l20.3 55.09l55.09 20.3a7.92 7.92 0 0 1 0 14.86" opacity=".2"/><path d="M197.58 129.06L146 110l-19-51.62a15.92 15.92 0 0 0-29.88 0L78 110l-51.62 19a15.92 15.92 0 0 0 0 29.88L78 178l19 51.62a15.92 15.92 0 0 0 29.88 0L146 178l51.62-19a15.92 15.92 0 0 0 0-29.88ZM137 164.22a8 8 0 0 0-4.74 4.74L112 223.85L91.78 169a8 8 0 0 0-4.78-4.78L32.15 144L87 123.78a8 8 0 0 0 4.78-4.78L112 64.15L132.22 119a8 8 0 0 0 4.74 4.74L191.85 144ZM144 40a8 8 0 0 1 8-8h16V16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0V48h-16a8 8 0 0 1-8-8m104 48a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8"/></g></svg></span>
<span class="glow__label">무료 체험 시작</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형은 이 세 줄만 바꾼다
$duration: $dur-loop;
$easing: linear;
$color: $subject-pink;
// 각도를 등록하지 않으면 conic-gradient 의 from 값이 보간되지 않아 러너가 아예 안 돈다
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@include stage($stage-ink);
.glow {
position: relative;
display: inline-flex;
align-items: center;
gap: $sp-2;
border: 0;
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: $r-pill;
background: rgba($subject-cream, .06);
color: $subject-cream;
font-family: inherit;
font-size: clamp(15px, 4.5vw, 19px);
font-weight: 700;
}
// 테두리만 남기는 마스크: 바깥 사각형에서 content-box 를 빼면 테두리 굵기만 남는다
.glow::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: $sp-1;
background: conic-gradient(from var(--angle),
rgba($color, 0) 0deg,
rgba($color, 0) 232deg,
rgba($color, 1) 300deg,
rgba($color, .2) 330deg,
rgba($color, 0) 360deg);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: glow-run $duration $easing infinite;
}
.glow__ic { display: inline-flex; color: $color; }
// 0deg → 360deg 는 같은 그림이라 끝과 처음이 맞물린다. 340deg 에서 끊으면 매 바퀴 튄다
@keyframes glow-run {
to { --angle: 360deg; }
}
@media (prefers-reduced-motion: reduce) {
.glow::before { animation: none; background: rgba($color, .7); }
}@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.glow {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
border: 0;
cursor: pointer;
padding: 16px 32px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.06);
color: #fff7e6;
font-family: inherit;
font-size: clamp(15px, 4.5vw, 19px);
font-weight: 700;
}
.glow::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: 4px;
background: conic-gradient(from var(--angle), rgba(255, 158, 187, 0) 0deg, rgba(255, 158, 187, 0) 232deg, #ff9ebb 300deg, rgba(255, 158, 187, 0.2) 330deg, rgba(255, 158, 187, 0) 360deg);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: glow-run 2s linear infinite;
}
.glow__ic {
display: inline-flex;
color: #ff9ebb;
}
@keyframes glow-run {
to {
--angle: 360deg;
}
}
@media (prefers-reduced-motion: reduce) {
.glow::before {
animation: none;
background: rgba(255, 158, 187, 0.7);
}
}import type { CSSProperties } from "react";
import styles from "./GlowRunnerButton.module.scss";
export type GlowRunnerButtonProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function GlowRunnerButton({
label = "Start free trial",
duration = 2000,
easing = "linear",
color = "#ff9ebb",
}: GlowRunnerButtonProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button type="button" className={styles.glow} style={vars}>
<span className={styles.label}>{label}</span>
</button>
);
}02 · 테두리 빛 러너 — 측정 누적면적 1.381% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
원뿔형 그라디언트(conic-gradient)를 통째로 돌리고 테두리 굵기만큼만 마스크로 남기는 방식이라 알약이든 사각형이든 모양을 그대로 따라갑니다.
.glow::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: $sp-1;
background: conic-gradient(from var(--angle),
rgba($color, 0) 0deg,
rgba($color, 0) 232deg,
rgba($color, 1) 300deg,
rgba($color, .2) 330deg,
rgba($color, 0) 360deg);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: glow-run $duration $easing infinite;
}
03마그네틱 당김 + 라벨 역시차
어떤 움직임인가
버튼은 커서 쪽으로 끌려오고, 안쪽 글자는 같은 좌표에 음수를 곱해 반대로 밀립니다.

<main class="stage">
<div class="mag__field">
<button class="mag is-demo" type="button"><span class="mag__label">프로젝트 보기</span></button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형은 이 세 줄만 바꾼다
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.mag__field {
display: grid;
place-items: center;
padding: $sp-8;
}
.mag {
border: 0;
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: $r-control;
background: $color;
color: $subject-cream;
font-family: inherit;
font-size: clamp(15px, 4.5vw, 19px);
font-weight: 700;
box-shadow: $shadow-press;
transition: translate $dur-quick $easing;
}
.mag__label {
display: inline-block;
transition: translate $dur-quick $easing;
}
.mag.is-demo { animation: mag-pull $duration $easing infinite; }
.mag.is-demo .mag__label { animation: mag-drift $duration $easing infinite; }
// 네 지점을 돌고 0 0 으로 돌아온다. 마지막 키프레임을 빼면 한 바퀴마다 제자리로 튄다
@keyframes mag-pull {
0% { translate: 0 0; }
25% { translate: 22px -8px; }
50% { translate: 0 6px; }
75% { translate: -22px -8px; }
100% { translate: 0 0; }
}
// 같은 좌표에 -0.4 를 곱한 값. 부호가 반대라 층이 갈라져 보인다
@keyframes mag-drift {
0% { translate: 0 0; }
25% { translate: -9px 3px; }
50% { translate: 0 -2px; }
75% { translate: 9px 3px; }
100% { translate: 0 0; }
}
@media (prefers-reduced-motion: reduce) {
.mag.is-demo,
.mag.is-demo .mag__label { animation: none; }
.mag,
.mag__label { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mag__field {
display: grid;
place-items: center;
padding: 32px;
}
.mag {
border: 0;
cursor: pointer;
padding: 16px 32px;
border-radius: 12px;
background: #2f6df6;
color: #fff7e6;
font-family: inherit;
font-size: clamp(15px, 4.5vw, 19px);
font-weight: 700;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: translate 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mag__label {
display: inline-block;
transition: translate 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mag.is-demo {
animation: mag-pull 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.mag.is-demo .mag__label {
animation: mag-drift 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes mag-pull {
0% {
translate: 0 0;
}
25% {
translate: 22px -8px;
}
50% {
translate: 0 6px;
}
75% {
translate: -22px -8px;
}
100% {
translate: 0 0;
}
}
@keyframes mag-drift {
0% {
translate: 0 0;
}
25% {
translate: -9px 3px;
}
50% {
translate: 0 -2px;
}
75% {
translate: 9px 3px;
}
100% {
translate: 0 0;
}
}
@media (prefers-reduced-motion: reduce) {
.mag.is-demo,
.mag.is-demo .mag__label {
animation: none;
}
.mag,
.mag__label {
transition: none;
}
}// 실제 동작: 커서가 감지 영역에 들어오면 버튼은 커서 쪽으로, 라벨은 그 절반만큼 반대로 움직인다.
var field = document.querySelector('.mag__field');
var btn = document.querySelector('.mag');
var label = document.querySelector('.mag__label');
field.addEventListener('pointermove', function (e) {
var r = btn.getBoundingClientRect();
var dx = (e.clientX - (r.left + r.width / 2)) * 0.35;
var dy = (e.clientY - (r.top + r.height / 2)) * 0.35;
btn.classList.remove('is-demo');
btn.style.translate = dx + 'px ' + dy + 'px';
label.style.translate = (-dx * 0.4) + 'px ' + (-dy * 0.4) + 'px';
});
field.addEventListener('pointerleave', function () {
btn.style.translate = '';
label.style.translate = '';
btn.classList.add('is-demo');
});import { useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./MagnetButton.module.scss";
export type MagnetButtonProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function MagnetButton({
label = "View projects",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: MagnetButtonProps) {
const [pull, setPull] = useState<{ x: number; y: number } | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function move(e: PointerEvent<HTMLDivElement>) {
const btn = e.currentTarget.firstElementChild as HTMLElement | null;
if (!btn) { return; }
const r = btn.getBoundingClientRect();
setPull({
x: (e.clientX - (r.left + r.width / 2)) * 0.35,
y: (e.clientY - (r.top + r.height / 2)) * 0.35,
});
}
return (
<div className={styles.field} style={vars} onPointerMove={move} onPointerLeave={() => setPull(null)}>
<button
type="button"
className={`${styles.mag} ${pull ? "" : styles.loop}`}
style={pull ? { translate: `${pull.x}px ${pull.y}px` } : undefined}
>
<span
className={`${styles.label} ${pull ? "" : styles.labelLoop}`}
style={pull ? { translate: `${-pull.x * 0.4}px ${-pull.y * 0.4}px` } : undefined}
>
{label}
</span>
</button>
</div>
);
}03 · 마그네틱 당김 + 라벨 역시차 — 측정 누적면적 10.698% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
이 부호 하나가 두 층을 갈라놓아 납작한 사각형이 살짝 떠 있는 것처럼 보입니다.
field.addEventListener('pointermove', function (e) {
var r = btn.getBoundingClientRect();
var dx = (e.clientX - (r.left + r.width / 2)) * 0.35;
var dy = (e.clientY - (r.top + r.height / 2)) * 0.35;
btn.classList.remove('is-demo');
btn.style.translate = dx + 'px ' + dy + 'px';
label.style.translate = (-dx * 0.4) + 'px ' + (-dy * 0.4) + 'px';
});
04액체 채움 (물결 수면)
무엇이 보이나
아래에서 색이 차오르는데 수면이 평평하지 않고 물결처럼 기울어집니다.

<main class="stage">
<button class="liq is-demo" type="button">
<span class="liq__wave" aria-hidden="true"></span>
<span class="liq__label">장바구니 담기</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형은 이 세 줄만 바꾼다
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-yellow);
.liq {
position: relative;
overflow: hidden;
isolation: isolate;
border: 0;
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: $r-pill;
background: $subject-cream;
box-shadow: inset 0 0 0 2px $subject-ink;
color: $subject-ink;
font-family: inherit;
font-size: clamp(15px, 4.5vw, 19px);
font-weight: 800;
}
// 수면이 평평하지 않은 이유: 완전한 원이 아니라 네 모서리를 42% 둥글린 정사각형을 통째로 돌린다
.liq__wave {
position: absolute;
left: 50%;
bottom: 0;
width: 260px;
height: 260px;
border-radius: 42%;
background: $color;
transform: translate(-50%, 100%);
}
.liq__label { position: relative; }
.liq.is-demo .liq__wave { animation: liq-rise $duration $easing infinite; }
// rotate 가 0deg 에서 360deg 로 끝나 마지막 프레임과 첫 프레임의 그림이 같다
@keyframes liq-rise {
0% { transform: translate(-50%, 100%) rotate(0deg); }
52% { transform: translate(-50%, 68%) rotate(180deg); }
66% { transform: translate(-50%, 68%) rotate(230deg); }
100% { transform: translate(-50%, 100%) rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.liq.is-demo .liq__wave { animation: none; transform: translate(-50%, 68%); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.liq {
position: relative;
overflow: hidden;
isolation: isolate;
border: 0;
cursor: pointer;
padding: 16px 32px;
border-radius: 999px;
background: #fff7e6;
box-shadow: inset 0 0 0 2px #17141a;
color: #17141a;
font-family: inherit;
font-size: clamp(15px, 4.5vw, 19px);
font-weight: 800;
}
.liq__wave {
position: absolute;
left: 50%;
bottom: 0;
width: 260px;
height: 260px;
border-radius: 42%;
background: #4cd4a6;
transform: translate(-50%, 100%);
}
.liq__label {
position: relative;
}
.liq.is-demo .liq__wave {
animation: liq-rise 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes liq-rise {
0% {
transform: translate(-50%, 100%) rotate(0deg);
}
52% {
transform: translate(-50%, 68%) rotate(180deg);
}
66% {
transform: translate(-50%, 68%) rotate(230deg);
}
100% {
transform: translate(-50%, 100%) rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.liq.is-demo .liq__wave {
animation: none;
transform: translate(-50%, 68%);
}
}import type { CSSProperties } from "react";
import styles from "./LiquidFillButton.module.scss";
export type LiquidFillButtonProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function LiquidFillButton({
label = "Add to cart",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#4cd4a6",
}: LiquidFillButtonProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button type="button" className={styles.liq} style={vars}>
<span className={styles.wave} aria-hidden="true" />
<span className={styles.label}>{label}</span>
</button>
);
}04 · 액체 채움 (물결 수면) — 측정 누적면적 6.315% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
완전한 원이 아니라 네 모서리를 42%만큼 둥글린 260px 정사각형을 통째로 돌리면서 동시에 위로 밀어 올리기 때문입니다.
.liq__wave {
position: absolute;
left: 50%;
bottom: 0;
width: 260px;
height: 260px;
border-radius: 42%;
background: $color;
transform: translate(-50%, 100%);
}
@keyframes liq-rise {
0% { transform: translate(-50%, 100%) rotate(0deg); }
52% { transform: translate(-50%, 68%) rotate(180deg); }
66% { transform: translate(-50%, 68%) rotate(230deg); }
100% { transform: translate(-50%, 100%) rotate(360deg); }
}
05글자 분할 슬라이드
화면에서 일어나는 일
글자가 하나씩 위로 빠져나가고 같은 글자가 아래에서 올라와 자리를 메웁니다.

<main class="stage">
<button class="split is-demo" type="button" aria-label="자세히 보기"><span class="split__in" aria-hidden="true"></span></button>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형은 이 세 줄만 바꾼다
$duration: $dur-loop;
$easing: $ease-spring;
$stagger: 70ms;
@include stage($stage-orange);
.split {
border: 0;
cursor: pointer;
padding: $sp-3 $sp-6;
border-radius: $r-control;
background: $subject-cream;
color: $stage-orange;
font-family: inherit;
font-size: clamp(18px, 6vw, 26px);
font-weight: 800;
}
.split__in { display: inline-flex; }
// 창(1.25em)은 고정이고 그 안의 두 겹이 굴러간다
.split__ch {
display: inline-block;
overflow: hidden;
height: 1.25em;
line-height: 1.25;
}
.split__ch b { display: block; }
.split__ch i { display: block; font-style: normal; }
.split.is-demo .split__ch b {
animation: split-roll $duration $easing infinite;
animation-delay: calc(var(--i) * #{$stagger});
}
// 두 겹이라 -50% 가 정확히 한 줄. 0 으로 돌아와 끝나므로 되감기가 안 보인다
@keyframes split-roll {
0%, 12% { translate: 0 0; }
42%, 68% { translate: 0 -50%; }
100% { translate: 0 0; }
}
@media (prefers-reduced-motion: reduce) {
.split.is-demo .split__ch b { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.split {
border: 0;
cursor: pointer;
padding: 12px 24px;
border-radius: 12px;
background: #fff7e6;
color: #ff4d1f;
font-family: inherit;
font-size: clamp(18px, 6vw, 26px);
font-weight: 800;
}
.split__in {
display: inline-flex;
}
.split__ch {
display: inline-block;
overflow: hidden;
height: 1.25em;
line-height: 1.25;
}
.split__ch b {
display: block;
}
.split__ch i {
display: block;
font-style: normal;
}
.split.is-demo .split__ch b {
animation: split-roll 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: calc(var(--i) * 70ms);
}
@keyframes split-roll {
0%, 12% {
translate: 0 0;
}
42%, 68% {
translate: 0 -50%;
}
100% {
translate: 0 0;
}
}
@media (prefers-reduced-motion: reduce) {
.split.is-demo .split__ch b {
animation: none;
}
}// 실제 동작: aria-label 의 글자를 하나씩 이중으로 쌓고, 글자 번호(--i)를 지연 시간으로 쓴다.
var split = document.querySelector('.split');
var box = split.querySelector('.split__in');
var text = split.getAttribute('aria-label');
for (var i = 0; i < text.length; i++) {
var cell = document.createElement('span');
var roll = document.createElement('b');
cell.className = 'split__ch';
cell.style.setProperty('--i', i);
for (var k = 0; k < 2; k++) {
var face = document.createElement('i');
face.textContent = text[i] === ' ' ? ' ' : text[i];
roll.appendChild(face);
}
cell.appendChild(roll);
box.appendChild(cell);
}import type { CSSProperties } from "react";
import styles from "./SplitTextButton.module.scss";
export type SplitTextButtonProps = {
label?: string;
duration?: number;
easing?: string;
stagger?: number;
};
type CellVars = CSSProperties & { "--i"?: number };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--stagger"?: string };
export default function SplitTextButton({
label = "See details",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
stagger = 70,
}: SplitTextButtonProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--stagger": `${stagger}ms` };
const chars = label.split("");
return (
<button type="button" className={styles.split} style={vars} aria-label={label}>
<span className={styles.inner} aria-hidden="true">
{chars.map((ch, i) => {
const cell: CellVars = { "--i": i };
return (
<span className={styles.ch} style={cell} key={`${ch}-${i}`}>
<b className={styles.roll}>
<i>{ch === " " ? " " : ch}</i>
<i>{ch === " " ? " " : ch}</i>
</b>
</span>
);
})}
</span>
</button>
);
}05 · 글자 분할 슬라이드 — 측정 누적면적 2.35% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
글자마다 창(overflow: hidden) 하나에 같은 글자를 두 겹 쌓아 두고, 지연 시간만 글자 번호에 비례시키면 물결처럼 번집니다.
.split__ch {
display: inline-block;
overflow: hidden;
height: 1.25em;
line-height: 1.25;
}
.split.is-demo .split__ch b {
animation: split-roll $duration $easing infinite;
animation-delay: calc(var(--i) * #{$stagger});
}
@keyframes split-roll {
0%, 12% { translate: 0 0; }
42%, 68% { translate: 0 -50%; }
100% { translate: 0 0; }
}
06아이콘 회전 교체
어떤 움직임인가
아이콘이 세로축으로 반 바퀴 돌면서 뒷면에 있던 다른 아이콘으로 바뀝니다.
<main class="stage">
<button class="swap is-demo" type="button">
<span class="swap__ic" aria-hidden="true">
<span class="swap__face swap__face--front"><svg viewBox="0 0 256 256" width="34" height="34"><g fill="currentColor"><path d="m232 72l-25.63 92.28A16 16 0 0 1 191 176H92.16a16 16 0 0 1-15.41-11.72L51.11 72Z" opacity=".2"/><path d="M104 216a16 16 0 1 1-16-16a16 16 0 0 1 16 16m88-16a16 16 0 1 0 16 16a16 16 0 0 0-16-16m47.71-125.86l-25.64 92.28A24.06 24.06 0 0 1 191 184H92.16A24.06 24.06 0 0 1 69 166.42L33.92 40H16a8 8 0 0 1 0-16h24a8 8 0 0 1 7.71 5.86L57.19 64H232a8 8 0 0 1 7.71 10.14M221.47 80H61.64l22.81 82.14a8 8 0 0 0 7.71 5.86H191a8 8 0 0 0 7.71-5.86Z"/></g></svg></span>
<span class="swap__face swap__face--back"><svg viewBox="0 0 256 256" width="34" height="34"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span>
</span>
<span class="swap__label">담기</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형은 이 세 줄만 바꾼다
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.swap {
display: inline-flex;
align-items: center;
gap: $sp-3;
border: 0;
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: $r-control;
background: rgba($color, .10);
color: $color;
font-family: inherit;
font-size: clamp(18px, 5.5vw, 24px);
font-weight: 800;
box-shadow: inset 0 0 0 2px rgba($color, .35);
}
// 두 아이콘이 같은 자리에 앞뒤로 겹친다. 뒷면을 가려야 한 장만 보인다
.swap__ic {
position: relative;
width: 34px;
height: 34px;
transform-style: preserve-3d;
}
.swap__face {
position: absolute;
inset: 0;
display: flex;
backface-visibility: hidden;
}
.swap__face--back { transform: rotateY(180deg); }
.swap.is-demo .swap__ic { animation: swap-spin $duration $easing infinite; }
// 360deg 로 끝내야 다음 바퀴의 0deg 와 이어진다. 180deg 에서 끊고 0 으로 돌리면 되감기가 보인다
@keyframes swap-spin {
0%, 22% { transform: rotateY(0deg); }
50%, 72% { transform: rotateY(180deg); }
100% { transform: rotateY(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.swap.is-demo .swap__ic { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.swap {
display: inline-flex;
align-items: center;
gap: 12px;
border: 0;
cursor: pointer;
padding: 16px 32px;
border-radius: 12px;
background: rgba(47, 109, 246, 0.1);
color: #2f6df6;
font-family: inherit;
font-size: clamp(18px, 5.5vw, 24px);
font-weight: 800;
box-shadow: inset 0 0 0 2px rgba(47, 109, 246, 0.35);
}
.swap__ic {
position: relative;
width: 34px;
height: 34px;
transform-style: preserve-3d;
}
.swap__face {
position: absolute;
inset: 0;
display: flex;
backface-visibility: hidden;
}
.swap__face--back {
transform: rotateY(180deg);
}
.swap.is-demo .swap__ic {
animation: swap-spin 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes swap-spin {
0%, 22% {
transform: rotateY(0deg);
}
50%, 72% {
transform: rotateY(180deg);
}
100% {
transform: rotateY(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.swap.is-demo .swap__ic {
animation: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./IconSwapButton.module.scss";
export type IconSwapButtonProps = {
label?: string;
front?: ReactNode;
back?: ReactNode;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const cart = (
<svg viewBox="0 0 256 256" width="34" height="34">
<g fill="currentColor">
<path d="m232 72l-25.63 92.28A16 16 0 0 1 191 176H92.16a16 16 0 0 1-15.41-11.72L51.11 72Z" opacity=".2" />
<path d="M104 216a16 16 0 1 1-16-16a16 16 0 0 1 16 16m88-16a16 16 0 1 0 16 16a16 16 0 0 0-16-16m47.71-125.86l-25.64 92.28A24.06 24.06 0 0 1 191 184H92.16A24.06 24.06 0 0 1 69 166.42L33.92 40H16a8 8 0 0 1 0-16h24a8 8 0 0 1 7.71 5.86L57.19 64H232a8 8 0 0 1 7.71 10.14M221.47 80H61.64l22.81 82.14a8 8 0 0 0 7.71 5.86H191a8 8 0 0 0 7.71-5.86Z" />
</g>
</svg>
);
const check = (
<svg viewBox="0 0 256 256" width="34" height="34">
<g fill="currentColor">
<path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2" />
<path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32" />
</g>
</svg>
);
export default function IconSwapButton({
label = "Add",
front = cart,
back = check,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: IconSwapButtonProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button type="button" className={styles.swap} style={vars}>
<span className={styles.ic} aria-hidden="true">
<span className={styles.face}>{front}</span>
<span className={`${styles.face} ${styles.back}`}>{back}</span>
</span>
<span>{label}</span>
</button>
);
}06 · 아이콘 회전 교체 — 측정 누적면적 0.485% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
두 아이콘을 같은 칸에 앞뒤로 겹쳐 두고 각자 뒷면만 숨기면 끝이라, 담기에서 담김으로 뜻이 바뀔 때도 라벨 글자는 그대로 두면 됩니다.
.swap__ic {
position: relative;
width: 34px;
height: 34px;
transform-style: preserve-3d;
}
.swap__face { position: absolute; inset: 0; display: flex; backface-visibility: hidden; }
.swap__face--back { transform: rotateY(180deg); }
@keyframes swap-spin {
0%, 22% { transform: rotateY(0deg); }
50%, 72% { transform: rotateY(180deg); }
100% { transform: rotateY(360deg); }
}
07눌림 반동 (스쿼시·스트레치)
무엇이 보이나
누르는 순간 세로로 납작해지며 가로로 퍼지고, 놓으면 크게 한 번 튀어 올랐다가 작게 한 번 더 흔들린 뒤 안착합니다.

<main class="stage">
<button class="squash is-demo" type="button"><span class="squash__label">제출하기</span></button>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형은 이 세 줄만 바꾼다
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-pink;
@include stage($stage-yellow);
.squash {
border: 0;
cursor: pointer;
padding: $sp-4 $sp-10;
border-radius: $r-control;
background: $color;
color: $subject-ink;
font-family: inherit;
font-size: clamp(17px, 5vw, 22px);
font-weight: 800;
box-shadow: $shadow-raised;
transform-origin: center bottom;
}
.squash.is-demo { animation: squash-press $duration $easing infinite; }
// 눌림(scale 1.06/.88) → 반동(.97/1.06) → 안착. 100% 가 0% 와 같은 값이라 되감기가 없다
@keyframes squash-press {
0%, 34% { transform: scale(1, 1); box-shadow: $shadow-raised; }
46% { transform: scale(1.06, .88); box-shadow: $shadow-press; }
60% { transform: scale(.97, 1.06); box-shadow: $shadow-raised; }
72% { transform: scale(1.02, .98); box-shadow: $shadow-raised; }
86%, 100% { transform: scale(1, 1); box-shadow: $shadow-raised; }
}
// 손으로 눌렀을 때도 같은 눌림 값
.squash:active {
animation: none;
transform: scale(1.06, .88);
box-shadow: $shadow-press;
}
@media (prefers-reduced-motion: reduce) {
.squash.is-demo { animation: none; }
.squash:active { transform: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.squash {
border: 0;
cursor: pointer;
padding: 16px 40px;
border-radius: 12px;
background: #ff9ebb;
color: #17141a;
font-family: inherit;
font-size: clamp(17px, 5vw, 22px);
font-weight: 800;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: center bottom;
}
.squash.is-demo {
animation: squash-press 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes squash-press {
0%, 34% {
transform: scale(1, 1);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
46% {
transform: scale(1.06, 0.88);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
60% {
transform: scale(0.97, 1.06);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
72% {
transform: scale(1.02, 0.98);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
86%, 100% {
transform: scale(1, 1);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
}
.squash:active {
animation: none;
transform: scale(1.06, 0.88);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
@media (prefers-reduced-motion: reduce) {
.squash.is-demo {
animation: none;
}
.squash:active {
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./SquashPressButton.module.scss";
export type SquashPressButtonProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SquashPressButton({
label = "Submit",
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#ff9ebb",
}: SquashPressButtonProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button type="button" className={styles.squash} style={vars}>
{label}
</button>
);
}07 · 눌림 반동 (스쿼시·스트레치) — 측정 누적면적 5.59% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
그림자도 같은 박자에 맞춰 얕아져야 비로소 고무공처럼 보입니다.
.squash { transform-origin: center bottom; }
@keyframes squash-press {
0%, 34% { transform: scale(1, 1); box-shadow: $shadow-raised; }
46% { transform: scale(1.06, .88); box-shadow: $shadow-press; }
60% { transform: scale(.97, 1.06); box-shadow: $shadow-raised; }
72% { transform: scale(1.02, .98); box-shadow: $shadow-raised; }
86%, 100% { transform: scale(1, 1); box-shadow: $shadow-raised; }
}
.squash:active {
animation: none;
transform: scale(1.06, .88);
box-shadow: $shadow-press;
}
08모서리 괄호 조준
화면에서 일어나는 일
네 모서리의 ㄱ자 조각이 바깥에서 모여들어 테두리를 완성합니다.

<main class="stage">
<button class="brk is-demo" type="button">
<span class="brk__c brk__c--tl" aria-hidden="true"></span>
<span class="brk__c brk__c--tr" aria-hidden="true"></span>
<span class="brk__c brk__c--bl" aria-hidden="true"></span>
<span class="brk__c brk__c--br" aria-hidden="true"></span>
<span class="brk__label">설정 열기</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형은 이 세 줄만 바꾼다
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
.brk {
position: relative;
border: 0;
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: 0;
background: transparent;
color: $color;
font-family: inherit;
font-size: clamp(16px, 5vw, 21px);
font-weight: 700;
}
// 조각 넷은 같은 크기·같은 이동 거리. 다른 건 어느 쪽 변을 지우느냐뿐이다
.brk__c {
position: absolute;
width: 22px;
height: 22px;
border: 3px solid $color;
--tx: -18px;
--ty: -18px;
}
.brk__c--tl { top: -8px; left: -8px; border-right: 0; border-bottom: 0; --i: 0; }
.brk__c--tr { top: -8px; right: -8px; border-left: 0; border-bottom: 0; --tx: 18px; --i: 1; }
.brk__c--bl { bottom: -8px; left: -8px; border-right: 0; border-top: 0; --ty: 18px; --i: 2; }
.brk__c--br { bottom: -8px; right: -8px; border-left: 0; border-top: 0; --tx: 18px; --ty: 18px; --i: 3; }
.brk.is-demo .brk__c {
animation: brk-close $duration $easing infinite;
animation-delay: calc(var(--i) * 60ms);
}
// 모였다가 다시 흩어져 시작 위치로 돌아온다. 모인 상태에서 끝내면 매 바퀴 조각이 순간이동한다
@keyframes brk-close {
0% { translate: var(--tx) var(--ty); opacity: 0; }
38%, 66% { translate: 0 0; opacity: 1; }
100% { translate: var(--tx) var(--ty); opacity: 0; }
}
// 키보드 포커스에도 같은 조준선
.brk:focus-visible {
outline: none;
.brk__c { animation: none; translate: 0 0; opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.brk.is-demo .brk__c { animation: none; translate: 0 0; opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.brk {
position: relative;
border: 0;
cursor: pointer;
padding: 16px 32px;
border-radius: 0;
background: transparent;
color: #fff7e6;
font-family: inherit;
font-size: clamp(16px, 5vw, 21px);
font-weight: 700;
}
.brk__c {
position: absolute;
width: 22px;
height: 22px;
border: 3px solid #fff7e6;
--tx: -18px;
--ty: -18px;
}
.brk__c--tl {
top: -8px;
left: -8px;
border-right: 0;
border-bottom: 0;
--i: 0;
}
.brk__c--tr {
top: -8px;
right: -8px;
border-left: 0;
border-bottom: 0;
--tx: 18px;
--i: 1;
}
.brk__c--bl {
bottom: -8px;
left: -8px;
border-right: 0;
border-top: 0;
--ty: 18px;
--i: 2;
}
.brk__c--br {
bottom: -8px;
right: -8px;
border-left: 0;
border-top: 0;
--tx: 18px;
--ty: 18px;
--i: 3;
}
.brk.is-demo .brk__c {
animation: brk-close 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: calc(var(--i) * 60ms);
}
@keyframes brk-close {
0% {
translate: var(--tx) var(--ty);
opacity: 0;
}
38%, 66% {
translate: 0 0;
opacity: 1;
}
100% {
translate: var(--tx) var(--ty);
opacity: 0;
}
}
.brk:focus-visible {
outline: none;
}
.brk:focus-visible .brk__c {
animation: none;
translate: 0 0;
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.brk.is-demo .brk__c {
animation: none;
translate: 0 0;
opacity: 1;
}
}import type { CSSProperties } from "react";
import styles from "./CornerBracketButton.module.scss";
export type CornerBracketButtonProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const corners = ["tl", "tr", "bl", "br"] as const;
export default function CornerBracketButton({
label = "Open settings",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: CornerBracketButtonProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button type="button" className={styles.brk} style={vars}>
{corners.map((c) => (
<span key={c} className={`${styles.c} ${styles[c]}`} aria-hidden="true" />
))}
<span className={styles.label}>{label}</span>
</button>
);
}08 · 모서리 괄호 조준 — 측정 누적면적 2.501% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
조각마다 다른 것은 지우는 변 두 쌍과 이동 방향이고 이동 거리는 18px로 같아서, 이 거리를 바꾸면 네 조각이 한꺼번에 넓어지거나 좁아집니다.
.brk__c {
position: absolute;
width: 22px;
height: 22px;
border: 3px solid $color;
--tx: -18px;
--ty: -18px;
}
.brk__c--tr { top: -8px; right: -8px; border-left: 0; border-bottom: 0; --tx: 18px; --i: 1; }
.brk.is-demo .brk__c {
animation: brk-close $duration $easing infinite;
animation-delay: calc(var(--i) * 60ms);
}
@keyframes brk-close {
0% { translate: var(--tx) var(--ty); opacity: 0; }
38%, 66% { translate: 0 0; opacity: 1; }
100% { translate: var(--tx) var(--ty); opacity: 0; }
}
09로딩 모프 (버튼→스피너→체크)
어떤 움직임인가
누르면 글자가 사라지며 폭이 원으로 좁아지고, 스피너가 돌다가 체크가 그려진 뒤 원래 폭으로 돌아옵니다.

<main class="stage">
<button class="morph is-demo" type="button">
<span class="morph__label">결제하기</span>
<span class="morph__spin" aria-hidden="true"></span>
<svg class="morph__check" viewBox="0 0 24 24" aria-hidden="true"><polyline points="5 12.5 10 17.5 19 7.5"/></svg>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형은 이 세 줄만 바꾼다
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
.morph {
position: relative;
display: grid;
place-items: center;
width: 200px;
height: 56px;
border: 0;
cursor: pointer;
padding: 0;
border-radius: $r-pill;
background: $color;
color: $stage-orange;
font-family: inherit;
font-size: 18px;
font-weight: 800;
overflow: hidden;
}
.morph__label,
.morph__spin,
.morph__check { grid-area: 1 / 1; }
.morph__spin {
width: 24px;
height: 24px;
border: 3px solid rgba($stage-orange, .25);
border-top-color: $stage-orange;
border-radius: 50%;
opacity: 0;
}
.morph__check {
width: 26px;
height: 26px;
fill: none;
stroke: $stage-orange;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 26;
stroke-dashoffset: 26;
opacity: 0;
}
.morph.is-demo { animation: morph-box $duration $easing infinite; }
.morph.is-demo .morph__label { animation: morph-label $duration $easing infinite; }
.morph.is-demo .morph__spin { animation: morph-spin-in $duration $easing infinite, morph-turn 800ms linear infinite; }
.morph.is-demo .morph__check { animation: morph-draw $duration $easing infinite; }
// 200 → 56 → 200. 마지막이 처음과 같은 폭이라 한 바퀴가 이어진다
@keyframes morph-box {
0%, 10% { width: 200px; }
28%, 62% { width: 56px; }
84%, 100% { width: 200px; }
}
@keyframes morph-label {
0%, 8% { opacity: 1; }
20%, 80% { opacity: 0; }
92%, 100% { opacity: 1; }
}
@keyframes morph-spin-in {
0%, 22% { opacity: 0; }
30%, 56% { opacity: 1; }
62%, 100% { opacity: 0; }
}
// 360deg 로 끝나 다음 바퀴와 맞물린다
@keyframes morph-turn {
to { rotate: 360deg; }
}
@keyframes morph-draw {
0%, 60% { stroke-dashoffset: 26; opacity: 0; }
64% { stroke-dashoffset: 26; opacity: 1; }
76%, 86% { stroke-dashoffset: 0; opacity: 1; }
94%, 100% { stroke-dashoffset: 26; opacity: 0; }
}
// 실제 클릭 상태 — 데모 루프가 아니라 사람이 눌렀을 때
.morph.is-busy { width: 56px; }
.morph.is-busy .morph__label { opacity: 0; }
.morph.is-busy .morph__spin { opacity: 1; animation: morph-turn 800ms linear infinite; }
.morph.is-done .morph__check { opacity: 1; stroke-dashoffset: 0; transition: stroke-dashoffset $dur-enter $easing; }
.morph.is-done .morph__label { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
.morph.is-demo,
.morph.is-demo .morph__label,
.morph.is-demo .morph__spin,
.morph.is-demo .morph__check { animation: none; }
.morph.is-busy .morph__spin { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.morph {
position: relative;
display: grid;
place-items: center;
width: 200px;
height: 56px;
border: 0;
cursor: pointer;
padding: 0;
border-radius: 999px;
background: #fff7e6;
color: #ff4d1f;
font-family: inherit;
font-size: 18px;
font-weight: 800;
overflow: hidden;
}
.morph__label,
.morph__spin,
.morph__check {
grid-area: 1/1;
}
.morph__spin {
width: 24px;
height: 24px;
border: 3px solid rgba(255, 77, 31, 0.25);
border-top-color: #ff4d1f;
border-radius: 50%;
opacity: 0;
}
.morph__check {
width: 26px;
height: 26px;
fill: none;
stroke: #ff4d1f;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 26;
stroke-dashoffset: 26;
opacity: 0;
}
.morph.is-demo {
animation: morph-box 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.morph.is-demo .morph__label {
animation: morph-label 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.morph.is-demo .morph__spin {
animation: morph-spin-in 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite, morph-turn 800ms linear infinite;
}
.morph.is-demo .morph__check {
animation: morph-draw 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes morph-box {
0%, 10% {
width: 200px;
}
28%, 62% {
width: 56px;
}
84%, 100% {
width: 200px;
}
}
@keyframes morph-label {
0%, 8% {
opacity: 1;
}
20%, 80% {
opacity: 0;
}
92%, 100% {
opacity: 1;
}
}
@keyframes morph-spin-in {
0%, 22% {
opacity: 0;
}
30%, 56% {
opacity: 1;
}
62%, 100% {
opacity: 0;
}
}
@keyframes morph-turn {
to {
rotate: 360deg;
}
}
@keyframes morph-draw {
0%, 60% {
stroke-dashoffset: 26;
opacity: 0;
}
64% {
stroke-dashoffset: 26;
opacity: 1;
}
76%, 86% {
stroke-dashoffset: 0;
opacity: 1;
}
94%, 100% {
stroke-dashoffset: 26;
opacity: 0;
}
}
.morph.is-busy {
width: 56px;
}
.morph.is-busy .morph__label {
opacity: 0;
}
.morph.is-busy .morph__spin {
opacity: 1;
animation: morph-turn 800ms linear infinite;
}
.morph.is-done .morph__check {
opacity: 1;
stroke-dashoffset: 0;
transition: stroke-dashoffset 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.morph.is-done .morph__label {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.morph.is-demo,
.morph.is-demo .morph__label,
.morph.is-demo .morph__spin,
.morph.is-demo .morph__check {
animation: none;
}
.morph.is-busy .morph__spin {
animation: none;
}
}// 실제 동작: 누르면 aria-busy 로 상태를 알리고 1.2초 뒤 체크로 끝난다. 그동안 두 번 눌리지 않는다.
var morph = document.querySelector('.morph');
morph.addEventListener('click', function () {
if (morph.classList.contains('is-busy')) { return; }
morph.classList.remove('is-demo');
morph.classList.add('is-busy');
morph.setAttribute('aria-busy', 'true');
setTimeout(function () {
morph.classList.remove('is-busy');
morph.classList.add('is-done');
morph.removeAttribute('aria-busy');
}, 1200);
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StateMorphButton.module.scss";
export type StateMorphButtonProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
onSubmit?: () => Promise<void>;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function StateMorphButton({
label = "Pay now",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
onSubmit,
}: StateMorphButtonProps) {
const [state, setState] = useState<"idle" | "busy" | "done">("idle");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function run() {
if (state === "busy") { return; }
setState("busy");
const job = onSubmit ? onSubmit() : new Promise<void>((r) => setTimeout(r, 1200));
job.then(() => setState("done"));
}
const cls = state === "busy" ? styles.busy : state === "done" ? styles.done : styles.loop;
return (
<button
type="button"
className={`${styles.morph} ${cls}`}
style={vars}
aria-busy={state === "busy"}
onClick={run}
>
<span className={styles.label}>{label}</span>
<span className={styles.spin} aria-hidden="true" />
<svg className={styles.check} viewBox="0 0 24 24" aria-hidden="true">
<polyline points="5 12.5 10 17.5 19 7.5" />
</svg>
</button>
);
}09 · 로딩 모프 (버튼→스피너→체크) — 측정 누적면적 6.331% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
세 상태가 한 요소 안에서 이어지므로 로딩 화면을 따로 띄우지 않아도 되고, 기다리는 동안 두 번 눌리지도 않습니다.
@keyframes morph-box {
0%, 10% { width: 200px; }
28%, 62% { width: 56px; }
84%, 100% { width: 200px; }
}
@keyframes morph-draw {
0%, 60% { stroke-dashoffset: 26; opacity: 0; }
64% { stroke-dashoffset: 26; opacity: 1; }
76%, 86% { stroke-dashoffset: 0; opacity: 1; }
94%, 100% { stroke-dashoffset: 26; opacity: 0; }
}
.morph.is-busy { width: 56px; }
.morph.is-busy .morph__label { opacity: 0; }
.morph.is-busy .morph__spin { opacity: 1; animation: morph-turn 800ms linear infinite; }
어디서 깨지는가 — 함정
아홉 개를 만들면서 제일 오래 잡고 있던 것은 01번과 02번이 아예 안 움직이던 문제였습니다. --mx와 --angle을 키프레임에 넣어 두면 당연히 부드럽게 변할 줄 알았는데, 브라우저는 등록되지 않은 사용자 지정 속성을 그냥 문자열로 봅니다. 문자열에는 중간값이 없으니 0%에서 100%로 뚝 끊기고, 24프레임을 뽑아 보면 빛이 두 칸에만 서 있다가 사라집니다. @property로 syntax: "<percentage>"나 "<angle>"을 선언해 타입을 알려 준 뒤에야 러너가 실제로 한 바퀴를 돌았습니다.
두 번째는 루프의 끝과 처음이 어긋나는 순간이동이었습니다. 02번 러너를 340deg에서 끊고 0deg로 되돌렸더니 한 바퀴마다 빛이 튀어서, 각도를 정확히 360deg까지 돌려 다음 바퀴의 0deg와 맞물리게 했습니다. 06번 아이콘도 180deg에서 멈추고 되감으면 뒷면이 앞으로 슬쩍 되돌아오는 것이 보여 360deg로 이어 붙였습니다. 04번은 반대로 너무 빨랐습니다. 처음에는 320px짜리 물방울을 썼는데, 렌더한 다섯 번째 프레임에서 이미 버튼이 가득 차 물결 수면이 보이지 않아 260px로 줄이고 차오르는 구간을 전체의 52%까지 늘린 뒤에야 곡면이 글자를 가로지르는 장면이 프레임에 남았습니다. 05번은 창 높이를 1.1em으로 잡았다가 한글 받침이 잘려 1.25em으로 올렸고, 그러고 나서야 아홉 개 전부 320px 폭 검사에서 넘침 0으로 통과했습니다. 마지막으로 09번만은 transform이 아니라 width를 움직입니다. 레이아웃을 다시 계산하게 만드는 값이라 되도록 피하는 것이 맞지만, 알약이 진짜 원이 되려면 폭이 줄어야 해서 overflow: hidden과 격자 겹치기로 글자가 찌그러지지 않게 잡아 두고 이 하나만 예외로 두었습니다. 버튼 하나가 기다림을 표시하기 시작하면 화면 전체가 그 일을 대신하는 로딩페이지 디자인과 같은 문제로 넘어갑니다. 압축 비밀번호 cpm3zu6e로 여는 zip 안의 소스에는 위 수정이 전부 반영돼 있어, 받은 파일을 그대로 열면 여기 적힌 값과 같은 코드가 들어 있습니다.
접근성
아홉 개 전부 prefers-reduced-motion: reduce에서 장식 루프를 끄되 신호는 남깁니다. 빛무리는 가운데 고정, 러너는 은은한 테두리 한 겹, 물결은 채워진 상태, 조각 넷은 모인 상태로 멈춥니다. 08번 조준선은 마우스 없이도 쓸 수 있도록 :focus-visible에 같은 모양을 붙였고, 09번은 기다리는 동안 aria-busy="true"로 상태를 알린 뒤 끝나면 지웁니다.
| 상태 | 움직임이 켜져 있을 때 | reduced-motion 일 때 |
|---|---|---|
| 가리킴 (01·02·04·05·06) | 빛·물결·글자가 2초 주기로 돈다 | 최종 모습으로 정지, 색 대비는 그대로 |
| 누름 (03·07) | 당김과 눌림 반동이 이어진다 | 이동·스케일 없이 색과 그림자만 |
| 포커스·대기 (08·09) | 조각이 모이고 체크가 그려진다 | 조각은 모인 채 고정, 체크는 즉시 표시 |
사용자 지정 속성을 애니메이션하려면 타입 등록이 필요하다는 규칙은 MDN의 @property 문서에 정리돼 있습니다. 같은 방아쇠를 쓰는 다른 효과는 호버 카테고리에, 실제 전환 버튼에 쓰는 것들은 CTA 카테고리에 모아 두었습니다.
FAQ
css 버튼 클릭 효과까지 자바스크립트 없이 쓸 수 있는 건 몇 개인가요?
아홉 개 중 다섯 개는 CSS만으로 끝납니다. 커서 좌표를 읽는 01번과 03번, 글자를 한 칸씩 쪼개 쌓는 05번, 누름 상태를 바꾸는 09번만 각각 스무 줄 안쪽의 바닐라 자바스크립트를 씁니다. 그 네 곳이 하는 일도 CSS 변수와 클래스를 바꾸고 글자 칸을 만들어 주는 것뿐이라, 스크롤 라이브러리나 애니메이션 라이브러리는 하나도 들어가지 않습니다. 수상작 기법을 한 줄짜리로 줄인 판은 바이브코딩 마이크로 인터랙션에 모여 있습니다.
터치 기기에서는 호버가 없는데 그대로 써도 되나요?
호버가 시작 신호인 항목(01·02·04·05·06)은 터치에서 아무 일도 일어나지 않으므로, 그런 자리에는 07번처럼 :active로도 같은 결과가 나오는 효과를 함께 두는 편이 안전합니다. 07번과 09번은 누름과 대기 상태를 쓰므로 터치에서도 그대로 작동하고, 08번은 :focus-visible 덕분에 키보드에서도 같은 모양이 나옵니다.
색과 속도를 우리 브랜드에 맞게 바꾸려면 어디를 고치나요?
각 SCSS 파일 맨 위 세 줄($duration·$easing·$color, 05번만 $color 대신 $stagger)만 바꾸면 됩니다. React 판도 같은 세 값을 props로 받아 CSS 사용자 지정 속성으로 넘기므로, 컴포넌트를 부를 때 duration={1200}처럼 넣으면 되고 나머지 코드는 건드리지 않습니다.









