9 button hover effects css that award sites reuse
These button hover effects css techniques are the reactions a button gives when a pointer arrives, sorted by what each signals.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Cursor spotlight
- 02 Border light runner
- 03 Magnetic pull with label offset
- 04 Liquid fill with a wave line
- 05 Per-letter split slide
- 06 Icon swap on a spin
- 07 Squash and stretch press
- 08 Corner brackets closing in
- 09 Loading morph: button to spinner to check
Look closely at award-winning sites and their button work stops looking random: the same handful of techniques keep showing up, each doing the same job. So the order below is not by popularity — it is the order in which a button meets a person. First it has to be noticed from across the page (01, 02), then a button hover animation css answers an approaching pointer (03), then it changes its surface, letters, or icon while you aim at it (04, 05, 06), then it returns a physical answer on press (07, 08), and finally it reports what is happening while you wait (09). No brand or site is copied here, only the technique, so every one of the nine takes your own colors and your own label. Picking how the button looks before any hover comes first in css button design ideas, and what happens after the finger lifts is picked up by button state css patterns.
01Cursor spotlight
What you see in these button hover effects examples
A round pool of light tracks the pointer across the face of the button. Only the horizontal center moves, passed in as --mx, and the gradient paints everything else.

<main class="stage">
<button class="spot is-demo" type="button"><span class="spot__label">See pricing</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 · Cursor spotlight — Measured area 7.528% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Registering that variable is the part people miss.
@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%; }
}
02Border light runner
What happens on screen
A single bright point runs around the border, lap after lap, with no visible seam.

<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">Start free trial</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 · Border light runner — Measured area 1.381% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
The whole conic gradient rotates while a mask keeps nothing but the border's thickness, so the same rule follows a pill, a rectangle, or a squircle without any new geometry.
.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;
}
03Magnetic pull with label offset
What moves
The button travels toward the pointer while the label takes the same coordinate multiplied by a negative number.

<main class="stage">
<div class="mag__field">
<button class="mag is-demo" type="button"><span class="mag__label">View projects</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 · Magnetic pull with label offset — Measured area 10.698% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
That one change of sign splits the two layers apart, and a flat rectangle suddenly reads as something floating slightly above the page.
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';
});
04Liquid fill with a wave line
What you see
Color rises from the bottom, but the surface tilts like water instead of staying flat.

<main class="stage">
<button class="liq is-demo" type="button">
<span class="liq__wave" aria-hidden="true"></span>
<span class="liq__label">Add to cart</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 · Liquid fill with a wave line — Measured area 6.315% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
It is not a circle: a 260px square rounded to 42% turns on its own axis while the whole layer climbs, so the waterline is never level twice.
.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); }
}
05Per-letter split slide
What happens on screen
Letters leave upward one at a time and identical copies rise from below to fill the gap.

<main class="stage">
<button class="split is-demo" type="button" aria-label="See details"><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 · Per-letter split slide — Measured area 2.35% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Each letter gets its own overflow: hidden window holding two stacked copies, and a delay proportional to the letter index is what turns a row of separate moves into one ripple.
.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; }
}
06Icon swap on a spin
What moves
The icon turns half a revolution on its vertical axis and comes back as a different icon.
<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">Add</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 · Icon swap on a spin — Measured area 0.485% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Two icons share one cell, each hiding its own back face, so a button can go from add to added without touching its label.
.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); }
}
07Squash and stretch press
What you see
A press flattens the button vertically and spreads it sideways; the release springs past the resting size and settles after one smaller wobble.

<main class="stage">
<button class="squash is-demo" type="button"><span class="squash__label">Submit</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 · Squash and stretch press — Measured area 5.59% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Thinning the shadow on the same beat is the other half of the trick — scale alone looks like a glitch, while scale plus shadow looks like rubber.
.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;
}
08Corner brackets closing in
What happens on screen
Four L-shaped pieces close in from the outside and complete a frame.

<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">Open settings</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 · Corner brackets closing in — Measured area 2.501% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Each corner differs only in which two borders it drops and which way it travels; the distance stays 18px for all four, so widening or tightening the whole frame is a single number.
.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; }
}
09Loading morph: button to spinner to check
What moves
A press hides the label, narrows the pill into a circle, spins, draws a check, and then returns to full width.

