CSS 버튼 눌림 효과 9종 — 한 줄 복붙으로
CSS 버튼 눌림 효과는 사용자가 버튼·카드·링크를 클릭하거나 터치했을 때 화면이 짧게 반응해 입력이 실제로 먹혔다는 걸 확인시켜 주는 시각적 신호를 말합니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 테두리 링 펄스
- 02 눌림 축소 + 그림자 감소
- 03 호버 없는 기기 전용 :active
- 04 길게 누르면 나타나는 지연 반응
- 05 리스트 행 탭 하이라이트
- 06 카드 탭 스케일 + 밝기
- 07 탭 확인 지터 (에러 흔들림 아님)
- 08 체크박스 탭 피드백
- 09 인라인 링크 탭 피드백
아홉 개는 인기 순이 아니라 한 번의 탭이 실제로 지나가는 순서로 놓았습니다. 누르자마자 링이 번지고(01), 눌리는 물성이 느껴지고(02), 기기에 따라 반응이 갈리고(03), 오래 누르면 기다림이 끼어들고(04), 목록·카드를 고르고(05~06), 좋아요를 짧게 확인하고(07), 체크박스로 상태를 바꾸고(08), 마지막으로 링크를 눌러 다음 화면으로 넘어갑니다(09). 각 코드는 css 버튼 애니메이션을 돌리는 데 꼭 필요한 부분만 잘라 왔고, 나머지 마크업과 변형 값은 zip 안에 그대로 들어 있습니다. 07에서 짧게 스치고 지나가는 좋아요 확인만 제대로 키우고 싶다면 CSS 하트 애니메이션 아홉 개가 그 한 동작만 다루고, 01의 링을 버튼 둘레를 계속 도는 선으로 바꾸고 싶다면 CSS 테두리 애니메이션 쪽이 이어지는 묶음입니다.
01테두리 링 펄스
css 버튼 효과를 바깥으로 번지게 하기
버튼 안쪽을 채우는 원이 아니라, 버튼 바깥으로 box-shadow 링이 한 번 부풀었다 사라집니다. 이번에 링을 두 겹으로 늘려 서로 다른 속도로 번지다가 단계별로 감쇠하게 해서, 파면이 노이즈처럼 불규칙하게 퍼지는 인상을 만들었습니다.

<main class="stage">
<button class="btn is-demo" type="button">구독하기</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 · 테두리 링 펄스 — 측정 누적면적 4.103% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
클릭이 끝나도 끝까지 재생하기
클릭이 끝나도 링은 끝까지 재생돼야 해서 pointerdown에서 클래스를 갈아끼우는 JS 열 줄만 씁니다.
.btn::after {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
box-shadow: 0 0 0 0 rgba($color, .55);
opacity: 0;
}
.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; }
}
02눌림 축소
눌리는 물성을 만드는 값
누르는 동안 scale(.96)으로 줄면서 그림자도 함께 얕아지고, 손을 떼면 스프링 없이 짧게 원래대로 돌아옵니다. 여기에 누르는 순간 두 겹 링이 다른 속도로 번졌다 빠르게 감쇠하는 물결이 얹혀, 눌림이 버튼 안에서 끝나지 않고 바깥으로도 번지는 느낌을 줍니다.

<main class="stage">
<button class="btn is-demo" type="button">구매하기</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 · 눌림 축소 + 그림자 감소 — 측정 누적면적 6.326% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
모바일 탭 지연 없애기
touch-action: manipulation을 걸어 모바일 브라우저의 더블탭 확대와 탭 지연을 없앴습니다.
.btn {
transform: scale(1);
transition: transform $duration $easing, box-shadow $duration $easing;
touch-action: manipulation;
}
.btn:active {
transform: scale(.96);
box-shadow: $shadow-press;
}
03호버 없는 기기 전용 :active
호버 없는 기기만 고르기
@media (hover: none) and (pointer: coarse) 블록 안에만 :active 배경색을 둡니다. 터치 기기에서 누르는 순간엔 두 겹 링이 불규칙하게 번졌다 감쇠하는 물결도 함께 살아납니다.

<main class="stage">
<button class="btn is-demo" type="button">다음</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 · 호버 없는 기기 전용 :active — 측정 누적면적 8.344% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
회색이 눌어붙지 않는 이유
마우스가 있는 기기는 이 블록을 아예 읽지 않아서, 스마트폰에서 탭한 뒤 회색이 눌어붙는 흔한 버그가 여기선 나지 않습니다.
@media (hover: none) and (pointer: coarse) {
.btn:active {
background: $stage-ink;
color: $subject-cream;
}
}
04길게 누르면 나타나는 지연 반응
600ms 뒤에 오는 반응
누르는 순간엔 아무 일도 없다가 transition-delay로 600ms 뒤에야 카드가 커지고 그림자가 짙어집니다. 반응이 온 순간엔 두 겹 링이 다른 속도로 번지며 터치 지점에서 퍼지듯 감쇠합니다.

