9 CSS Button Click Feedback Effects, Copy-Paste
CSS button click feedback is the flash, scale, or color shift a button shows the instant it's pressed, confirming a tap landed — nine effects below, one
01 click-touch-feedback-ripple-css
02 click-touch-feedback-press-shrink
03 click-touch-feedback-touch-active-nohover
04 click-touch-feedback-long-press-hold
05 click-touch-feedback-list-tap-highlight
06 click-touch-feedback-card-tap-scale
07 click-touch-feedback-haptic-shake
08 click-touch-feedback-checkbox-tap
09 click-touch-feedback-link-tap-feedback
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Ring pulse (outline, not fill)
- 02 Press shrink + shadow flatten
- 03 Touch-only :active via pointer: coarse
- 04 Long-press delay reveal (no fill bar)
- 05 List row tap highlight
- 06 Card tap scale + brightness dim
- 07 Tap-confirm jitter (not the error shake)
- 08 Checkbox tap feedback
- 09 Inline link tap feedback
These nine css button design snippets sit in the order one tap actually moves through a screen: 01 and 02 confirm a plain button press, 03 and 04 change how or when that confirmation fires, 05 and 06 scale the same idea up to a whole row or card, and 07 through 09 close on an icon jitter, a checkbox, and an inline link. The grid above replays each state on a loop by itself — every panel sits in its own iframe with nothing wired up to click, so watching is the only option here. Four of the nine, 02, 03, 04, and 06, reach for touch-specific media queries or touch-action instead of treating every tap as a mouse click wearing a costume. If item 07's quick like-confirm is the piece you actually want to grow, nine CSS heart animations spend a whole set on that one gesture, and if 01's ring should keep travelling around the button edge instead of pulsing once, the CSS border animations pick that thread up.
01Ring pulse (outline, not fill)
Why a css button animation on click outlives the press
A box-shadow ring blooms outward from the button's own edge and fades, triggered by a pointerdown listener that swaps in an .is-ringing class rather than leaning on :active alone, and this update doubles the ring into two coats that spread at different speeds and decay in steps, so the wavefront reads as irregular rather than perfectly smooth. So the ring finishes its full animation even after the finger or pointer lifts.