<main class="stage">
<button class="morph is-demo" type="button">
<span class="morph__label">Pay now</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 · Loading morph: button to spinner to check — Measured area 6.331% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Three states live inside one element, so no separate loading overlay is needed, and a second press cannot get through while the first is still running.
@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; }
Where it breaks — the trap
The longest fight in this set was that 01 and 02 simply did not move. Dropping --mx and --angle into keyframes looks like it should interpolate, but a browser treats an unregistered custom property as a plain string, and strings have no midpoint. Across 24 rendered frames the light simply sat in one of two positions and then vanished. Declaring syntax: "<percentage>" and "<angle>" through @property gave the values a type, and only then did the runner complete a real lap.
The second trap was the seam where a loop ends and starts again. Cutting the runner at 340deg and snapping back to 0deg made the light jump once per lap, so the angle now travels a full 360deg and lands exactly on the next lap's zero. The icon in 06 had the same problem at 180deg — the rewind made the swap visibly run backwards — and it now continues to 360deg instead. Item 04 failed in the opposite direction: with a 320px droplet the button was already full by the fifth rendered frame, which left the wave surface invisible, so the droplet dropped to 260px and the rise was stretched across 52% of the loop until the curved waterline actually crossed the label on camera. Item 05 clipped the descenders of its letters in a 1.1em window and needed 1.25em. Only after all of that did the nine pass the 320px-wide overflow check with zero failures. Once a single button starts signalling a wait, it becomes the same problem the whole screen solves in Loading Screen Designs. Everything above ships inside the archive that opens with the password cpm3zu6e, so what you download is the same code printed here. One deliberate exception remains: 09 animates width, not transform. Width is a layout property and normally worth avoiding, but a pill cannot become a real circle without its width changing, so the label is held steady with overflow: hidden and a one-cell grid instead.
Accessibility (reduced-motion)
Under prefers-reduced-motion: reduce all nine drop the decorative loop but keep the signal. The spotlight parks in the center, the runner settles into a quiet ring, the liquid stays filled, and the four brackets stay closed. Item 08 carries the same shape on :focus-visible, so it works without a mouse at all, and item 09 announces the wait with aria-busy="true" and clears it when the work finishes.
| State | Motion on | Motion reduced |
|---|---|---|
| Aiming (01, 02, 04, 05, 06) | Light, liquid, and letters cycle on a 2s loop | Frozen at the end pose, contrast unchanged |
| Pressing (03, 07) | Pull and squash run back to back | Color and shadow only, no travel or scale |
| Focus and waiting (08, 09) | Brackets close in, the check draws itself | Brackets stay closed, the check appears at once |
The rule that a custom property must be registered before it can animate is documented in the MDN page for @property. More effects with the same trigger live in the hover category, and the ones aimed at conversion buttons are in the CTA category.
FAQ
How many of these button hover state css demos work without JavaScript?
Five of the nine are pure CSS, with no script tag at all. Of the rest, only 01 and 03 read a real pointer coordinate, each in under twenty lines of plain JavaScript, while 05 builds its letter cells and 09 toggles a busy class. Every one of those scripts does nothing but set a CSS variable or a class name, so no animation library, no scroll library, and no build step is required for any of the nine. The one-line versions of these award-site techniques are gathered in Micro Interactions CSS.
Touch devices have no hover — are these safe to ship there?
The five meant for hover (01, 02, 04, 05, 06) have nothing to react to on a tap, so on touch-heavy screens it is safer to pair them with something like 07, which produces the same result from :active. Items 07 and 09 rely on press and busy states, so they behave identically under a finger, and 08 reaches keyboard users through :focus-visible.
Where do I change the color and speed for my own brand?
The three variables at the top of each SCSS file — $duration, $easing, and a third that is usually $color ($stagger in 05) — are the whole control surface. The React port takes the same three as props and forwards them as CSS custom properties, so calling the component with duration={1200} is enough, and nothing else in the file has to be touched.