<main class="stage">
<div class="card is-demo" tabindex="0">더보기</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 · 길게 누르면 나타나는 지연 반응 — 측정 누적면적 10.674% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
진행바 대신 지연 하나
배경이 차오르는 진행바 대신 지연 하나로 짧은 탭과 긴 누름을 가릅니다.
.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;
}
05리스트 행 탭 하이라이트
눌린 행을 알려 주는 번쩍임
-webkit-tap-highlight-color를 투명 처리한 자리에 탭한 행의 배경색이 짧게 번쩍이고 사라져, 어떤 행을 눌렀는지 확인해 줍니다. 행 안쪽 가장자리로는 두 겹 링이 다른 깊이로 번졌다 감쇠하는 물결이 지나가 터치에 결이 하나 더 얹힙니다.

<main class="stage">
<ul class="list">
<li class="is-demo" tabindex="0">알림</li>
<li class="is-demo" tabindex="0">개인정보</li>
<li class="is-demo" tabindex="0">계정</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 · 리스트 행 탭 하이라이트 — 측정 누적면적 19.752% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
손가락 폭에 맞춘 행 높이
행 높이는 44px 이상으로 잡아 손가락 폭에 맞췄습니다.
.list li {
transition: background $duration $easing;
}
.list li:active {
background: rgba($color, .18);
transition: background 0s;
}
06카드 탭 스케일 + 밝기
카드 전체가 눌리는 값
카드를 탭하는 동안 scale(.98)과 filter: brightness(.94)가 함께 걸려 카드 전체가 눌린 듯 보입니다. 가장자리 안쪽으로 두 겹 링이 다른 깊이로 번졌다 빠르게 감쇠해, 카드 바깥으로 나가지 않고 눌림이 카드 안에서 물결칩니다.

<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">블루투스 스피커</h3>
<p class="card__meta">32,000원</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 · 카드 탭 스케일 + 밝기 — 측정 누적면적 7.167% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
두 번 탭 확대 막기
touch-action: manipulation으로 상품 카드를 빠르게 두 번 누를 때의 확대도 막았습니다.
.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);
}
07탭 확인 지터
140ms 짧은 흔들림
버튼을 탭한 직후 140ms 동안 ±2px 폭으로만 짧게 흔들리며 하트 아이콘이 옅은 색에서 진한 색으로 채워집니다. 흔들림과 함께 붉은 기운의 두 겹 링이 다른 속도로 번졌다 감쇠해 진동이 남긴 여운을 눈으로도 볼 수 있습니다.

<main class="stage">
<button class="btn is-demo" type="button" aria-label="좋아요">
<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 · 탭 확인 지터 (에러 흔들림 아님) — 측정 누적면적 1.997% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
오류 흔들림과 다른 점
좌우로 세 번 크게 흔드는 오류 표시와는 폭도 목적도 다른, 성공을 확인하는 지터입니다.
.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); }
}
08체크박스 탭 피드백
체크선이 그려지는 방식
커스텀 체크박스를 탭하면 체크 아이콘이 stroke-dashoffset으로 그려지는 동시에 박스가 scale(1.08)로 한 번 튑니다. 체크되는 순간 박스 바깥으로 두 겹 링이 다른 속도로 번졌다 감쇠해 선택이 바깥까지 확 번지듯 전해집니다.

<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">이용약관에 동의합니다</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 · 체크박스 탭 피드백 — 측정 누적면적 1.833% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
숨긴 input 과 형제 선택자
실제 input[type=checkbox]는 숨기고 인접 형제 선택자로 박스와 체크선을 함께 움직입니다.
.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;
}
09인라인 링크 탭 피드백
링크에도 남는 탭 신호
본문 속 링크를 탭하면 글자 배경에 색 블록이 나타났다 0.14초 만에 사라져, 기본 탭 하이라이트를 지운 자리에도 눌렸다는 신호가 남습니다. 사라지기 전 글자 테두리 안쪽으로 두 겹 물결이 스쳐 지나가 짧은 순간에도 결을 하나 더 만듭니다.