<main class="stage">
<button class="btn is-demo" type="button">Subscribe</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$duration: $dur-enter; // 링이 다 부풀고 사라지는 시간
$easing: $ease-out;
$color: $subject-blue; // 버튼·링 색
@include stage($stage-ink);
.btn {
position: relative;
border: 0;
border-radius: $r-pill;
padding: 18px 40px;
font-weight: 600; font-size: 18px; line-height: 1; font-family: inherit;
color: $subject-cream;
background: $subject-blue-deep;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: filter $dur-quick;
&:hover { filter: brightness(1.06); }
&:active { filter: brightness(.96); }
// 채워지는 원(::after 가 버튼 내부를 덮는 리플)이 아니라, 버튼 바깥으로 테두리만 번지는 링
// 규칙8 한 겹: 링 하나가 아니라 두 겹이 서로 다른 속도로 번져 노이즈처럼 불규칙한 파면을 만들고,
// 바깥 겹이 안쪽 겹보다 먼저 옅어지며 감쇠한다(선형 감쇠가 아니라 24%→52%→78% 단계별로 빠르게 줄어듦)
&::after {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
box-shadow: 0 0 0 0 rgba($color, .55), 0 0 0 0 rgba($color, .32);
opacity: 0;
pointer-events: none;
}
&.is-ringing::after {
animation: ring $duration $easing forwards;
}
// 데모용: 클릭 여부와 무관하게 2초마다 자동 반복. linear 로 프레임마다 고르게 퍼지게 한다
&.is-demo::after {
animation: ring-loop $dur-loop linear infinite;
}
}
@keyframes ring {
0% { box-shadow: 0 0 0 0 rgba($color, .68), 0 0 0 0 rgba($color, .44); opacity: 1; }
30% { box-shadow: 0 0 0 8px rgba($color, .4), 0 0 0 3px rgba($color, .44); opacity: 1; }
62% { box-shadow: 0 0 0 14px rgba($color, .18), 0 0 0 9px rgba($color, .24); opacity: .72; }
86% { box-shadow: 0 0 0 17px rgba($color, .04), 0 0 0 12px rgba($color, .06); opacity: .16; }
100% { box-shadow: 0 0 0 17px rgba($color, 0), 0 0 0 12px rgba($color, 0); opacity: 0; }
}
@keyframes ring-loop {
0%, 8% { box-shadow: 0 0 0 0 rgba($color, .68), 0 0 0 0 rgba($color, .44); opacity: 1; }
24% { box-shadow: 0 0 0 8px rgba($color, .4), 0 0 0 3px rgba($color, .44); opacity: 1; }
40% { box-shadow: 0 0 0 14px rgba($color, .18), 0 0 0 9px rgba($color, .24); opacity: .72; }
52% { box-shadow: 0 0 0 17px rgba($color, .04), 0 0 0 12px rgba($color, .06); opacity: .16; }
62%, 100% { box-shadow: 0 0 0 17px rgba($color, 0), 0 0 0 12px rgba($color, 0); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.btn::after { animation: none !important; opacity: 0; }
.btn:active { filter: brightness(.88); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.btn {
position: relative;
border: 0;
border-radius: 999px;
padding: 18px 40px;
font-weight: 600;
font-size: 18px;
line-height: 1;
font-family: inherit;
color: #fff7e6;
background: #0a4fe8;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: filter 220ms;
}
.btn:hover {
filter: brightness(1.06);
}
.btn:active {
filter: brightness(0.96);
}
.btn::after {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.55), 0 0 0 0 rgba(47, 109, 246, 0.32);
opacity: 0;
pointer-events: none;
}
.btn.is-ringing::after {
animation: ring 600ms ease-out forwards;
}
.btn.is-demo::after {
animation: ring-loop 2s linear infinite;
}
@keyframes ring {
0% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.68), 0 0 0 0 rgba(47, 109, 246, 0.44);
opacity: 1;
}
30% {
box-shadow: 0 0 0 8px rgba(47, 109, 246, 0.4), 0 0 0 3px rgba(47, 109, 246, 0.44);
opacity: 1;
}
62% {
box-shadow: 0 0 0 14px rgba(47, 109, 246, 0.18), 0 0 0 9px rgba(47, 109, 246, 0.24);
opacity: 0.72;
}
86% {
box-shadow: 0 0 0 17px rgba(47, 109, 246, 0.04), 0 0 0 12px rgba(47, 109, 246, 0.06);
opacity: 0.16;
}
100% {
box-shadow: 0 0 0 17px rgba(47, 109, 246, 0), 0 0 0 12px rgba(47, 109, 246, 0);
opacity: 0;
}
}
@keyframes ring-loop {
0%, 8% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.68), 0 0 0 0 rgba(47, 109, 246, 0.44);
opacity: 1;
}
24% {
box-shadow: 0 0 0 8px rgba(47, 109, 246, 0.4), 0 0 0 3px rgba(47, 109, 246, 0.44);
opacity: 1;
}
40% {
box-shadow: 0 0 0 14px rgba(47, 109, 246, 0.18), 0 0 0 9px rgba(47, 109, 246, 0.24);
opacity: 0.72;
}
52% {
box-shadow: 0 0 0 17px rgba(47, 109, 246, 0.04), 0 0 0 12px rgba(47, 109, 246, 0.06);
opacity: 0.16;
}
62%, 100% {
box-shadow: 0 0 0 17px rgba(47, 109, 246, 0), 0 0 0 12px rgba(47, 109, 246, 0);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.btn::after {
animation: none !important;
opacity: 0;
}
.btn:active {
filter: brightness(0.88);
}
}// 링은 클릭이 끝나도 끝까지 재생돼야 해서 :active 만으로는 부족하다. pointerdown 에서 클래스를 한 번 갈아끼운다.
var b = document.querySelector('.btn');
b.addEventListener('pointerdown', function () {
b.classList.remove('is-demo');
b.classList.remove('is-ringing');
void b.offsetWidth;
b.classList.add('is-ringing');
});
b.addEventListener('animationend', function (e) {
if (e.animationName === 'ring') b.classList.remove('is-ringing');
});import { useCallback, useRef, type CSSProperties } from "react";
import styles from "./RingPulseButton.module.scss";
export type RingPulseButtonProps = {
label: string;
/** 링이 다 부풀고 사라지는 시간 (기본 600ms) */
duration?: number;
easing?: string;
/** 버튼·링 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function RingPulseButton({
label,
duration = 600,
easing = "ease-out",
color = "#0a4fe8",
}: RingPulseButtonProps) {
const ref = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 클릭이 끝나도 링은 끝까지 재생돼야 해서 :active 만으로는 부족하다 (index.html 과 같은 이유)
const onPointerDown = useCallback(() => {
const b = ref.current;
if (!b) return;
b.classList.remove(styles.ringing);
void b.offsetWidth;
b.classList.add(styles.ringing);
}, []);
const onAnimationEnd = useCallback((e: { animationName: string }) => {
if (e.animationName.includes("ring")) ref.current?.classList.remove(styles.ringing);
}, []);
return (
<button
ref={ref}
type="button"
className={styles.btn}
style={vars}
onPointerDown={onPointerDown}
onAnimationEnd={onAnimationEnd}
>
{label}
</button>
);
}01 · Ring pulse (outline, not fill) — Measured area 4.103% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where a ring beats a ripple
Reach for it on icon buttons that already use a filled ripple elsewhere, where a ring supplies a second, visibly different signal instead of stacking two near-identical waves.
.btn::after {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
box-shadow: 0 0 0 0 rgba($color, .55);
opacity: 0;
pointer-events: none;
}
.btn.is-ringing::after {
animation: ring $duration $easing forwards;
}
@keyframes ring {
0% { box-shadow: 0 0 0 0 rgba($color, .55); opacity: 1; }
100% { box-shadow: 0 0 0 26px rgba($color, 0); opacity: 0; }
}
02Press shrink + shadow flatten
What shrinks, and by how much
While pressed, the button shrinks to scale(.96) and its box-shadow flattens at the same time, then springs back the instant the pointer lifts with no spring bounce, and on the press itself two ring coats now spread outward at different speeds and damp away, so the press reads as travelling beyond the button's edge.

<main class="stage">
<button class="btn is-demo" type="button">Buy now</button>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 눌렸다가 돌아오는 시간 (바운스 없음)
$easing: $ease-out;
$color: $subject-ink; // 버튼 색
@include stage($stage-yellow);
.btn {
position: relative;
border: 0;
border-radius: $r-control;
padding: 18px 40px;
font-weight: 600; font-size: 18px; line-height: 1; font-family: inherit;
color: $subject-cream;
background: $color;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation; // 더블탭 확대·300ms 지연 제거
box-shadow: $shadow-raised;
transform: scale(1);
transition: transform $duration $easing, box-shadow $duration $easing;
// 규칙8 한 겹: 눌리는 순간 버튼 바깥으로 두 겹 링이 서로 다른 속도로 불규칙하게 번지다 감쇠
&::after {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
box-shadow: 0 0 0 0 rgba($subject-blue, .5), 0 0 0 0 rgba($subject-blue, .3);
opacity: 0;
pointer-events: none;
}
}
.btn:active {
transform: scale(.96);
box-shadow: $shadow-press;
}
.btn:active::after {
animation: ripple-wave $dur-enter $easing;
}
.btn:focus-visible {
outline: 3px solid $subject-blue;
outline-offset: 3px;
}
.btn.is-demo { animation: press-loop $dur-loop $easing infinite; }
.btn.is-demo::after { animation: ripple-loop $dur-loop $easing infinite; }
@keyframes press-loop {
0%, 20% { transform: scale(1); box-shadow: $shadow-raised; }
45%, 60% { transform: scale(.96); box-shadow: $shadow-press; }
100% { transform: scale(1); box-shadow: $shadow-raised; }
}
@keyframes ripple-wave {
0% { box-shadow: 0 0 0 0 rgba($subject-blue, .55), 0 0 0 0 rgba($subject-blue, .34); opacity: 1; }
20% { box-shadow: 0 0 0 8px rgba($subject-blue, .55), 0 0 0 3px rgba($subject-blue, .34); opacity: 1; }
55% { box-shadow: 0 0 0 14px rgba($subject-blue, .3), 0 0 0 9px rgba($subject-blue, .2); opacity: .9; }
85% { box-shadow: 0 0 0 16px rgba($subject-blue, .06), 0 0 0 12px rgba($subject-blue, .04); opacity: .2; }
100% { box-shadow: 0 0 0 16px rgba($subject-blue, 0), 0 0 0 12px rgba($subject-blue, 0); opacity: 0; }
}
@keyframes ripple-loop {
0%, 40% { box-shadow: 0 0 0 0 rgba($subject-blue, .55), 0 0 0 0 rgba($subject-blue, .34); opacity: 1; }
46% { box-shadow: 0 0 0 8px rgba($subject-blue, .55), 0 0 0 3px rgba($subject-blue, .34); opacity: 1; }
53% { box-shadow: 0 0 0 14px rgba($subject-blue, .3), 0 0 0 9px rgba($subject-blue, .2); opacity: .9; }
58% { box-shadow: 0 0 0 16px rgba($subject-blue, .06), 0 0 0 12px rgba($subject-blue, .04); opacity: .2; }
63%, 100% { box-shadow: 0 0 0 16px rgba($subject-blue, 0), 0 0 0 12px rgba($subject-blue, 0); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.btn { transition: none; }
.btn::after { animation: none !important; opacity: 0; }
.btn.is-demo { animation: none; box-shadow: $shadow-press; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.btn {
position: relative;
border: 0;
border-radius: 12px;
padding: 18px 40px;
font-weight: 600;
font-size: 18px;
line-height: 1;
font-family: inherit;
color: #fff7e6;
background: #17141a;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: scale(1);
transition: transform 220ms ease-out, box-shadow 220ms ease-out;
}
.btn::after {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.5), 0 0 0 0 rgba(47, 109, 246, 0.3);
opacity: 0;
pointer-events: none;
}
.btn:active {
transform: scale(0.96);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.btn:active::after {
animation: ripple-wave 600ms ease-out;
}
.btn:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.btn.is-demo {
animation: press-loop 2s ease-out infinite;
}
.btn.is-demo::after {
animation: ripple-loop 2s ease-out infinite;
}
@keyframes press-loop {
0%, 20% {
transform: scale(1);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
45%, 60% {
transform: scale(0.96);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
100% {
transform: scale(1);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
}
@keyframes ripple-wave {
0% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.55), 0 0 0 0 rgba(47, 109, 246, 0.34);
opacity: 1;
}
20% {
box-shadow: 0 0 0 8px rgba(47, 109, 246, 0.55), 0 0 0 3px rgba(47, 109, 246, 0.34);
opacity: 1;
}
55% {
box-shadow: 0 0 0 14px rgba(47, 109, 246, 0.3), 0 0 0 9px rgba(47, 109, 246, 0.2);
opacity: 0.9;
}
85% {
box-shadow: 0 0 0 16px rgba(47, 109, 246, 0.06), 0 0 0 12px rgba(47, 109, 246, 0.04);
opacity: 0.2;
}
100% {
box-shadow: 0 0 0 16px rgba(47, 109, 246, 0), 0 0 0 12px rgba(47, 109, 246, 0);
opacity: 0;
}
}
@keyframes ripple-loop {
0%, 40% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.55), 0 0 0 0 rgba(47, 109, 246, 0.34);
opacity: 1;
}
46% {
box-shadow: 0 0 0 8px rgba(47, 109, 246, 0.55), 0 0 0 3px rgba(47, 109, 246, 0.34);
opacity: 1;
}
53% {
box-shadow: 0 0 0 14px rgba(47, 109, 246, 0.3), 0 0 0 9px rgba(47, 109, 246, 0.2);
opacity: 0.9;
}
58% {
box-shadow: 0 0 0 16px rgba(47, 109, 246, 0.06), 0 0 0 12px rgba(47, 109, 246, 0.04);
opacity: 0.2;
}
63%, 100% {
box-shadow: 0 0 0 16px rgba(47, 109, 246, 0), 0 0 0 12px rgba(47, 109, 246, 0);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.btn {
transition: none;
}
.btn::after {
animation: none !important;
opacity: 0;
}
.btn.is-demo {
animation: none;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}import type { CSSProperties } from "react";
import styles from "./PressShrinkButton.module.scss";
export type PressShrinkButtonProps = {
label: string;
/** 눌렸다가 돌아오는 시간, 바운스 없음 (기본 220ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PressShrinkButton({
label,
duration = 220,
easing = "ease-out",
color = "#17141a",
}: PressShrinkButtonProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button type="button" className={styles.btn} style={vars}>
{label}
</button>
);
}02 · Press shrink + shadow flatten — Measured area 6.326% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why submit buttons lean on it
touch-action: manipulation strips the tap delay and double-tap zoom that would otherwise blur that timing on a phone, which is why submit and checkout buttons — anywhere confirming the press matters most — lean on it.
.btn {
transform: scale(1);
transition: transform $duration $easing, box-shadow $duration $easing;
touch-action: manipulation;
}
.btn:active {
transform: scale(.96);
box-shadow: $shadow-press;
}
03Touch-only :active via pointer: coarse
Scoping :active to touch only
@media (hover: none) and (pointer: coarse) scopes the :active background to touch devices only, so a mouse-driven browser never even parses that rule and can't leave a stuck css button hover gray behind after a click, and on touch the press now also wakes two ring coats that spread irregularly and damp away.

<main class="stage">
<button class="btn is-demo" type="button">Next</button>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-cream; // 버튼 색
@include stage($stage-orange);
.btn {
position: relative;
border: 0;
border-radius: $r-control;
padding: 18px 44px;
font-weight: 600; font-size: 18px; line-height: 1; font-family: inherit;
color: $stage-ink;
background: $color;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background $duration $easing, color $duration $easing;
// 규칙8 한 겹: 색 반전 순간 버튼 바깥으로 두 겹 링이 서로 다른 속도로 불규칙하게 번지다 감쇠
&::after {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
box-shadow: 0 0 0 0 rgba($subject-blue, .48), 0 0 0 0 rgba($subject-blue, .28);
opacity: 0;
pointer-events: none;
}
}
// 실제 규칙: 호버가 없는(터치) 기기에서만 :active 배경을 준다.
// 마우스 기기는 이 블록을 아예 안 읽어서, 탭 뒤 색이 눌어붙는 일이 없다.
@media (hover: none) and (pointer: coarse) {
.btn:active {
background: $stage-ink;
color: $color;
}
.btn:active::after {
animation: ripple-wave $dur-enter $easing;
}
}
// 데모용: 렌더 환경은 마우스 포인터로 잡히므로 위 미디어쿼리가 실제로 켜졌을 때의
// 모습을 .is-demo 로 강제 재생한다 (실제 터치 여부와 무관).
.btn.is-demo {
animation: touch-active-loop $dur-loop $easing infinite;
}
.btn.is-demo::after {
animation: ripple-loop $dur-loop $easing infinite;
}
@keyframes touch-active-loop {
0%, 20% { background: $subject-cream; color: $stage-ink; }
50%, 75% { background: $stage-ink; color: $subject-cream; }
100% { background: $subject-cream; color: $stage-ink; }
}
@keyframes ripple-wave {
0% { box-shadow: 0 0 0 0 rgba($subject-blue, .48), 0 0 0 0 rgba($subject-blue, .28); opacity: 1; }
38% { box-shadow: 0 0 0 10px rgba($subject-blue, .2), 0 0 0 4px rgba($subject-blue, .26); opacity: .74; }
66% { box-shadow: 0 0 0 17px rgba($subject-blue, .09), 0 0 0 11px rgba($subject-blue, .15); opacity: .36; }
100% { box-shadow: 0 0 0 24px rgba($subject-blue, 0), 0 0 0 20px rgba($subject-blue, 0); opacity: 0; }
}
@keyframes ripple-loop {
0%, 20% { box-shadow: 0 0 0 0 rgba($subject-blue, .48), 0 0 0 0 rgba($subject-blue, .28); opacity: 1; }
38% { box-shadow: 0 0 0 10px rgba($subject-blue, .2), 0 0 0 4px rgba($subject-blue, .26); opacity: .74; }
55% { box-shadow: 0 0 0 17px rgba($subject-blue, .09), 0 0 0 11px rgba($subject-blue, .15); opacity: .36; }
75%, 100% { box-shadow: 0 0 0 24px rgba($subject-blue, 0), 0 0 0 20px rgba($subject-blue, 0); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.btn { transition: none; }
.btn::after { animation: none !important; opacity: 0; }
.btn.is-demo { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.btn {
position: relative;
border: 0;
border-radius: 12px;
padding: 18px 44px;
font-weight: 600;
font-size: 18px;
line-height: 1;
font-family: inherit;
color: #17141a;
background: #fff7e6;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background 220ms ease-out, color 220ms ease-out;
}
.btn::after {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.48), 0 0 0 0 rgba(47, 109, 246, 0.28);
opacity: 0;
pointer-events: none;
}
@media (hover: none) and (pointer: coarse) {
.btn:active {
background: #17141a;
color: #fff7e6;
}
.btn:active::after {
animation: ripple-wave 600ms ease-out;
}
}
.btn.is-demo {
animation: touch-active-loop 2s ease-out infinite;
}
.btn.is-demo::after {
animation: ripple-loop 2s ease-out infinite;
}
@keyframes touch-active-loop {
0%, 20% {
background: #fff7e6;
color: #17141a;
}
50%, 75% {
background: #17141a;
color: #fff7e6;
}
100% {
background: #fff7e6;
color: #17141a;
}
}
@keyframes ripple-wave {
0% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.48), 0 0 0 0 rgba(47, 109, 246, 0.28);
opacity: 1;
}
38% {
box-shadow: 0 0 0 10px rgba(47, 109, 246, 0.2), 0 0 0 4px rgba(47, 109, 246, 0.26);
opacity: 0.74;
}
66% {
box-shadow: 0 0 0 17px rgba(47, 109, 246, 0.09), 0 0 0 11px rgba(47, 109, 246, 0.15);
opacity: 0.36;
}
100% {
box-shadow: 0 0 0 24px rgba(47, 109, 246, 0), 0 0 0 20px rgba(47, 109, 246, 0);
opacity: 0;
}
}
@keyframes ripple-loop {
0%, 20% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.48), 0 0 0 0 rgba(47, 109, 246, 0.28);
opacity: 1;
}
38% {
box-shadow: 0 0 0 10px rgba(47, 109, 246, 0.2), 0 0 0 4px rgba(47, 109, 246, 0.26);
opacity: 0.74;
}
55% {
box-shadow: 0 0 0 17px rgba(47, 109, 246, 0.09), 0 0 0 11px rgba(47, 109, 246, 0.15);
opacity: 0.36;
}
75%, 100% {
box-shadow: 0 0 0 24px rgba(47, 109, 246, 0), 0 0 0 20px rgba(47, 109, 246, 0);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.btn {
transition: none;
}
.btn::after {
animation: none !important;
opacity: 0;
}
.btn.is-demo {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./TouchActiveButton.module.scss";
export type TouchActiveButtonProps = {
label: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** @media (hover: none) and (pointer: coarse) 에서만 :active 배경이 걸린다. 마우스 기기에서는 이 규칙이 아예 읽히지 않는다. */
export default function TouchActiveButton({
label,
duration = 220,
easing = "ease-out",
color = "#fff7e6",
}: TouchActiveButtonProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button type="button" className={styles.btn} style={vars}>
{label}
</button>
);
}03 · Touch-only :active via pointer: coarse — Measured area 8.344% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where the stuck gray disappears
It fits mobile web buttons that otherwise leave a gray background lingering after every tap.
@media (hover: none) and (pointer: coarse) {
.btn:active {
background: $stage-ink;
color: $subject-cream;
}
}
04Long-press delay reveal (no fill bar)
Holding the response back 600ms
Nothing happens the instant a finger lands — a transition-delay of 600ms holds the response back, and only once that threshold passes does the card grow while its border and shadow darken, with two ring coats spreading from the touch point at different speeds and damping out.

