08チェックボックスのタップフィードバック
カスタムチェックボックスをタップすると、チェックアイコンが stroke-dashoffset で描かれると同時にボックス全体が一度 scale(1.08) に弾み、状態が変わったことを二重に伝える。
使いどころ規約同意やToDoリストなど、状態変化が明確であるべきチェックボックス
stroke-dashoffsetscale0.2초

<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 オリジナル · SCSS と React は zip に同梱 · CSS タップ エフェクト 9選 — 一行でコピペOK
このデモが属する記事
CSS タップ エフェクト 9選 — 一行でコピペOKcss タップ エフェクトとは、指でボタンやカード、リンクに触れた瞬間だけ色・大きさ・影が変わり、入力が確かに届いたとスマホの画面だけで伝える演出です。この記事では9種類を選び、それぞれ6〜25行のコードとして実装しました。4つは@medi この記事のデモをすべて見る