<main class="stage">
<p class="p">다른 효과가 궁금하면 <a href="#" class="p__link is-demo">자세히 보기</a>를 눌러보세요.</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 · 인라인 링크 탭 피드백 — 측정 누적면적 1.469% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
핵심 코드
.p__link {
background: transparent;
transition: background $duration $easing;
}
.p__link:active {
background: rgba($color, .32);
transition: background 0s;
}
어디서 깨지는가 — 함정 하나
배경색 알파만 건드리는 효과는 눈에는 보여도 측정에는 안 잡히는 구간이 생기는데, 이는 ai 랜딩페이지 오브 디자인 키트의 빛나는 섹션들을 렌더할 때 똑같이 겪은 지점입니다.
알파 변화를 프레임이 못 잡는다
09 인라인 링크는 반복 구간의 background 알파값을 2초 애니메이션의 0~60% 구간에 걸쳐 linear로 고르게 바꾸도록 처음 만들었습니다. 이 구간이면 알파 변화가 프레임 하나당 11 정도로 잘게 쪼개져, 설정의 픽셀문턱 12(0~255 기준)에도 못 미쳐 판정 하한(강도 15·움직인프레임 6)을 넘기지 못할 계산이었습니다. MDN의 animation-timing-function 문서를 보면 알 수 있듯 linear는 변화를 프레임 수만큼 잘게 쪼개기 때문에, 총 변화량은 그대로 두고 구간을 0~60%에서 0~35%로 좁혀 프레임당 변화 폭을 키웠습니다. 이징을 ease-out으로 바꾸는 방법도 있지만, 눌림 피드백은 손이 닿는 순간 바로 반응해야 해서 linear를 그대로 두고 구간만 손봤습니다. 이렇게 좁혀 실제로 렌더한 뒤 잰 값은 누적면적 1.322%·강도_평균 16.2·움직인프레임 8(24프레임 중)로 하한을 넘겼고, 이 파일의 비밀번호는 e8dzq4mp입니다.
접근성
줄인 움직임에서 남기는 것
아홉 개 전부 prefers-reduced-motion: reduce에서 움직임을 끄되 마지막 상태(눌림·체크됨·하이라이트)는 남겨 반응 자체는 사라지지 않게 했습니다. 탭할 표면 크기도 코드로 맞췄습니다 — 05 리스트 행은 min-height: 44px로, 08 체크박스는 시각적 박스가 26px이라도 label에 min-height: 44px를 줘서 실제 탭 영역을 늘렸습니다. 다만 09처럼 문장 속에 섞인 링크는 WCAG 2.5.8의 예외(문장·블록 텍스트 안 링크)에 해당해 44px을 강제하지 않았습니다. 키보드 사용자를 위해 02·04·06·08에는 :focus-visible 아웃라인을 따로 걸었습니다.
.card:focus-visible {
outline: 3px solid $color;
outline-offset: 3px;
}
기본 포커스 링과 이어 볼 글
나머지 다섯(01·03·05·07·09)은 버튼·리스트·링크의 브라우저 기본 포커스 링을 지우지 않고 그대로 씁니다. @media (hover: none) and (pointer: coarse)는 caniuse의 미디어 기능 지원표에서 Chrome·Safari·Firefox 전부 오래전부터 지원하는 걸 확인할 수 있습니다. css 버튼 만들기에서 한 걸음 더 나가, 클릭이 아니라 커서를 따라가는 효과가 궁금하면 커서 이펙트 아홉 가지 글도 있고, 다른 CSS 애니메이션 소재 전부는 CSS 애니메이션 카테고리에 모여 있으며, 이 사이트가 데모를 무엇으로 만드는지는 소개 페이지에 적어 두었습니다.
FAQ
iOS Safari에서도 01의 링이 클릭 즉시 나타납니까?
:active만으로 만들었다면 늦게 나타날 수 있습니다. MDN의 :active 문서에 적힌 대로 iOS Safari는 <body>나 상위 요소에 touchstart 리스너가 하나라도 있어야 :active 스타일을 지연 없이 적용하는데, 01은 애초에 :active가 아니라 pointerdown 이벤트로 클래스를 직접 갈아끼우기 때문에 이 문제를 비켜 갑니다.
03의 터치 전용 스타일은 안드로이드 태블릿처럼 마우스도 붙는 기기에선 어떻게 됩니까?
(hover: none) and (pointer: coarse)는 "주 입력 장치"를 기준으로 판정됩니다. 마우스를 연결해도 태블릿이 스스로를 터치 우선으로 보고하면 여전히 터치용 스타일이 적용되고, 반대로 마우스가 주 입력으로 바뀌면 이 블록은 조용히 꺼집니다.
css 버튼 디자인 아홉 개를 한 폼에 같이 써도 됩니까?
됩니다. 02·06·08처럼 서로 다른 요소(버튼·카드·체크박스)에 걸려 있는 것들은 한 화면에 섞여도 무엇을 눌렀는지 헷갈리지 않습니다. 다만 04처럼 지연이 있는 효과와 02처럼 즉시 반응하는 효과를 같은 버튼에 동시에 걸면 사용자가 어느 쪽 반응을 기다려야 하는지 헷갈리므로 요소당 하나씩만 둡니다. 손을 뗀 뒤 로딩·완료까지 이어서 보여줘야 하는 버튼은 여기 아홉 개가 아니라 누른 다음 상태가 바뀌는 css 버튼 클릭 효과 쪽에서 다룹니다.