<main class="stage">
<div class="card is-demo" tabindex="0">More</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 문턱을 넘은 뒤 반응이 실제로 펼쳐지는 시간
$hold: $dur-enter; // 반응이 시작되기까지 참는 시간 (누르는 순간엔 아무 일도 없다)
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-paper);
.stage { padding: clamp(8px, 4vw, 24px); }
.card {
position: relative;
width: min(180px, 60vw);
border-radius: $r-card;
background: $subject-cream;
border: 2px solid rgba($stage-ink, .12);
padding: 22px 16px;
text-align: center;
font-weight: 600; font-size: 15px; line-height: 1.4; font-family: inherit;
color: $stage-ink;
cursor: pointer;
touch-action: none; // 누르는 동안 스크롤 제스처와 겹치지 않게
transform: scale(1);
box-shadow: none;
// transition-delay 가 곧 "길게 눌러야만" 반응하는 문턱이다. 진행바를 채우지 않는다.
transition: transform $duration $easing $hold,
box-shadow $duration $easing $hold,
border-color $duration $easing $hold;
// 규칙8 한 겹: 문턱을 넘어 반응이 펼쳐지는 순간 카드 바깥으로 두 겹 링이 불규칙하게 번지다 감쇠
&::after {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
box-shadow: 0 0 0 0 rgba($color, .45), 0 0 0 0 rgba($color, .26);
opacity: 0;
pointer-events: none;
}
}
.card:active {
transform: scale(1.06);
box-shadow: $shadow-raised;
border-color: $color;
}
.card:active::after {
animation: ripple-wave $dur-enter $easing $hold;
}
.card:focus-visible {
outline: 3px solid $color;
outline-offset: 3px;
}
.card.is-demo {
animation: long-press-loop $dur-loop $easing infinite;
}
.card.is-demo::after {
animation: ripple-loop $dur-loop $easing infinite;
}
@keyframes long-press-loop {
0%, 30% { transform: scale(1); box-shadow: none; border-color: rgba($stage-ink, .12); }
70%, 85% { transform: scale(1.06); box-shadow: $shadow-raised; border-color: $color; }
100% { transform: scale(1); box-shadow: none; border-color: rgba($stage-ink, .12); }
}
@keyframes ripple-wave {
0% { box-shadow: 0 0 0 0 rgba($color, .45), 0 0 0 0 rgba($color, .26); opacity: 1; }
38% { box-shadow: 0 0 0 10px rgba($color, .2), 0 0 0 4px rgba($color, .24); opacity: .72; }
66% { box-shadow: 0 0 0 17px rgba($color, .09), 0 0 0 11px rgba($color, .14); opacity: .34; }
100% { box-shadow: 0 0 0 24px rgba($color, 0), 0 0 0 20px rgba($color, 0); opacity: 0; }
}
@keyframes ripple-loop {
0%, 30% { box-shadow: 0 0 0 0 rgba($color, .45), 0 0 0 0 rgba($color, .26); opacity: 1; }
50% { box-shadow: 0 0 0 10px rgba($color, .2), 0 0 0 4px rgba($color, .24); opacity: .72; }
67% { box-shadow: 0 0 0 17px rgba($color, .09), 0 0 0 11px rgba($color, .14); opacity: .34; }
85%, 100% { box-shadow: 0 0 0 24px rgba($color, 0), 0 0 0 20px rgba($color, 0); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.card { transition: none; }
.card::after { animation: none !important; opacity: 0; }
.card.is-demo { 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);
}
.stage {
padding: clamp(8px, 4vw, 24px);
}
.card {
position: relative;
width: min(180px, 60vw);
border-radius: 16px;
background: #fff7e6;
border: 2px solid rgba(23, 20, 26, 0.12);
padding: 22px 16px;
text-align: center;
font-weight: 600;
font-size: 15px;
line-height: 1.4;
font-family: inherit;
color: #17141a;
cursor: pointer;
touch-action: none;
transform: scale(1);
box-shadow: none;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1) 600ms, box-shadow 220ms cubic-bezier(0.2, 1.4, 0.4, 1) 600ms, border-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1) 600ms;
}
.card::after {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.45), 0 0 0 0 rgba(47, 109, 246, 0.26);
opacity: 0;
pointer-events: none;
}
.card:active {
transform: scale(1.06);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
border-color: #2f6df6;
}
.card:active::after {
animation: ripple-wave 600ms cubic-bezier(0.2, 1.4, 0.4, 1) 600ms;
}
.card:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.card.is-demo {
animation: long-press-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.card.is-demo::after {
animation: ripple-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes long-press-loop {
0%, 30% {
transform: scale(1);
box-shadow: none;
border-color: rgba(23, 20, 26, 0.12);
}
70%, 85% {
transform: scale(1.06);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
border-color: #2f6df6;
}
100% {
transform: scale(1);
box-shadow: none;
border-color: rgba(23, 20, 26, 0.12);
}
}
@keyframes ripple-wave {
0% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.45), 0 0 0 0 rgba(47, 109, 246, 0.26);
opacity: 1;
}
38% {
box-shadow: 0 0 0 10px rgba(47, 109, 246, 0.2), 0 0 0 4px rgba(47, 109, 246, 0.24);
opacity: 0.72;
}
66% {
box-shadow: 0 0 0 17px rgba(47, 109, 246, 0.09), 0 0 0 11px rgba(47, 109, 246, 0.14);
opacity: 0.34;
}
100% {
box-shadow: 0 0 0 24px rgba(47, 109, 246, 0), 0 0 0 20px rgba(47, 109, 246, 0);
opacity: 0;
}
}
@keyframes ripple-loop {
0%, 30% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.45), 0 0 0 0 rgba(47, 109, 246, 0.26);
opacity: 1;
}
50% {
box-shadow: 0 0 0 10px rgba(47, 109, 246, 0.2), 0 0 0 4px rgba(47, 109, 246, 0.24);
opacity: 0.72;
}
67% {
box-shadow: 0 0 0 17px rgba(47, 109, 246, 0.09), 0 0 0 11px rgba(47, 109, 246, 0.14);
opacity: 0.34;
}
85%, 100% {
box-shadow: 0 0 0 24px rgba(47, 109, 246, 0), 0 0 0 20px rgba(47, 109, 246, 0);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.card::after {
animation: none !important;
opacity: 0;
}
.card.is-demo {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./LongPressCard.module.scss";
export type LongPressCardProps = {
label: string;
/** 문턱을 넘은 뒤 반응이 펼쳐지는 시간 (기본 220ms) */
duration?: number;
/** 반응이 시작되기까지 참는 시간 — 누르는 순간엔 아무 일도 없다 (기본 600ms) */
hold?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--hold"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function LongPressCard({
label,
duration = 220,
hold = 600,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: LongPressCardProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--hold": `${hold}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.card} style={vars} tabIndex={0}>
{label}
</div>
);
}04 · Long-press delay reveal (no fill bar) — Measured area 10.674% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why touch-action: none matters
touch-action: none keeps the browser's own scroll gesture from competing with the hold, which suits a card previewing a context menu, where a short accidental tap should trigger nothing at all.
.card {
transition: transform $duration $easing $hold,
box-shadow $duration $easing $hold,
border-color $duration $easing $hold;
touch-action: none;
}
.card:active {
transform: scale(1.06);
box-shadow: $shadow-raised;
border-color: $color;
}
05List row tap highlight
A flash instead of the gray rectangle
With -webkit-tap-highlight-color turned off, tapping a row flashes a custom background color that fades back out, instead of leaving the browser's own gray tap rectangle behind, and a two-coat ripple now travels along the row's inner edge and damps away, adding one more grain to the touch. Each row also holds a 44px minimum height so the tappable strip matches a finger's width rather than the text inside it.

<main class="stage">
<ul class="list">
<li class="is-demo" tabindex="0">Notifications</li>
<li class="is-demo" tabindex="0">Privacy</li>
<li class="is-demo" tabindex="0">Account</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 하이라이트가 사라지는 시간
$easing: $ease-out;
$color: $subject-blue; // 하이라이트 색
@include stage($stage-ink);
.list {
width: min(220px, 70vw);
background: $subject-cream;
border-radius: $r-card;
overflow: hidden;
list-style: none;
margin: 0;
padding: 0;
}
.list li {
position: relative;
min-height: 44px; // 터치 목표 44px (WCAG 2.5.8 / Apple HIG)
box-sizing: border-box;
display: flex;
align-items: center;
padding: 14px 18px;
font-weight: 600; font-size: 14px; line-height: 1; font-family: inherit;
color: $stage-ink;
background: $subject-cream;
border-bottom: 1px solid rgba($stage-ink, .08);
-webkit-tap-highlight-color: transparent;
cursor: pointer;
transition: background $duration $easing;
overflow: hidden;
// 규칙8 한 겹: 배경 플래시와 함께 행 안쪽 가장자리로 두 겹 링이 불규칙하게 번지다 감쇠
&::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: inset 0 0 0 0 rgba($color, .4), inset 0 0 0 0 rgba($color, .22);
opacity: 0;
pointer-events: none;
}
}
.list li:last-child { border-bottom: 0; }
.list li:active {
background: rgba($color, .18);
transition: background 0s; // 눌리는 순간은 지연 없이 즉시
}
.list li:active::after {
animation: ripple-wave $dur-enter $easing;
}
.list li.is-demo { animation: row-flash $dur-loop $easing infinite; }
.list li.is-demo::after { animation: ripple-loop $dur-loop $easing infinite; }
.list li:nth-child(2).is-demo,
.list li:nth-child(2).is-demo::after { animation-delay: .4s; }
.list li:nth-child(3).is-demo,
.list li:nth-child(3).is-demo::after { animation-delay: .8s; }
@keyframes row-flash {
0%, 8% { background: rgba($color, .18); }
35% { background: $subject-cream; }
100% { background: $subject-cream; }
}
@keyframes ripple-wave {
0% { box-shadow: inset 0 0 0 0 rgba($color, .4), inset 0 0 0 0 rgba($color, .22); opacity: 1; }
38% { box-shadow: inset 0 0 0 8px rgba($color, .18), inset 0 0 0 3px rgba($color, .22); opacity: .7; }
66% { box-shadow: inset 0 0 0 14px rgba($color, .08), inset 0 0 0 9px rgba($color, .12); opacity: .3; }
100% { box-shadow: inset 0 0 0 20px rgba($color, 0), inset 0 0 0 16px rgba($color, 0); opacity: 0; }
}
@keyframes ripple-loop {
0%, 8% { box-shadow: inset 0 0 0 0 rgba($color, .4), inset 0 0 0 0 rgba($color, .22); opacity: 1; }
22% { box-shadow: inset 0 0 0 8px rgba($color, .18), inset 0 0 0 3px rgba($color, .22); opacity: .7; }
35% { box-shadow: inset 0 0 0 14px rgba($color, .08), inset 0 0 0 9px rgba($color, .12); opacity: .3; }
50%, 100% { box-shadow: inset 0 0 0 20px rgba($color, 0), inset 0 0 0 16px rgba($color, 0); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.list li { transition: none; }
.list li::after { animation: none !important; opacity: 0; }
.list li.is-demo { animation: none; }
}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);
}
.list {
width: min(220px, 70vw);
background: #fff7e6;
border-radius: 16px;
overflow: hidden;
list-style: none;
margin: 0;
padding: 0;
}
.list li {
position: relative;
min-height: 44px;
box-sizing: border-box;
display: flex;
align-items: center;
padding: 14px 18px;
font-weight: 600;
font-size: 14px;
line-height: 1;
font-family: inherit;
color: #17141a;
background: #fff7e6;
border-bottom: 1px solid rgba(23, 20, 26, 0.08);
-webkit-tap-highlight-color: transparent;
cursor: pointer;
transition: background 220ms ease-out;
overflow: hidden;
}
.list li::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: inset 0 0 0 0 rgba(47, 109, 246, 0.4), inset 0 0 0 0 rgba(47, 109, 246, 0.22);
opacity: 0;
pointer-events: none;
}
.list li:last-child {
border-bottom: 0;
}
.list li:active {
background: rgba(47, 109, 246, 0.18);
transition: background 0s;
}
.list li:active::after {
animation: ripple-wave 600ms ease-out;
}
.list li.is-demo {
animation: row-flash 2s ease-out infinite;
}
.list li.is-demo::after {
animation: ripple-loop 2s ease-out infinite;
}
.list li:nth-child(2).is-demo,
.list li:nth-child(2).is-demo::after {
animation-delay: 0.4s;
}
.list li:nth-child(3).is-demo,
.list li:nth-child(3).is-demo::after {
animation-delay: 0.8s;
}
@keyframes row-flash {
0%, 8% {
background: rgba(47, 109, 246, 0.18);
}
35% {
background: #fff7e6;
}
100% {
background: #fff7e6;
}
}
@keyframes ripple-wave {
0% {
box-shadow: inset 0 0 0 0 rgba(47, 109, 246, 0.4), inset 0 0 0 0 rgba(47, 109, 246, 0.22);
opacity: 1;
}
38% {
box-shadow: inset 0 0 0 8px rgba(47, 109, 246, 0.18), inset 0 0 0 3px rgba(47, 109, 246, 0.22);
opacity: 0.7;
}
66% {
box-shadow: inset 0 0 0 14px rgba(47, 109, 246, 0.08), inset 0 0 0 9px rgba(47, 109, 246, 0.12);
opacity: 0.3;
}
100% {
box-shadow: inset 0 0 0 20px rgba(47, 109, 246, 0), inset 0 0 0 16px rgba(47, 109, 246, 0);
opacity: 0;
}
}
@keyframes ripple-loop {
0%, 8% {
box-shadow: inset 0 0 0 0 rgba(47, 109, 246, 0.4), inset 0 0 0 0 rgba(47, 109, 246, 0.22);
opacity: 1;
}
22% {
box-shadow: inset 0 0 0 8px rgba(47, 109, 246, 0.18), inset 0 0 0 3px rgba(47, 109, 246, 0.22);
opacity: 0.7;
}
35% {
box-shadow: inset 0 0 0 14px rgba(47, 109, 246, 0.08), inset 0 0 0 9px rgba(47, 109, 246, 0.12);
opacity: 0.3;
}
50%, 100% {
box-shadow: inset 0 0 0 20px rgba(47, 109, 246, 0), inset 0 0 0 16px rgba(47, 109, 246, 0);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.list li {
transition: none;
}
.list li::after {
animation: none !important;
opacity: 0;
}
.list li.is-demo {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./TapHighlightList.module.scss";
export type TapHighlightListProps = {
items: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function TapHighlightList({
items,
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: TapHighlightListProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<ul className={styles.list} style={vars}>
{items.map((label) => (
<li key={label}>{label}</li>
))}
</ul>
);
}05 · List row tap highlight — Measured area 19.752% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
Sizing the row to a finger
That matters most on settings menus and chat lists where rows sit close together.
.list li {
min-height: 44px;
-webkit-tap-highlight-color: transparent;
transition: background $duration $easing;
}
.list li:active {
background: rgba($color, .18);
transition: background 0s;
}
06Card tap scale + brightness dim
Scale and brightness together
scale(.98) and filter: brightness(.94) apply together the moment a card is tapped, so the whole surface reads as pressed rather than just its edges, while two ring coats spread at different depths along the inner border and damp out fast, rippling inside the card without ever crossing its bounds.

<main class="stage">
<article class="card is-demo" tabindex="0">
<span class="card__media" aria-hidden="true"></span>
<div class="card__body">
<h3 class="card__title">Bluetooth Speaker</h3>
<p class="card__meta">32,000 KRW</p>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $stage-ink; // 글자 색
@include stage($stage-yellow);
.stage { padding: clamp(6px, 2.4vw, 16px); }
.card {
position: relative;
width: min(150px, 48vw);
border-radius: $r-card;
background: $subject-cream;
overflow: hidden;
cursor: pointer;
touch-action: manipulation; // 더블탭 확대 방지
transform: scale(1);
filter: brightness(1);
transition: transform $duration $easing, filter $duration $easing;
// 규칙8 한 겹: 눌리는 순간 카드 안쪽 가장자리로 두 겹 링이 불규칙하게 번지다 감쇠
&::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: inset 0 0 0 0 rgba($subject-blue, .4), inset 0 0 0 0 rgba($subject-blue, .22);
opacity: 0;
pointer-events: none;
}
}
.card__media {
display: block;
aspect-ratio: 16 / 9;
background: linear-gradient(155deg, $subject-blue 0%, $stage-ink 100%);
}
.card__body { padding: 8px 12px; }
.card__title { margin: 0 0 4px; font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1.3; font-family: inherit; color: $color; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit; color: rgba($color, .6); }
.card:active {
transform: scale(.98);
filter: brightness(.94);
}
.card:active::after {
animation: ripple-wave $dur-enter $easing;
}
.card:focus-visible {
outline: 3px solid $subject-blue;
outline-offset: 3px;
}
.card.is-demo { animation: tap-loop $dur-loop $easing infinite; }
.card.is-demo::after { animation: ripple-loop $dur-loop $easing infinite; }
@keyframes tap-loop {
0%, 20% { transform: scale(1); filter: brightness(1); }
50%, 70% { transform: scale(.98); filter: brightness(.94); }
100% { transform: scale(1); filter: brightness(1); }
}
@keyframes ripple-wave {
0% { box-shadow: inset 0 0 0 0 rgba($subject-blue, .62), inset 0 0 0 0 rgba($subject-blue, .45); opacity: 1; }
16% { box-shadow: inset 0 0 0 5px rgba($subject-blue, .62), inset 0 0 0 2px rgba($subject-blue, .45); opacity: 1; }
48% { box-shadow: inset 0 0 0 8px rgba($subject-blue, .55), inset 0 0 0 5px rgba($subject-blue, .4); opacity: 1; }
56% { box-shadow: inset 0 0 0 9px rgba($subject-blue, 0), inset 0 0 0 6px rgba($subject-blue, 0); opacity: 0; }
100% { box-shadow: inset 0 0 0 9px rgba($subject-blue, 0), inset 0 0 0 6px rgba($subject-blue, 0); opacity: 0; }
}
@keyframes ripple-loop {
0%, 40% { box-shadow: inset 0 0 0 0 rgba($subject-blue, .62), inset 0 0 0 0 rgba($subject-blue, .45); opacity: 1; }
46% { box-shadow: inset 0 0 0 5px rgba($subject-blue, .62), inset 0 0 0 2px rgba($subject-blue, .45); opacity: 1; }
62% { box-shadow: inset 0 0 0 8px rgba($subject-blue, .55), inset 0 0 0 5px rgba($subject-blue, .4); opacity: 1; }
68% { box-shadow: inset 0 0 0 9px rgba($subject-blue, 0), inset 0 0 0 6px rgba($subject-blue, 0); opacity: 0; }
74%, 100% { box-shadow: inset 0 0 0 9px rgba($subject-blue, 0), inset 0 0 0 6px rgba($subject-blue, 0); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.card { transition: none; }
.card::after { animation: none !important; opacity: 0; }
.card.is-demo { animation: 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);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.card {
position: relative;
width: min(150px, 48vw);
border-radius: 16px;
background: #fff7e6;
overflow: hidden;
cursor: pointer;
touch-action: manipulation;
transform: scale(1);
filter: brightness(1);
transition: transform 220ms ease-out, filter 220ms ease-out;
}
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: inset 0 0 0 0 rgba(47, 109, 246, 0.4), inset 0 0 0 0 rgba(47, 109, 246, 0.22);
opacity: 0;
pointer-events: none;
}
.card__media {
display: block;
aspect-ratio: 16/9;
background: linear-gradient(155deg, #2f6df6 0%, #17141a 100%);
}
.card__body {
padding: 8px 12px;
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.6);
}
.card:active {
transform: scale(0.98);
filter: brightness(0.94);
}
.card:active::after {
animation: ripple-wave 600ms ease-out;
}
.card:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.card.is-demo {
animation: tap-loop 2s ease-out infinite;
}
.card.is-demo::after {
animation: ripple-loop 2s ease-out infinite;
}
@keyframes tap-loop {
0%, 20% {
transform: scale(1);
filter: brightness(1);
}
50%, 70% {
transform: scale(0.98);
filter: brightness(0.94);
}
100% {
transform: scale(1);
filter: brightness(1);
}
}
@keyframes ripple-wave {
0% {
box-shadow: inset 0 0 0 0 rgba(47, 109, 246, 0.62), inset 0 0 0 0 rgba(47, 109, 246, 0.45);
opacity: 1;
}
16% {
box-shadow: inset 0 0 0 5px rgba(47, 109, 246, 0.62), inset 0 0 0 2px rgba(47, 109, 246, 0.45);
opacity: 1;
}
48% {
box-shadow: inset 0 0 0 8px rgba(47, 109, 246, 0.55), inset 0 0 0 5px rgba(47, 109, 246, 0.4);
opacity: 1;
}
56% {
box-shadow: inset 0 0 0 9px rgba(47, 109, 246, 0), inset 0 0 0 6px rgba(47, 109, 246, 0);
opacity: 0;
}
100% {
box-shadow: inset 0 0 0 9px rgba(47, 109, 246, 0), inset 0 0 0 6px rgba(47, 109, 246, 0);
opacity: 0;
}
}
@keyframes ripple-loop {
0%, 40% {
box-shadow: inset 0 0 0 0 rgba(47, 109, 246, 0.62), inset 0 0 0 0 rgba(47, 109, 246, 0.45);
opacity: 1;
}
46% {
box-shadow: inset 0 0 0 5px rgba(47, 109, 246, 0.62), inset 0 0 0 2px rgba(47, 109, 246, 0.45);
opacity: 1;
}
62% {
box-shadow: inset 0 0 0 8px rgba(47, 109, 246, 0.55), inset 0 0 0 5px rgba(47, 109, 246, 0.4);
opacity: 1;
}
68% {
box-shadow: inset 0 0 0 9px rgba(47, 109, 246, 0), inset 0 0 0 6px rgba(47, 109, 246, 0);
opacity: 0;
}
74%, 100% {
box-shadow: inset 0 0 0 9px rgba(47, 109, 246, 0), inset 0 0 0 6px rgba(47, 109, 246, 0);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.card::after {
animation: none !important;
opacity: 0;
}
.card.is-demo {
animation: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./TapScaleCard.module.scss";
export type TapScaleCardProps = {
title: string;
meta: string;
duration?: number;
easing?: string;
media?: ReactNode;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
export default function TapScaleCard({
title,
meta,
duration = 220,
easing = "ease-out",
media,
}: TapScaleCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
return (
<article className={styles.card} style={vars} tabIndex={0}>
<span className={styles.media} aria-hidden="true">{media}</span>
<div className={styles.body}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
</article>
);
}06 · Card tap scale + brightness dim — Measured area 7.167% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
Blocking the double-tap zoom
touch-action: manipulation blocks the double-tap zoom a quick second tap would otherwise trigger, which fits product or thumbnail cards where the tappable surface is large.
.card {
transform: scale(1);
filter: brightness(1);
transition: transform $duration $easing, filter $duration $easing;
touch-action: manipulation;
}
.card:active {
transform: scale(.98);
filter: brightness(.94);
}
07Tap-confirm jitter (not the error shake)
A 140ms jitter, not a shake
Right after a tap, the icon jitters twice within about 140ms at a ±2px amplitude while its color fills from a pale tint to solid, mimicking a phone's haptic buzz on screen.

<main class="stage">
<button class="btn is-demo" type="button" aria-label="Like">
<span class="btn__icon" aria-hidden="true">♥</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-instant; // 지터가 끝나는 시간 (140ms — 아주 짧게)
$easing: $ease-pop;
$color: $subject-cream; // 버튼 색
@include stage($stage-orange);
.btn {
position: relative;
border: 0;
width: 64px;
height: 64px;
border-radius: $r-pill;
display: grid;
place-items: center;
background: $color;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
// 규칙8 한 겹: 지터와 함께 버튼 바깥으로 두 겹 링이 불규칙하게 번지다 감쇠
&::after {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
box-shadow: 0 0 0 0 rgba($error, .42), 0 0 0 0 rgba($error, .24);
opacity: 0;
pointer-events: none;
}
}
.btn__icon {
font-size: 28px;
line-height: 1;
color: rgba($error, .72); // AA: 크림 면 위 큰 아이콘 3:1 — 3.26:1
transition: color $dur-quick;
}
// 좌우로 세 번 크게 흔드는 오류 표시(다른 편의 "shake")와 달리,
// 여기는 ±2px 폭의 아주 짧은 지터 + 하트 채움으로 "탭이 됐다"만 확인시킨다
.btn:active { animation: jitter $duration $easing; }
.btn:active::after { animation: ripple-wave $dur-quick $easing; }
.btn:active .btn__icon { color: $error; }
.btn.is-demo { animation: jitter-loop $dur-loop $easing infinite; }
.btn.is-demo::after { animation: ripple-loop $dur-loop $easing infinite; }
.btn.is-demo .btn__icon { animation: fill-loop $dur-loop $easing infinite; }
@keyframes jitter {
0% { transform: scale(1) translate(0, 0); }
25% { transform: scale(1.18) translate(-2px, 1px); }
50% { transform: scale(1.1) translate(2px, -1px); }
75% { transform: scale(1.16) translate(-1px, 1px); }
100% { transform: scale(1) translate(0, 0); }
}
@keyframes jitter-loop {
0%, 30% { transform: scale(1) translate(0, 0); }
38% { transform: scale(1.18) translate(-2px, 1px); }
46% { transform: scale(1.1) translate(2px, -1px); }
54% { transform: scale(1.16) translate(-1px, 1px); }
62%, 100% { transform: scale(1) translate(0, 0); }
}
@keyframes fill-loop {
0%, 30% { color: rgba($error, .72); }
50%, 100% { color: $error; }
}
@keyframes ripple-wave {
0% { box-shadow: 0 0 0 0 rgba($error, .42), 0 0 0 0 rgba($error, .24); opacity: 1; }
40% { box-shadow: 0 0 0 8px rgba($error, .18), 0 0 0 3px rgba($error, .22); opacity: .6; }
100% { box-shadow: 0 0 0 16px rgba($error, 0), 0 0 0 12px rgba($error, 0); opacity: 0; }
}
@keyframes ripple-loop {
0%, 30% { box-shadow: 0 0 0 0 rgba($error, .42), 0 0 0 0 rgba($error, .24); opacity: 1; }
46% { box-shadow: 0 0 0 8px rgba($error, .18), 0 0 0 3px rgba($error, .22); opacity: .6; }
62%, 100% { box-shadow: 0 0 0 16px rgba($error, 0), 0 0 0 12px rgba($error, 0); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.btn:active { animation: none; }
.btn:active::after { animation: none !important; opacity: 0; }
.btn:active .btn__icon { color: $error; }
.btn.is-demo, .btn.is-demo .btn__icon { animation: none; }
.btn.is-demo::after { animation: none !important; opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.btn {
position: relative;
border: 0;
width: 64px;
height: 64px;
border-radius: 999px;
display: grid;
place-items: center;
background: #fff7e6;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.btn::after {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
box-shadow: 0 0 0 0 rgba(194, 54, 26, 0.42), 0 0 0 0 rgba(194, 54, 26, 0.24);
opacity: 0;
pointer-events: none;
}
.btn__icon {
font-size: 28px;
line-height: 1;
color: rgba(194, 54, 26, 0.72);
transition: color 220ms;
}
.btn:active {
animation: jitter 140ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.btn:active::after {
animation: ripple-wave 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.btn:active .btn__icon {
color: #c2361a;
}
.btn.is-demo {
animation: jitter-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.btn.is-demo::after {
animation: ripple-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.btn.is-demo .btn__icon {
animation: fill-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes jitter {
0% {
transform: scale(1) translate(0, 0);
}
25% {
transform: scale(1.18) translate(-2px, 1px);
}
50% {
transform: scale(1.1) translate(2px, -1px);
}
75% {
transform: scale(1.16) translate(-1px, 1px);
}
100% {
transform: scale(1) translate(0, 0);
}
}
@keyframes jitter-loop {
0%, 30% {
transform: scale(1) translate(0, 0);
}
38% {
transform: scale(1.18) translate(-2px, 1px);
}
46% {
transform: scale(1.1) translate(2px, -1px);
}
54% {
transform: scale(1.16) translate(-1px, 1px);
}
62%, 100% {
transform: scale(1) translate(0, 0);
}
}
@keyframes fill-loop {
0%, 30% {
color: rgba(194, 54, 26, 0.72);
}
50%, 100% {
color: #c2361a;
}
}
@keyframes ripple-wave {
0% {
box-shadow: 0 0 0 0 rgba(194, 54, 26, 0.42), 0 0 0 0 rgba(194, 54, 26, 0.24);
opacity: 1;
}
40% {
box-shadow: 0 0 0 8px rgba(194, 54, 26, 0.18), 0 0 0 3px rgba(194, 54, 26, 0.22);
opacity: 0.6;
}
100% {
box-shadow: 0 0 0 16px rgba(194, 54, 26, 0), 0 0 0 12px rgba(194, 54, 26, 0);
opacity: 0;
}
}
@keyframes ripple-loop {
0%, 30% {
box-shadow: 0 0 0 0 rgba(194, 54, 26, 0.42), 0 0 0 0 rgba(194, 54, 26, 0.24);
opacity: 1;
}
46% {
box-shadow: 0 0 0 8px rgba(194, 54, 26, 0.18), 0 0 0 3px rgba(194, 54, 26, 0.22);
opacity: 0.6;
}
62%, 100% {
box-shadow: 0 0 0 16px rgba(194, 54, 26, 0), 0 0 0 12px rgba(194, 54, 26, 0);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.btn:active {
animation: none;
}
.btn:active::after {
animation: none !important;
opacity: 0;
}
.btn:active .btn__icon {
color: #c2361a;
}
.btn.is-demo, .btn.is-demo .btn__icon {
animation: none;
}
.btn.is-demo::after {
animation: none !important;
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./HapticLikeButton.module.scss";
export type HapticLikeButtonProps = {
/** 지터가 끝나는 시간 — 아주 짧게 (기본 140ms) */
duration?: number;
easing?: string;
ariaLabel?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
export default function HapticLikeButton({
duration = 140,
easing = "cubic-bezier(.2,1.4,.4,1)",
ariaLabel = "좋아요",
}: HapticLikeButtonProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
return (
<button type="button" className={styles.btn} style={vars} aria-label={ariaLabel}>
<span className={styles.icon} aria-hidden="true">♥</span>
</button>
);
}07 · Tap-confirm jitter (not the error shake) — Measured area 1.997% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where a small motion fits
That's a far smaller motion, and a different purpose, than a three-swing error shake — reach for it on like or favorite buttons that need a brief success confirmation rather than a warning.
.btn:active { animation: jitter $duration $easing; }
.btn:active .btn__icon { color: $error; }
@keyframes jitter {
0% { transform: scale(1) translate(0, 0); }
25% { transform: scale(1.18) translate(-2px, 1px); }
50% { transform: scale(1.1) translate(2px, -1px); }
75% { transform: scale(1.16) translate(-1px, 1px); }
100% { transform: scale(1) translate(0, 0); }
}
08Checkbox tap feedback
Drawing the check mark
Tapping a custom checkbox draws its check mark through stroke-dashoffset while the box itself pops to scale(1.08) once, so the state change gets confirmed twice in the same moment.

<main class="stage">
<label class="cb">
<input type="checkbox" class="cb__input">
<span class="cb__box is-demo" aria-hidden="true">
<svg class="cb__check" viewBox="0 0 24 24"><path d="M4 12l6 6L20 6"/></svg>
</span>
<span class="cb__label">I agree to the terms</span>
</label>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 박스가 튀고 체크가 그려지는 시간
$easing: $ease-pop;
$color: $subject-blue; // 체크됐을 때 색
@include stage($stage-paper);
.cb {
min-height: 44px; // 시각적 박스는 26px 이지만, 탭 영역(label 전체)은 44px 이상
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.cb__input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.cb__box {
position: relative;
width: 26px;
height: 26px;
border-radius: 6px;
border: 2px solid rgba($stage-ink, .3);
background: $subject-cream;
display: grid;
place-items: center;
transform: scale(1);
transition: transform $duration $easing, border-color $duration $easing, background $duration $easing;
// 규칙8 한 겹: 체크되는 순간 박스 바깥으로 두 겹 링이 불규칙하게 번지다 감쇠
&::after {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
box-shadow: 0 0 0 0 rgba($color, .45), 0 0 0 0 rgba($color, .26);
opacity: 0;
pointer-events: none;
}
}
.cb__check {
width: 16px;
height: 16px;
fill: none;
stroke: $subject-cream;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 24;
stroke-dashoffset: 24;
transition: stroke-dashoffset $duration $easing;
}
.cb__label { font-weight: 600; font-size: 13px; line-height: 1.3; font-family: inherit; color: $stage-ink; }
.cb__input:checked ~ .cb__box {
border-color: $color;
background: $color;
transform: scale(1.08);
}
.cb__input:checked ~ .cb__box::after {
animation: ripple-wave $dur-enter $easing;
}
.cb__input:checked ~ .cb__box .cb__check { stroke-dashoffset: 0; }
.cb__input:focus-visible ~ .cb__box {
outline: 3px solid $subject-blue;
outline-offset: 2px;
}
// 데모용: 실제 체크 상태와 무관하게 박스와 체크를 함께 반복 재생한다
.cb__box.is-demo { animation: check-loop $dur-loop $easing infinite; }
.cb__box.is-demo::after { animation: ripple-loop $dur-loop $easing infinite; }
.cb__box.is-demo .cb__check { animation: draw-loop $dur-loop $easing infinite; }
@keyframes check-loop {
0%, 20% { transform: scale(1); border-color: rgba($stage-ink, .3); background: $subject-cream; }
35% { transform: scale(1.08); border-color: $color; background: $color; }
55%,100% { transform: scale(1); border-color: $color; background: $color; }
}
@keyframes draw-loop {
0%, 20% { stroke-dashoffset: 24; }
45%,100% { stroke-dashoffset: 0; }
}
@keyframes ripple-wave {
0% { box-shadow: 0 0 0 0 rgba($color, .72), 0 0 0 0 rgba($color, .52); opacity: 1; }
14% { box-shadow: 0 0 0 6px rgba($color, .72), 0 0 0 2px rgba($color, .52); opacity: 1; }
44% { box-shadow: 0 0 0 9px rgba($color, .62), 0 0 0 5px rgba($color, .46); opacity: 1; }
52% { box-shadow: 0 0 0 10px rgba($color, 0), 0 0 0 6px rgba($color, 0); opacity: 0; }
100% { box-shadow: 0 0 0 10px rgba($color, 0), 0 0 0 6px rgba($color, 0); opacity: 0; }
}
@keyframes ripple-loop {
0%, 20% { box-shadow: 0 0 0 0 rgba($color, .72), 0 0 0 0 rgba($color, .52); opacity: 1; }
25% { box-shadow: 0 0 0 6px rgba($color, .72), 0 0 0 2px rgba($color, .52); opacity: 1; }
44% { box-shadow: 0 0 0 9px rgba($color, .62), 0 0 0 5px rgba($color, .46); opacity: 1; }
52% { box-shadow: 0 0 0 10px rgba($color, 0), 0 0 0 6px rgba($color, 0); opacity: 0; }
58%, 100% { box-shadow: 0 0 0 10px rgba($color, 0), 0 0 0 6px rgba($color, 0); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.cb__box, .cb__check { transition: none; }
.cb__box::after { animation: none !important; opacity: 0; }
.cb__box.is-demo, .cb__box.is-demo .cb__check { 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);
}
.cb {
min-height: 44px;
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.cb__input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.cb__box {
position: relative;
width: 26px;
height: 26px;
border-radius: 6px;
border: 2px solid rgba(23, 20, 26, 0.3);
background: #fff7e6;
display: grid;
place-items: center;
transform: scale(1);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), border-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1), background 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cb__box::after {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.45), 0 0 0 0 rgba(47, 109, 246, 0.26);
opacity: 0;
pointer-events: none;
}
.cb__check {
width: 16px;
height: 16px;
fill: none;
stroke: #fff7e6;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 24;
stroke-dashoffset: 24;
transition: stroke-dashoffset 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cb__label {
font-weight: 600;
font-size: 13px;
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.cb__input:checked ~ .cb__box {
border-color: #2f6df6;
background: #2f6df6;
transform: scale(1.08);
}
.cb__input:checked ~ .cb__box::after {
animation: ripple-wave 600ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cb__input:checked ~ .cb__box .cb__check {
stroke-dashoffset: 0;
}
.cb__input:focus-visible ~ .cb__box {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.cb__box.is-demo {
animation: check-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.cb__box.is-demo::after {
animation: ripple-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.cb__box.is-demo .cb__check {
animation: draw-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes check-loop {
0%, 20% {
transform: scale(1);
border-color: rgba(23, 20, 26, 0.3);
background: #fff7e6;
}
35% {
transform: scale(1.08);
border-color: #2f6df6;
background: #2f6df6;
}
55%, 100% {
transform: scale(1);
border-color: #2f6df6;
background: #2f6df6;
}
}
@keyframes draw-loop {
0%, 20% {
stroke-dashoffset: 24;
}
45%, 100% {
stroke-dashoffset: 0;
}
}
@keyframes ripple-wave {
0% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.72), 0 0 0 0 rgba(47, 109, 246, 0.52);
opacity: 1;
}
14% {
box-shadow: 0 0 0 6px rgba(47, 109, 246, 0.72), 0 0 0 2px rgba(47, 109, 246, 0.52);
opacity: 1;
}
44% {
box-shadow: 0 0 0 9px rgba(47, 109, 246, 0.62), 0 0 0 5px rgba(47, 109, 246, 0.46);
opacity: 1;
}
52% {
box-shadow: 0 0 0 10px rgba(47, 109, 246, 0), 0 0 0 6px rgba(47, 109, 246, 0);
opacity: 0;
}
100% {
box-shadow: 0 0 0 10px rgba(47, 109, 246, 0), 0 0 0 6px rgba(47, 109, 246, 0);
opacity: 0;
}
}
@keyframes ripple-loop {
0%, 20% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.72), 0 0 0 0 rgba(47, 109, 246, 0.52);
opacity: 1;
}
25% {
box-shadow: 0 0 0 6px rgba(47, 109, 246, 0.72), 0 0 0 2px rgba(47, 109, 246, 0.52);
opacity: 1;
}
44% {
box-shadow: 0 0 0 9px rgba(47, 109, 246, 0.62), 0 0 0 5px rgba(47, 109, 246, 0.46);
opacity: 1;
}
52% {
box-shadow: 0 0 0 10px rgba(47, 109, 246, 0), 0 0 0 6px rgba(47, 109, 246, 0);
opacity: 0;
}
58%, 100% {
box-shadow: 0 0 0 10px rgba(47, 109, 246, 0), 0 0 0 6px rgba(47, 109, 246, 0);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.cb__box, .cb__check {
transition: none;
}
.cb__box::after {
animation: none !important;
opacity: 0;
}
.cb__box.is-demo, .cb__box.is-demo .cb__check {
animation: none;
}
}import { useState, type CSSProperties, type ChangeEvent } from "react";
import styles from "./TapCheckbox.module.scss";
export type TapCheckboxProps = {
label: string;
/** 같은 페이지에 여러 개 둘 때 겹치지 않게 */
id?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function TapCheckbox({
label,
id = "tap-checkbox",
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: TapCheckboxProps) {
const [checked, setChecked] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<label className={styles.cb} style={vars} htmlFor={id}>
<input
id={id}
type="checkbox"
className={styles.input}
checked={checked}
onChange={(e: ChangeEvent<HTMLInputElement>) => setChecked(e.target.checked)}
/>
<span className={styles.box} aria-hidden="true">
<svg className={styles.check} viewBox="0 0 24 24"><path d="M4 12l6 6L20 6" /></svg>
</span>
<span className={styles.label}>{label}</span>
</label>
);
}08 · Checkbox tap feedback — Measured area 1.833% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
A 26px box in a 44px target
The real input stays visually hidden but keeps its own focus ring, and the label wrapping both spans holds a 44px tap target even though the visible box itself measures only 26px.
.cb__input:checked ~ .cb__box {
border-color: $color;
background: $color;
transform: scale(1.08);
}
.cb__input:checked ~ .cb__box .cb__check {
stroke-dashoffset: 0;
}
.cb__check {
stroke-dasharray: 24;
stroke-dashoffset: 24;
transition: stroke-dashoffset $duration $easing;
}
09Inline link tap feedback
A flash behind the letters
Tapping a link inside a paragraph flashes a short color block behind the letters and lets it fade back out, leaving a press signal in place even where the browser's own tap-highlight has been switched off. Padding paired with a matching negative margin keeps the flash aligned to the text instead of spilling past the link's own box.

<main class="stage">
<p class="p">Curious about the other effects? <a href="#" class="p__link is-demo">See details</a>, tap it below.</p>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-instant; // 배경 블록이 사라지는 시간 (0.14초)
$easing: $ease-out;
$color: $subject-blue; // 링크·하이라이트 색
@include stage($stage-ink);
.stage { padding: clamp(10px, 4vw, 28px); }
.p {
max-width: 260px;
font-weight: 500; font-size: 15px; line-height: 1.7; font-family: inherit;
color: $subject-cream;
margin: 0;
}
.p__link {
position: relative;
color: $subject-blue-light; // AA: 잉크 면 위 파란 링크 5.69:1 (탭 하이라이트는 $color 유지)
text-decoration: underline;
text-underline-offset: 3px;
-webkit-tap-highlight-color: transparent;
padding: 2px 3px;
margin: -2px -3px;
border-radius: 4px;
background: transparent;
transition: background $duration $easing;
// 규칙8 한 겹: 배경 플래시와 함께 두 겹 링이 불규칙하게 번지다 감쇠
&::after {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
box-shadow: inset 0 0 0 0 rgba($color, .4), inset 0 0 0 0 rgba($color, .22);
opacity: 0;
pointer-events: none;
}
}
.p__link:active {
background: rgba($color, .32);
transition: background 0s;
}
.p__link:active::after {
animation: ripple-wave $dur-instant $ease-out;
}
.p__link.is-demo { animation: link-flash $dur-loop cubic-bezier(0, 0, 1, 1) infinite; }
.p__link.is-demo::after { animation: ripple-loop $dur-loop cubic-bezier(0, 0, 1, 1) infinite; }
@keyframes link-flash {
0% { background: rgba($color, .62); }
35% { background: rgba($color, 0); }
100% { background: rgba($color, 0); }
}
@keyframes ripple-wave {
0% { box-shadow: inset 0 0 0 0 rgba($color, .4), inset 0 0 0 0 rgba($color, .22); opacity: 1; }
100% { box-shadow: inset 0 0 0 6px rgba($color, 0), inset 0 0 0 4px rgba($color, 0); opacity: 0; }
}
@keyframes ripple-loop {
0% { box-shadow: inset 0 0 0 0 rgba($color, .4), inset 0 0 0 0 rgba($color, .22); opacity: 1; }
35% { box-shadow: inset 0 0 0 6px rgba($color, 0), inset 0 0 0 4px rgba($color, 0); opacity: 0; }
100% { box-shadow: inset 0 0 0 6px rgba($color, 0), inset 0 0 0 4px rgba($color, 0); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.p__link { transition: none; }
.p__link::after { animation: none !important; opacity: 0; }
.p__link.is-demo { animation: none; }
}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);
}
.stage {
padding: clamp(10px, 4vw, 28px);
}
.p {
max-width: 260px;
font-weight: 500;
font-size: 15px;
line-height: 1.7;
font-family: inherit;
color: #fff7e6;
margin: 0;
}
.p__link {
position: relative;
color: #5b8cf8;
text-decoration: underline;
text-underline-offset: 3px;
-webkit-tap-highlight-color: transparent;
padding: 2px 3px;
margin: -2px -3px;
border-radius: 4px;
background: transparent;
transition: background 140ms ease-out;
}
.p__link::after {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
box-shadow: inset 0 0 0 0 rgba(47, 109, 246, 0.4), inset 0 0 0 0 rgba(47, 109, 246, 0.22);
opacity: 0;
pointer-events: none;
}
.p__link:active {
background: rgba(47, 109, 246, 0.32);
transition: background 0s;
}
.p__link:active::after {
animation: ripple-wave 140ms ease-out;
}
.p__link.is-demo {
animation: link-flash 2s cubic-bezier(0, 0, 1, 1) infinite;
}
.p__link.is-demo::after {
animation: ripple-loop 2s cubic-bezier(0, 0, 1, 1) infinite;
}
@keyframes link-flash {
0% {
background: rgba(47, 109, 246, 0.62);
}
35% {
background: rgba(47, 109, 246, 0);
}
100% {
background: rgba(47, 109, 246, 0);
}
}
@keyframes ripple-wave {
0% {
box-shadow: inset 0 0 0 0 rgba(47, 109, 246, 0.4), inset 0 0 0 0 rgba(47, 109, 246, 0.22);
opacity: 1;
}
100% {
box-shadow: inset 0 0 0 6px rgba(47, 109, 246, 0), inset 0 0 0 4px rgba(47, 109, 246, 0);
opacity: 0;
}
}
@keyframes ripple-loop {
0% {
box-shadow: inset 0 0 0 0 rgba(47, 109, 246, 0.4), inset 0 0 0 0 rgba(47, 109, 246, 0.22);
opacity: 1;
}
35% {
box-shadow: inset 0 0 0 6px rgba(47, 109, 246, 0), inset 0 0 0 4px rgba(47, 109, 246, 0);
opacity: 0;
}
100% {
box-shadow: inset 0 0 0 6px rgba(47, 109, 246, 0), inset 0 0 0 4px rgba(47, 109, 246, 0);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.p__link {
transition: none;
}
.p__link::after {
animation: none !important;
opacity: 0;
}
.p__link.is-demo {
animation: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./TapLinkText.module.scss";
export type TapLinkTextProps = {
before: string;
linkText: string;
after: string;
href?: string;
duration?: number;
easing?: string;
color?: string;
children?: ReactNode;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function TapLinkText({
before,
linkText,
after,
href = "#",
duration = 140,
easing = "ease-out",
color = "#5b8cf8",
}: TapLinkTextProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<p className={styles.p} style={vars}>
{before} <a href={href} className={styles.link}>{linkText}</a>{after}
</p>
);
}09 · Inline link tap feedback — Measured area 1.469% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
Keeping the block on the text
That suits blog posts and terms pages where links sit inline inside running sentences.
.p__link {
padding: 2px 3px;
margin: -2px -3px;
background: transparent;
transition: background $duration $easing;
}
.p__link:active {
background: rgba($color, .32);
transition: background 0s;
}
Where it breaks — the timing trap
An effect that moves nothing but a background alpha can look alive and still measure as still — the same wall the glowing panels in the AI landing page orb sections ran into during their own render pass.
A linear fade the render check nearly missed
Item 09 is the one demo on this page that almost failed the render check every collection here has to pass before it goes live. Its flash is a single linear background-color fade, and a linear curve spreads a color change into equal steps across every frame rather than clustering it anywhere — unlike an eased curve, whose steps bunch up hardest right where two frames sit closest together. This site's render pipeline marks a frame as unmoved once its per-pixel color delta falls under a fixed threshold, and spreading 09's fade evenly across 24 frames left several of them sitting right on that line, nearly reading as a static block of text with a link glued to it. The version that finally passed measures an average intensity of 16.2 against a floor of 15, and 8 out of 24 frames counted as moved against a floor of 6 — a real margin now, but one timing tweak away from slipping back under. MDN's animation-timing-function reference covers exactly this gap between linear and eased pacing. The narrowed timing that fixed it ships in the archive, unlocked with e8dzq4mp typed exactly as it reads on this line, no spaces added anywhere.
Accessibility
What survives reduced motion
All nine drop their motion under prefers-reduced-motion: reduce, but seven of them keep the confirmation itself rather than just switching a transition off. Everything except 01 and 07 sets transition: none, so whatever end state a tap already produces — a smaller scale, a darker card, a highlighted row, a drawn checkmark, a flashed link — appears instantly instead of easing in. 07 removes its jitter animation outright with .btn:active { animation: none; }, yet the confirming icon-color rule sits in a separate line outside that animation, so tapping the heart still fills it in even with motion off. 01 is the outlier: the ring is removed entirely at opacity: 0, replaced by nothing more than the small brightness dip its plain :active state already carries.
Focus-visible for keyboard users
Two of the nine also carry a :focus-visible outline so keyboard users get the same signal a pointer would:
.card:focus-visible {
outline: 3px solid $color;
outline-offset: 3px;
}
Tap targets sized in CSS
Tap targets are sized in the same CSS rather than left to chance — 05's list rows and 08's checkbox label both carry min-height: 44px, matching the floor WCAG 2.5.8 Target Size (Minimum) sets for anything a finger needs to land on, even though 08's visible checkbox square itself measures only 26px. See the rest of this line of demos on the CSS category hub, or read what this site does and doesn't sell on the about page.
FAQ
Does a css button clicked state need :active, or does pointerdown avoid an iOS delay?
It avoids the delay on purpose. Safari on iOS only applies :active styles right away once some element in the page carries a touchstart listener — without one, a tap-and-release can finish before the style ever paints, a quirk MDN's :active reference notes directly. 01 sidesteps the whole issue by never depending on :active for its ring in the first place; a pointerdown listener swaps the class in immediately on every platform.
Does the touch-only style in 03 still apply on a touchscreen laptop that also has a mouse?
(hover: none) and (pointer: coarse) is judged against the device's primary pointer, not every input it happens to own. A touchscreen laptop that reports touch as primary still gets 03's background; the moment its mouse becomes primary, that block goes quiet on its own. Caniuse's interaction media features table shows Chrome, Safari, and Firefox have all supported this pairing for years.
Can all nine css button click feedback effects sit on one page together?
Yes — each one is scoped to its own class (.btn, .card, .list, .cb, .p__link), so nothing here shares state across effects. The one thing to avoid is stacking two of them on the very same element, such as 02's instant press together with 04's 600ms delay; a button can't cleanly tell a visitor which timing to expect, so pick one feedback style per element instead. A button that has to keep reporting after the finger lifts — spinner, then done — belongs with the button state css patterns rather than any of these nine.








