CSS 체크박스 커스텀 라디오 애니메이션 9종 — 한 줄 복붙으로
CSS 체크박스 커스텀은 브라우저 기본 네모난 체크박스·동그란 라디오의 <input> 은 지우지 않고 그대로 둔 채, 옆에 놓은 도형만 :checked 하나로 다시 그리는 방법입니다. 아홉 개를 모았고, 그중 하나는 체크박스인데 스위치 모양을 빌린 예외입니다.
01 checkbox-radio-custom-check-draw-stroke
02 checkbox-radio-custom-bounce-check
03 checkbox-radio-custom-radio-fill
04 checkbox-radio-custom-switch-like-checkbox
05 checkbox-radio-custom-card-select
06 checkbox-radio-custom-todo-strike
07 checkbox-radio-custom-color-swatch-pick
08 checkbox-radio-custom-star-rate-input
09 checkbox-radio-custom-chip-toggle
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 체크 스트로크 그리기
- 02 바운스 체크
- 03 라디오 채움 애니메이션
- 04 스위치형 체크박스
- 05 카드 선택형
- 06 할일 취소선 애니메이션
- 07 색상 스와치 선택
- 08 별점 입력형
- 09 칩 토글
아홉 개는 인기 순이 아니라 선택지 개수가 늘어나는 순서로 놓았습니다. 01·02·06·09는 체크박스 하나가 혼자 켜졌다 꺼지는 경우이고, 03·05·07·08은 css 라디오버튼 커스텀처럼 라디오로 묶여 여러 개 중 하나만 남는 경우이며, 04는 체크박스인데 스위치의 생김새만 빌린 예외입니다. 04가 빌려온 그 생김새 자체가 필요하다면 CSS 토글 스위치 아홉 개가 따로 있고, 08 별점 입력형처럼 감정을 담은 아이콘이 목적이라면 하트 좋아요 애니메이션 쪽이 더 가깝습니다.
01체크 스트로크 그리기
어떻게 움직이나
체크박스를 클릭하면 숨겨둔 <input>의 :checked 가 SVG 체크 아이콘의 stroke-dashoffset 을 20에서 0으로 줄여, 체크 표시가 왼쪽 아래부터 오른쪽 위까지 그어지듯 나타납니다.
어디에 쓰나
이용약관 동의처럼 "읽고 눌렀다"는 확신을 주고 싶은 자리에 씁니다.

<main class="stage">
<label class="check is-demo">
<input type="checkbox" class="sr-input">
<span class="box" aria-hidden="true">
<svg viewBox="0 0 24 24" class="box__svg"><path class="box__path" d="M5 12.5 L10 17.5 L19 7" /></svg>
</span>
<span class="check__text">이용약관에 동의합니다</span>
</label>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.sr-input {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.check { display: inline-flex; align-items: center; gap: 12px; cursor: pointer;
font-weight: 600; font-size: clamp(14px, 4vw, 17px); line-height: 1.3; font-family: inherit; color: $subject-cream; }
.box {
flex: none; width: 28px; height: 28px; border-radius: 8px;
border: 2px solid rgba($subject-cream, .55); background: transparent;
display: grid; place-items: center;
transition: background $duration $easing, border-color $duration $easing;
}
.sr-input:checked ~ .box { background: $color; border-color: $color; }
.sr-input:focus-visible ~ .box { box-shadow: 0 0 0 3px rgba($subject-cream, .5); }
.box__svg { width: 18px; height: 18px; overflow: visible; }
.box__path {
fill: none; stroke: $subject-cream; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round;
stroke-dasharray: 20; stroke-dashoffset: 20;
transition: stroke-dashoffset $duration $easing;
}
.sr-input:checked ~ .box .box__path { stroke-dashoffset: 0; }
@media (prefers-reduced-motion: reduce) {
// 접근성: 움직임 줄이기 설정이면 트랜지션을 즉시 끝낸다
.box, .box__path { transition-duration: 1ms !important; }
}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);
}
.sr-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.check {
display: inline-flex;
align-items: center;
gap: 12px;
cursor: pointer;
font-weight: 600;
font-size: clamp(14px, 4vw, 17px);
line-height: 1.3;
font-family: inherit;
color: #fff7e6;
}
.box {
flex: none;
width: 28px;
height: 28px;
border-radius: 8px;
border: 2px solid rgba(255, 247, 230, 0.55);
background: transparent;
display: grid;
place-items: center;
transition: background 600ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sr-input:checked ~ .box {
background: #2f6df6;
border-color: #2f6df6;
}
.sr-input:focus-visible ~ .box {
box-shadow: 0 0 0 3px rgba(255, 247, 230, 0.5);
}
.box__svg {
width: 18px;
height: 18px;
overflow: visible;
}
.box__path {
fill: none;
stroke: #fff7e6;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 20;
stroke-dashoffset: 20;
transition: stroke-dashoffset 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sr-input:checked ~ .box .box__path {
stroke-dashoffset: 0;
}
@media (prefers-reduced-motion: reduce) {
.box, .box__path {
transition-duration: 1ms !important;
}
}// .is-demo 데모용 자동 토글. 실제로는 클릭이 :checked 를 바꾼다 (4줄)
document.querySelectorAll('.is-demo .sr-input').forEach(function (el) {
var t = function () { el.checked = !el.checked; };
t(); setInterval(t, 600);
});import { useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import styles from "./CheckDrawStroke.module.scss";
export type CheckDrawStrokeProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
defaultChecked?: boolean;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CheckDrawStroke({
label = "I agree to the terms",
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
defaultChecked = false,
}: CheckDrawStrokeProps) {
const [checked, setChecked] = useState(defaultChecked);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<label className={styles.check} style={vars}>
<input
type="checkbox"
className={styles.srInput}
checked={checked}
onChange={(e: ChangeEvent<HTMLInputElement>) => setChecked(e.target.checked)}
/>
<span className={styles.box} aria-hidden="true">
<svg viewBox="0 0 24 24" className={styles.boxSvg}>
<path className={styles.boxPath} d="M5 12.5 L10 17.5 L19 7" />
</svg>
</span>
<span>{label}</span>
</label>
);
}01 · 체크 스트로크 그리기 — 측정 누적면적 0.683% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.box {
border: 2px solid rgba($subject-cream, .55);
transition: background $duration $easing, border-color $duration $easing;
}
.sr-input:checked ~ .box { background: $color; border-color: $color; }
.box__path {
stroke-dasharray: 20;
stroke-dashoffset: 20;
transition: stroke-dashoffset $duration $easing;
}
.sr-input:checked ~ .box .box__path { stroke-dashoffset: 0; }
02바운스 체크
어떻게 움직이나
체크되는 순간 원형 박스 안의 체크 아이콘이 scale(0)에서 1.3배로 튀어 올랐다가 1배로 가라앉는 @keyframes 바운스로 등장합니다.
어디에 쓰나
장바구니 담기처럼 눌렀다는 반응이 경쾌해야 하는 곳에 맞습니다.

<main class="stage">
<label class="check is-demo">
<input type="checkbox" class="sr-input">
<span class="box" aria-hidden="true">
<svg viewBox="0 0 24 24" class="box__svg"><path class="box__path" d="M5 12.5 L10 17.5 L19 7" /></svg>
</span>
<span class="check__text">알림 받기</span>
</label>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-enter;
$easing: $ease-pop;
$color: $stage-ink;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.sr-input {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.check { display: inline-flex; align-items: center; gap: 12px; cursor: pointer;
font-weight: 700; font-size: clamp(14px, 4vw, 17px); line-height: 1.3; font-family: inherit; color: $color; }
.box {
flex: none; width: 28px; height: 28px; border-radius: 50%;
border: 2px solid rgba($color, .5); background: transparent;
display: grid; place-items: center; transition: background $dur-quick $ease-out;
}
.sr-input:checked ~ .box { background: $color; border-color: $color; }
.sr-input:focus-visible ~ .box { box-shadow: 0 0 0 3px rgba($color, .35); }
.box__svg {
width: 16px; height: 16px; overflow: visible;
transform: scale(0); opacity: 0;
}
.box__path { fill: none; stroke: $stage-yellow; stroke-width: 2.6; stroke-linecap: round; stroke-linejoin: round; }
.sr-input:checked ~ .box .box__svg { animation: check-pop $duration $easing forwards; }
@keyframes check-pop {
0% { transform: scale(0); opacity: 0; }
55% { transform: scale(1.3); opacity: 1; }
75% { transform: scale(.9); }
100% { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
// 접근성: 움직임 줄이기 설정이면 트랜지션을 즉시 끝낸다
.box__svg { animation: none !important; }
.sr-input:checked ~ .box .box__svg { transform: scale(1); opacity: 1; }
}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);
}
.sr-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.check {
display: inline-flex;
align-items: center;
gap: 12px;
cursor: pointer;
font-weight: 700;
font-size: clamp(14px, 4vw, 17px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.box {
flex: none;
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid rgba(23, 20, 26, 0.5);
background: transparent;
display: grid;
place-items: center;
transition: background 220ms ease-out;
}
.sr-input:checked ~ .box {
background: #17141a;
border-color: #17141a;
}
.sr-input:focus-visible ~ .box {
box-shadow: 0 0 0 3px rgba(23, 20, 26, 0.35);
}
.box__svg {
width: 16px;
height: 16px;
overflow: visible;
transform: scale(0);
opacity: 0;
}
.box__path {
fill: none;
stroke: #ffd23f;
stroke-width: 2.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.sr-input:checked ~ .box .box__svg {
animation: check-pop 600ms cubic-bezier(0.2, 1.4, 0.4, 1) forwards;
}
@keyframes check-pop {
0% {
transform: scale(0);
opacity: 0;
}
55% {
transform: scale(1.3);
opacity: 1;
}
75% {
transform: scale(0.9);
}
100% {
transform: scale(1);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.box__svg {
animation: none !important;
}
.sr-input:checked ~ .box .box__svg {
transform: scale(1);
opacity: 1;
}
}// .is-demo 데모용 자동 토글. 실제로는 클릭이 :checked 를 바꾼다 (4줄)
document.querySelectorAll('.is-demo .sr-input').forEach(function (el) {
var t = function () { el.checked = !el.checked; };
t(); setInterval(t, 600);
});import { useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import styles from "./BounceCheck.module.scss";
export type BounceCheckProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BounceCheck({
label = "Get notifications",
duration = 600,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#17141a",
}: BounceCheckProps) {
const [checked, setChecked] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<label className={styles.check} style={vars}>
<input
type="checkbox"
className={styles.srInput}
checked={checked}
onChange={(e: ChangeEvent<HTMLInputElement>) => setChecked(e.target.checked)}
/>
<span className={styles.box} aria-hidden="true">
{checked && (
<svg viewBox="0 0 24 24" className={styles.boxSvg}>
<path className={styles.boxPath} d="M5 12.5 L10 17.5 L19 7" />
</svg>
)}
</span>
<span>{label}</span>
</label>
);
}02 · 바운스 체크 — 측정 누적면적 0.583% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.sr-input:checked ~ .box .box__svg {
animation: check-pop $duration $easing forwards;
}
@keyframes check-pop {
0% { transform: scale(0); opacity: 0; }
55% { transform: scale(1.3); opacity: 1; }
75% { transform: scale(.9); }
100% { transform: scale(1); opacity: 1; }
}
03라디오 채움 애니메이션
css 라디오 버튼 커스텀은 어디가 다른가
라디오 세 개 중 하나를 고르면 테두리 안쪽 점이 scale(0)에서 scale(1)로 커지고, 다른 옵션을 고르는 순간 이전 점은 같은 :checked 규칙에 의해 즉시 사라집니다.
어디에 쓰나
결제 수단이나 배송 옵션처럼 하나만 남는 자리에 씁니다.

<main class="stage">
<fieldset class="radio-group is-demo">
<legend class="radio-group__legend">결제 수단</legend>
<label class="radio"><input type="radio" name="pay" class="sr-input" checked><span class="radio__dot" aria-hidden="true"></span><span>카드</span></label>
<label class="radio"><input type="radio" name="pay" class="sr-input"><span class="radio__dot" aria-hidden="true"></span><span>계좌</span></label>
<label class="radio"><input type="radio" name="pay" class="sr-input"><span class="radio__dot" aria-hidden="true"></span><span>휴대폰</span></label>
</fieldset>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
.sr-input {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.radio-group { display: flex; flex-direction: column; gap: clamp(8px, 2.4vw, 12px); border: 0; margin: 0; padding: 0; }
.radio-group__legend {
color: $subject-ink; font-weight: 700; font-size: 12px; line-height: 1; font-family: inherit; letter-spacing: .04em; text-transform: uppercase;
padding: 0 0 2px;
}
.radio { display: inline-flex; align-items: center; gap: 10px; cursor: pointer;
font-weight: 600; font-size: clamp(13px, 3.8vw, 15px); line-height: 1; font-family: inherit; color: $subject-ink; }
.radio__dot { flex: none; width: 22px; height: 22px; border-radius: 50%;
border: 2px solid rgba($subject-cream, .6); display: grid; place-items: center; }
.radio__dot::after {
content: ""; width: 12px; height: 12px; border-radius: 50%; background: $color;
transform: scale(0); transition: transform $duration $easing;
}
.sr-input:checked ~ .radio__dot::after { transform: scale(1); }
.sr-input:focus-visible ~ .radio__dot { box-shadow: 0 0 0 3px rgba($subject-cream, .5); }
@media (prefers-reduced-motion: reduce) {
// 접근성: 움직임 줄이기 설정이면 트랜지션을 즉시 끝낸다
.radio__dot::after { transition-duration: 1ms !important; }
}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);
}
.sr-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.radio-group {
display: flex;
flex-direction: column;
gap: clamp(8px, 2.4vw, 12px);
border: 0;
margin: 0;
padding: 0;
}
.radio-group__legend {
color: #17141a;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 0 0 2px;
}
.radio {
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-weight: 600;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1;
font-family: inherit;
color: #17141a;
}
.radio__dot {
flex: none;
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid rgba(255, 247, 230, 0.6);
display: grid;
place-items: center;
}
.radio__dot::after {
content: "";
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff7e6;
transform: scale(0);
transition: transform 220ms ease-out;
}
.sr-input:checked ~ .radio__dot::after {
transform: scale(1);
}
.sr-input:focus-visible ~ .radio__dot {
box-shadow: 0 0 0 3px rgba(255, 247, 230, 0.5);
}
@media (prefers-reduced-motion: reduce) {
.radio__dot::after {
transition-duration: 1ms !important;
}
}// .is-demo 데모용 자동 순환. 실제로는 클릭이 :checked 를 바꾼다 (6줄)
document.querySelectorAll('.is-demo').forEach(function (grp) {
var inputs = grp.querySelectorAll('.sr-input'); var i = 0;
var t = function () { inputs[i % inputs.length].checked = true; i++; };
t(); setInterval(t, 500);
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RadioFill.module.scss";
export type RadioFillOption = { value: string; label: string };
export type RadioFillProps = {
legend?: string;
options?: RadioFillOption[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function RadioFill({
legend = "Payment method",
options = [
{ value: "card", label: "Card" },
{ value: "bank", label: "Bank transfer" },
{ value: "phone", label: "Phone" },
],
duration = 220,
easing = "ease-out",
color = "#fff7e6",
}: RadioFillProps) {
const [value, setValue] = useState(options[0]?.value);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<fieldset className={styles.group} style={vars}>
<legend className={styles.legend}>{legend}</legend>
{options.map((o) => (
<label className={styles.radio} key={o.value}>
<input
type="radio"
name="radio-fill"
className={styles.srInput}
checked={value === o.value}
onChange={() => setValue(o.value)}
/>
<span className={styles.dot} aria-hidden="true" />
<span>{o.label}</span>
</label>
))}
</fieldset>
);
}03 · 라디오 채움 애니메이션 — 측정 누적면적 0.258% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.radio__dot::after {
content: "";
width: 12px; height: 12px;
border-radius: 50%;
background: $color;
transform: scale(0);
transition: transform $duration $easing;
}
.sr-input:checked ~ .radio__dot::after { transform: scale(1); }
04스위치형 체크박스
어떻게 움직이나
겉모습은 스위치지만 노브는 가운데에 고정해 두고, 트랙 배경이 clip-path: circle() 로 중심에서 바깥까지 번지며 켜진 상태를 알립니다.
어디에 쓰나
노브 안 두 아이콘은 opacity·scale 로 자리를 바꿔가며 크로스페이드합니다.

<main class="stage">
<label class="switch is-demo">
<input type="checkbox" class="sr-input">
<span class="switch__track" aria-hidden="true">
<span class="switch__fill"></span>
<span class="switch__knob">
<svg viewBox="0 0 24 24" class="switch__icon switch__icon--on"><path d="M18 8a6 6 0 1 1-6-6 7 7 0 0 0 6 6Z" /></svg>
<svg viewBox="0 0 24 24" class="switch__icon switch__icon--off"><circle cx="12" cy="12" r="3.2" /><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1" stroke-width="1.6" stroke-linecap="round" /></svg>
</span>
</span>
<span class="switch__text">알림 무음</span>
</label>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-paper);
.sr-input {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.switch { display: inline-flex; align-items: center; gap: 12px; cursor: pointer;
font-weight: 600; font-size: clamp(14px, 4vw, 16px); line-height: 1.3; font-family: inherit; color: $subject-ink; }
.switch__track {
position: relative; width: 56px; height: 28px; border-radius: $r-pill;
background: rgba($subject-ink, .14); overflow: hidden; display: grid; place-items: center;
}
.switch__fill {
position: absolute; inset: 0; background: $color;
clip-path: circle(0% at 50% 50%); transition: clip-path $duration $easing;
}
.sr-input:checked ~ .switch__track .switch__fill { clip-path: circle(75% at 50% 50%); }
.sr-input:focus-visible ~ .switch__track { box-shadow: 0 0 0 3px rgba($subject-blue, .35); }
.switch__knob {
position: relative; z-index: 1; width: 22px; height: 22px; border-radius: 50%;
background: #fff; display: grid; place-items: center; box-shadow: $shadow-press;
}
.switch__icon {
position: absolute; width: 13px; height: 13px; fill: none; stroke: $subject-ink; stroke-width: 1.6;
transition: opacity $duration $easing, transform $duration $easing;
}
.switch__icon--on { opacity: 0; transform: scale(.6); }
.switch__icon--off { opacity: 1; transform: scale(1); }
.sr-input:checked ~ .switch__track .switch__icon--on { opacity: 1; transform: scale(1); }
.sr-input:checked ~ .switch__track .switch__icon--off { opacity: 0; transform: scale(.6); }
@media (prefers-reduced-motion: reduce) {
// 접근성: 움직임 줄이기 설정이면 트랜지션을 즉시 끝낸다
.switch__fill, .switch__icon { transition-duration: 1ms !important; }
}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);
}
.sr-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.switch {
display: inline-flex;
align-items: center;
gap: 12px;
cursor: pointer;
font-weight: 600;
font-size: clamp(14px, 4vw, 16px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.switch__track {
position: relative;
width: 56px;
height: 28px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.14);
overflow: hidden;
display: grid;
place-items: center;
}
.switch__fill {
position: absolute;
inset: 0;
background: #2f6df6;
clip-path: circle(0% at 50% 50%);
transition: clip-path 300ms ease-out;
}
.sr-input:checked ~ .switch__track .switch__fill {
clip-path: circle(75% at 50% 50%);
}
.sr-input:focus-visible ~ .switch__track {
box-shadow: 0 0 0 3px rgba(47, 109, 246, 0.35);
}
.switch__knob {
position: relative;
z-index: 1;
width: 22px;
height: 22px;
border-radius: 50%;
background: #fff;
display: grid;
place-items: center;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.switch__icon {
position: absolute;
width: 13px;
height: 13px;
fill: none;
stroke: #17141a;
stroke-width: 1.6;
transition: opacity 300ms ease-out, transform 300ms ease-out;
}
.switch__icon--on {
opacity: 0;
transform: scale(0.6);
}
.switch__icon--off {
opacity: 1;
transform: scale(1);
}
.sr-input:checked ~ .switch__track .switch__icon--on {
opacity: 1;
transform: scale(1);
}
.sr-input:checked ~ .switch__track .switch__icon--off {
opacity: 0;
transform: scale(0.6);
}
@media (prefers-reduced-motion: reduce) {
.switch__fill, .switch__icon {
transition-duration: 1ms !important;
}
}// .is-demo 데모용 자동 토글. 실제로는 클릭이 :checked 를 바꾼다 (4줄)
document.querySelectorAll('.is-demo .sr-input').forEach(function (el) {
var t = function () { el.checked = !el.checked; };
t(); setInterval(t, 600);
});import { useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import styles from "./SwitchLikeCheckbox.module.scss";
export type SwitchLikeCheckboxProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SwitchLikeCheckbox({
label = "Mute notifications",
duration = 300,
easing = "ease-out",
color = "#2f6df6",
}: SwitchLikeCheckboxProps) {
const [checked, setChecked] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<label className={styles.switch} style={vars}>
<input
type="checkbox"
className={styles.srInput}
checked={checked}
onChange={(e: ChangeEvent<HTMLInputElement>) => setChecked(e.target.checked)}
/>
<span className={styles.track} aria-hidden="true">
<span className={styles.fill} />
<span className={styles.knob}>{checked ? "●" : "○"}</span>
</span>
<span>{label}</span>
</label>
);
}04 · 스위치형 체크박스 — 측정 누적면적 0.803% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.switch__fill {
position: absolute; inset: 0;
background: $color;
clip-path: circle(0% at 50% 50%);
transition: clip-path $duration $easing;
}
.sr-input:checked ~ .switch__track .switch__fill {
clip-path: circle(75% at 50% 50%);
}
05카드 선택형
어떻게 움직이나
라디오로 묶은 카드 중 하나를 고르면 :has(.sr-input:checked) 로 그 카드의 테두리·배경이 강조되고, 오른쪽 위 배지 안 체크 아이콘이 scale 로 나타납니다.
어디에 쓰나
:has() 덕분에 라디오와 배지가 형제가 아니어도 카드 전체를 스타일링할 수 있습니다.

<main class="stage">
<fieldset class="cards is-demo">
<legend class="sr-only">요금제 선택</legend>
<label class="card">
<input type="radio" name="plan" class="sr-input" checked>
<span class="card__badge" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 12.5 L10 17.5 L19 7" /></svg></span>
<span class="card__title">베이직</span>
</label>
<label class="card">
<input type="radio" name="plan" class="sr-input">
<span class="card__badge" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 12.5 L10 17.5 L19 7" /></svg></span>
<span class="card__title">프로</span>
</label>
<label class="card">
<input type="radio" name="plan" class="sr-input">
<span class="card__badge" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 12.5 L10 17.5 L19 7" /></svg></span>
<span class="card__title">팀</span>
</label>
</fieldset>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.sr-input {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.cards { display: flex; gap: clamp(8px, 2.4vw, 12px); border: 0; margin: 0; padding: 0; }
.card {
position: relative; display: flex; flex-direction: column; align-items: center; gap: 8px;
padding: clamp(10px, 3vw, 14px) clamp(12px, 3.6vw, 18px);
border-radius: $r-card; border: 2px solid rgba($subject-cream, .22); background: rgba($subject-cream, .06);
cursor: pointer; transition: border-color $duration $easing, background $duration $easing;
}
.card:has(.sr-input:checked) { border-color: $color; background: rgba($subject-cream, .14); }
.card__badge {
width: 22px; height: 22px; border-radius: 50%; border: 2px solid rgba($subject-cream, .4);
display: grid; place-items: center; transition: border-color $duration $easing, background $duration $easing;
}
.card__badge svg {
width: 13px; height: 13px; fill: none; stroke: $subject-cream; stroke-width: 2.4;
stroke-linecap: round; stroke-linejoin: round; opacity: 0; transform: scale(.4);
transition: opacity $duration $easing, transform $duration $easing;
}
.card:has(.sr-input:checked) .card__badge { border-color: $color; background: $color; }
.card:has(.sr-input:checked) .card__badge svg { opacity: 1; transform: scale(1); stroke: $stage-ink; }
.card__title { font-weight: 700; font-size: clamp(12px, 3.4vw, 14px); line-height: 1; font-family: inherit; color: $subject-cream; }
.sr-input:focus-visible ~ .card__badge { box-shadow: 0 0 0 3px rgba($subject-cream, .5); }
@media (prefers-reduced-motion: reduce) {
// 접근성: 움직임 줄이기 설정이면 트랜지션을 즉시 끝낸다
.card, .card__badge, .card__badge svg { transition-duration: 1ms !important; }
}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);
}
.sr-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.cards {
display: flex;
gap: clamp(8px, 2.4vw, 12px);
border: 0;
margin: 0;
padding: 0;
}
.card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: clamp(10px, 3vw, 14px) clamp(12px, 3.6vw, 18px);
border-radius: 16px;
border: 2px solid rgba(255, 247, 230, 0.22);
background: rgba(255, 247, 230, 0.06);
cursor: pointer;
transition: border-color 300ms ease-out, background 300ms ease-out;
}
.card:has(.sr-input:checked) {
border-color: #2f6df6;
background: rgba(255, 247, 230, 0.14);
}
.card__badge {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid rgba(255, 247, 230, 0.4);
display: grid;
place-items: center;
transition: border-color 300ms ease-out, background 300ms ease-out;
}
.card__badge svg {
width: 13px;
height: 13px;
fill: none;
stroke: #fff7e6;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0;
transform: scale(0.4);
transition: opacity 300ms ease-out, transform 300ms ease-out;
}
.card:has(.sr-input:checked) .card__badge {
border-color: #2f6df6;
background: #2f6df6;
}
.card:has(.sr-input:checked) .card__badge svg {
opacity: 1;
transform: scale(1);
stroke: #17141a;
}
.card__title {
font-weight: 700;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
}
.sr-input:focus-visible ~ .card__badge {
box-shadow: 0 0 0 3px rgba(255, 247, 230, 0.5);
}
@media (prefers-reduced-motion: reduce) {
.card, .card__badge, .card__badge svg {
transition-duration: 1ms !important;
}
}// .is-demo 데모용 자동 순환. 실제로는 클릭이 :checked 를 바꾼다 (6줄)
document.querySelectorAll('.is-demo').forEach(function (grp) {
var inputs = grp.querySelectorAll('.sr-input'); var i = 0;
var t = function () { inputs[i % inputs.length].checked = true; i++; };
t(); setInterval(t, 500);
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CardSelect.module.scss";
export type CardSelectOption = { value: string; title: string };
export type CardSelectProps = {
legend?: string;
options?: CardSelectOption[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CardSelect({
legend = "Choose a plan",
options = [
{ value: "basic", title: "Basic" },
{ value: "pro", title: "Pro" },
{ value: "team", title: "Team" },
],
duration = 300,
easing = "ease-out",
color = "#2f6df6",
}: CardSelectProps) {
const [value, setValue] = useState(options[0]?.value);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<fieldset className={styles.cards} style={vars}>
<legend className={styles.srOnly}>{legend}</legend>
{options.map((o) => (
<label className={styles.card} key={o.value} data-checked={value === o.value}>
<input
type="radio"
name="card-select"
className={styles.srInput}
checked={value === o.value}
onChange={() => setValue(o.value)}
/>
<span className={styles.badge} aria-hidden="true">
{value === o.value ? "✓" : ""}
</span>
<span className={styles.title}>{o.title}</span>
</label>
))}
</fieldset>
);
}05 · 카드 선택형 — 측정 누적면적 10.862% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.card:has(.sr-input:checked) {
border-color: $color;
background: rgba($subject-cream, .14);
}
.card:has(.sr-input:checked) .card__badge {
border-color: $color;
background: $color;
}
.card:has(.sr-input:checked) .card__badge svg {
opacity: 1; transform: scale(1);
}
06할일 취소선 애니메이션
어떻게 움직이나
체크박스를 켜면 라벨 글자 위로 ::after 취소선이 scaleX(0)에서 scaleX(1)로 왼쪽부터 그어지고, 글자 opacity 가 0.45 로 낮아집니다.
어디에 쓰나
할 일 목록이나 체크리스트의 완료 표시에 씁니다.

<main class="stage">
<label class="todo is-demo">
<input type="checkbox" class="sr-input">
<span class="todo__box" aria-hidden="true">
<svg viewBox="0 0 24 24" class="todo__svg"><path d="M5 12.5 L10 17.5 L19 7" /></svg>
</span>
<span class="todo__text">
<span class="todo__label">보고서 제출</span>
<span class="todo__strike" aria-hidden="true"></span>
</span>
</label>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base;
$easing: $ease-out;
$color: $stage-ink;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.sr-input {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.todo { display: inline-flex; align-items: center; gap: 12px; cursor: pointer;
font-weight: 600; font-size: clamp(14px, 4vw, 16px); line-height: 1.3; font-family: inherit; color: $color; }
.todo__box {
flex: none; width: 24px; height: 24px; border-radius: 6px; border: 2px solid rgba($color, .5);
display: grid; place-items: center; transition: background $duration $easing, border-color $duration $easing;
}
.sr-input:checked ~ .todo__box { background: $color; border-color: $color; }
.sr-input:focus-visible ~ .todo__box { box-shadow: 0 0 0 3px rgba($color, .3); }
.todo__svg {
width: 15px; height: 15px; fill: none; stroke: $stage-yellow; stroke-width: 2.6;
stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 20; stroke-dashoffset: 20;
transition: stroke-dashoffset $duration $easing;
}
.sr-input:checked ~ .todo__box .todo__svg { stroke-dashoffset: 0; }
.todo__text { position: relative; display: inline-block; }
.todo__label { transition: opacity $duration $easing; }
.sr-input:checked ~ .todo__text .todo__label { opacity: .45; }
.todo__strike {
position: absolute; inset-inline: 0; top: 50%; height: 2px; background: currentColor;
transform: scaleX(0); transform-origin: left; transition: transform $duration $easing; // 규칙8: 취소선 왼쪽 그어짐
}
.sr-input:checked ~ .todo__text .todo__strike { transform: scaleX(1); }
@media (prefers-reduced-motion: reduce) {
// 접근성: 움직임 줄이기 설정이면 트랜지션을 즉시 끝낸다
.todo__box, .todo__svg, .todo__label, .todo__strike { transition-duration: 1ms !important; }
}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);
}
.sr-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.todo {
display: inline-flex;
align-items: center;
gap: 12px;
cursor: pointer;
font-weight: 600;
font-size: clamp(14px, 4vw, 16px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.todo__box {
flex: none;
width: 24px;
height: 24px;
border-radius: 6px;
border: 2px solid rgba(23, 20, 26, 0.5);
display: grid;
place-items: center;
transition: background 300ms ease-out, border-color 300ms ease-out;
}
.sr-input:checked ~ .todo__box {
background: #17141a;
border-color: #17141a;
}
.sr-input:focus-visible ~ .todo__box {
box-shadow: 0 0 0 3px rgba(23, 20, 26, 0.3);
}
.todo__svg {
width: 15px;
height: 15px;
fill: none;
stroke: #ffd23f;
stroke-width: 2.6;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 20;
stroke-dashoffset: 20;
transition: stroke-dashoffset 300ms ease-out;
}
.sr-input:checked ~ .todo__box .todo__svg {
stroke-dashoffset: 0;
}
.todo__text {
position: relative;
display: inline-block;
}
.todo__label {
transition: opacity 300ms ease-out;
}
.sr-input:checked ~ .todo__text .todo__label {
opacity: 0.45;
}
.todo__strike {
position: absolute;
inset-inline: 0;
top: 50%;
height: 2px;
background: currentColor;
transform: scaleX(0);
transform-origin: left;
transition: transform 300ms ease-out;
}
.sr-input:checked ~ .todo__text .todo__strike {
transform: scaleX(1);
}
@media (prefers-reduced-motion: reduce) {
.todo__box, .todo__svg, .todo__label, .todo__strike {
transition-duration: 1ms !important;
}
}// .is-demo 데모용 자동 토글. 실제로는 클릭이 :checked 를 바꾼다 (4줄)
document.querySelectorAll('.is-demo .sr-input').forEach(function (el) {
var t = function () { el.checked = !el.checked; };
t(); setInterval(t, 600);
});import { useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import styles from "./TodoStrike.module.scss";
export type TodoStrikeProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function TodoStrike({
label = "Submit report",
duration = 300,
easing = "ease-out",
color = "#17141a",
}: TodoStrikeProps) {
const [done, setDone] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<label className={styles.todo} style={vars}>
<input
type="checkbox"
className={styles.srInput}
checked={done}
onChange={(e: ChangeEvent<HTMLInputElement>) => setDone(e.target.checked)}
/>
<span className={styles.box} aria-hidden="true">
{done && <span className={styles.check}>✓</span>}
</span>
<span className={styles.text}>
<span className={styles.label} data-done={done}>{label}</span>
<span className={styles.strike} data-done={done} aria-hidden="true" />
</span>
</label>
);
}06 · 할일 취소선 애니메이션 — 측정 누적면적 0.999% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.todo__strike {
position: absolute; inset-inline: 0; top: 50%;
height: 2px; background: currentColor;
transform: scaleX(0);
transform-origin: left;
transition: transform $duration $easing;
}
.sr-input:checked ~ .todo__text .todo__strike { transform: scaleX(1); }
.sr-input:checked ~ .todo__text .todo__label { opacity: .45; }
07색상 스와치 선택
어떻게 움직이나
원형 스와치 중 하나를 고르면 :has() 로 그 label 의 ::after 링이 scale(0)에서 scale(1)로 커집니다.
어디에 쓰나
스와치 배경색은 새 hex 를 더하지 않고 디자인 토큰 네 개를 그대로 재사용했습니다.

<main class="stage">
<fieldset class="swatches is-demo">
<legend class="sr-only">색상 선택</legend>
<label class="swatch"><input type="radio" name="color" class="sr-input" checked aria-label="색상 1"><span class="swatch__chip swatch__chip--1" aria-hidden="true"></span></label>
<label class="swatch"><input type="radio" name="color" class="sr-input" aria-label="색상 2"><span class="swatch__chip swatch__chip--2" aria-hidden="true"></span></label>
<label class="swatch"><input type="radio" name="color" class="sr-input" aria-label="색상 3"><span class="swatch__chip swatch__chip--3" aria-hidden="true"></span></label>
<label class="swatch"><input type="radio" name="color" class="sr-input" aria-label="색상 4"><span class="swatch__chip swatch__chip--4" aria-hidden="true"></span></label>
</fieldset>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
.sr-input {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.swatches { display: flex; gap: clamp(8px, 2.6vw, 14px); border: 0; margin: 0; padding: 0; }
.swatch { position: relative; display: inline-flex; padding: 5px; border-radius: 50%; cursor: pointer; }
.swatch::after {
content: ""; position: absolute; inset: 0; border-radius: 50%; border: 2px solid $color;
transform: scale(0); opacity: 0; transition: transform $duration $easing, opacity $duration $easing;
}
.swatch:has(.sr-input:checked)::after { transform: scale(1); opacity: 1; }
.sr-input:focus-visible ~ .swatch__chip { box-shadow: 0 0 0 3px rgba($subject-cream, .5); }
.swatch__chip { width: 28px; height: 28px; border-radius: 50%; display: block; box-shadow: $shadow-press; }
.swatch__chip--1 { background: $subject-blue; }
.swatch__chip--2 { background: $stage-yellow; }
.swatch__chip--3 { background: $stage-ink; }
.swatch__chip--4 { background: $subject-cream; }
@media (prefers-reduced-motion: reduce) {
// 접근성: 움직임 줄이기 설정이면 트랜지션을 즉시 끝낸다
.swatch::after { transition-duration: 1ms !important; }
}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);
}
.sr-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.swatches {
display: flex;
gap: clamp(8px, 2.6vw, 14px);
border: 0;
margin: 0;
padding: 0;
}
.swatch {
position: relative;
display: inline-flex;
padding: 5px;
border-radius: 50%;
cursor: pointer;
}
.swatch::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid #fff7e6;
transform: scale(0);
opacity: 0;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.swatch:has(.sr-input:checked)::after {
transform: scale(1);
opacity: 1;
}
.sr-input:focus-visible ~ .swatch__chip {
box-shadow: 0 0 0 3px rgba(255, 247, 230, 0.5);
}
.swatch__chip {
width: 28px;
height: 28px;
border-radius: 50%;
display: block;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.swatch__chip--1 {
background: #2f6df6;
}
.swatch__chip--2 {
background: #ffd23f;
}
.swatch__chip--3 {
background: #17141a;
}
.swatch__chip--4 {
background: #fff7e6;
}
@media (prefers-reduced-motion: reduce) {
.swatch::after {
transition-duration: 1ms !important;
}
}// .is-demo 데모용 자동 순환. 실제로는 클릭이 :checked 를 바꾼다 (6줄)
document.querySelectorAll('.is-demo').forEach(function (grp) {
var inputs = grp.querySelectorAll('.sr-input'); var i = 0;
var t = function () { inputs[i % inputs.length].checked = true; i++; };
t(); setInterval(t, 500);
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ColorSwatchPick.module.scss";
export type Swatch = { value: string; color: string; label: string };
export type ColorSwatchPickProps = {
legend?: string;
swatches?: Swatch[];
duration?: number;
easing?: string;
ringColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--ring"?: string };
export default function ColorSwatchPick({
legend = "Choose a color",
swatches = [
{ value: "1", color: "#2f6df6", label: "Color 1" },
{ value: "2", color: "#ffd23f", label: "Color 2" },
{ value: "3", color: "#17141a", label: "Color 3" },
{ value: "4", color: "#fff7e6", label: "Color 4" },
],
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
ringColor = "#fff7e6",
}: ColorSwatchPickProps) {
const [value, setValue] = useState(swatches[0]?.value);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--ring": ringColor };
return (
<fieldset className={styles.swatches} style={vars}>
<legend className={styles.srOnly}>{legend}</legend>
{swatches.map((s) => (
<label className={styles.swatch} key={s.value} data-checked={value === s.value}>
<input
type="radio"
name="swatch"
className={styles.srInput}
checked={value === s.value}
onChange={() => setValue(s.value)}
aria-label={s.label}
/>
<span className={styles.chip} style={{ background: s.color }} aria-hidden="true" />
</label>
))}
</fieldset>
);
}07 · 색상 스와치 선택 — 측정 누적면적 1.397% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.swatch::after {
content: "";
position: absolute; inset: 0;
border-radius: 50%;
border: 2px solid $color;
transform: scale(0);
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
}
.swatch:has(.sr-input:checked)::after { transform: scale(1); opacity: 1; }
08별점 입력형
어떻게 움직이나
라디오 다섯 개를 5→1 역순으로 두고 label 을 row-reverse 로 뒤집어 화면엔 1→5 로 보이게 한 뒤, :checked ~ .star .star__icon 형제 선택자 하나로 고른 별과 그 앞의 별을 한 번에 채웁니다.
어디에 쓰나
리뷰나 만족도 조사의 별점 입력에 씁니다.

<main class="stage">
<fieldset class="stars is-demo">
<legend class="sr-only">평점 선택</legend>
<input type="radio" id="s5" name="rate" class="sr-input" value="5">
<label for="s5" class="star" aria-label="5점"><span class="star__icon" aria-hidden="true"></span></label>
<input type="radio" id="s4" name="rate" class="sr-input" value="4">
<label for="s4" class="star" aria-label="4점"><span class="star__icon" aria-hidden="true"></span></label>
<input type="radio" id="s3" name="rate" class="sr-input" value="3" checked>
<label for="s3" class="star" aria-label="3점"><span class="star__icon" aria-hidden="true"></span></label>
<input type="radio" id="s2" name="rate" class="sr-input" value="2">
<label for="s2" class="star" aria-label="2점"><span class="star__icon" aria-hidden="true"></span></label>
<input type="radio" id="s1" name="rate" class="sr-input" value="1">
<label for="s1" class="star" aria-label="1점"><span class="star__icon" aria-hidden="true"></span></label>
</fieldset>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-paper);
.sr-input {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
// 별점 트릭: 라디오를 5→1 역순으로 두고 row-reverse 로 뒤집어 화면엔 1→5 로 보이게 한다.
.stars { display: flex; flex-direction: row-reverse; gap: 6px; border: 0; margin: 0; padding: 0; }
.star { cursor: pointer; display: block; }
.star__icon {
display: block; width: 26px; height: 26px; background: rgba($subject-ink, .22);
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
transition: background $duration $easing;
}
.sr-input:checked ~ .star .star__icon,
.sr-input:checked ~ .star ~ .star .star__icon { background: $color; }
.sr-input:focus-visible + .star .star__icon { box-shadow: 0 0 0 3px rgba($subject-blue, .35); }
@media (prefers-reduced-motion: reduce) {
// 접근성: 움직임 줄이기 설정이면 트랜지션을 즉시 끝낸다
.star__icon { transition-duration: 1ms !important; }
}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);
}
.sr-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.stars {
display: flex;
flex-direction: row-reverse;
gap: 6px;
border: 0;
margin: 0;
padding: 0;
}
.star {
cursor: pointer;
display: block;
}
.star__icon {
display: block;
width: 26px;
height: 26px;
background: rgba(23, 20, 26, 0.22);
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
transition: background 220ms ease-out;
}
.sr-input:checked ~ .star .star__icon,
.sr-input:checked ~ .star ~ .star .star__icon {
background: #2f6df6;
}
.sr-input:focus-visible + .star .star__icon {
box-shadow: 0 0 0 3px rgba(47, 109, 246, 0.35);
}
@media (prefers-reduced-motion: reduce) {
.star__icon {
transition-duration: 1ms !important;
}
}// .is-demo 데모용 자동 순환. 실제로는 클릭이 :checked 를 바꾼다 (6줄)
document.querySelectorAll('.is-demo').forEach(function (grp) {
var inputs = grp.querySelectorAll('.sr-input'); var i = 0;
var t = function () { inputs[i % inputs.length].checked = true; i++; };
t(); setInterval(t, 500);
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StarRateInput.module.scss";
export type StarRateInputProps = {
legend?: string;
max?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function StarRateInput({
legend = "Choose a rating",
max = 5,
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: StarRateInputProps) {
const [rating, setRating] = useState(3);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const stars = Array.from({ length: max }, (_, i) => i + 1);
return (
<fieldset className={styles.stars} style={vars}>
<legend className={styles.srOnly}>{legend}</legend>
{stars.map((n) => (
<label className={styles.star} key={n}>
<input
type="radio"
name="rate"
className={styles.srInput}
checked={rating === n}
onChange={() => setRating(n)}
aria-label={`${n} star${n > 1 ? "s" : ""}`}
/>
<span className={styles.icon} data-filled={n <= rating} aria-hidden="true" />
</label>
))}
</fieldset>
);
}08 · 별점 입력형 — 측정 누적면적 0.636% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.stars { display: flex; flex-direction: row-reverse; gap: 6px; }
.sr-input:checked ~ .star .star__icon,
.sr-input:checked ~ .star ~ .star .star__icon {
background: $color;
}
09칩 토글
어떻게 움직이나
칩 모양 체크박스를 누르면 배경이 가운데서부터 scaleX(0)에서 scaleX(1)로 채워지고, :active 상태에서 칩 전체가 scale(.96)으로 살짝 눌립니다.
어디에 쓰나
태그나 필터를 여러 개 동시에 고를 때 씁니다.

<main class="stage">
<div class="chips is-demo">
<label class="chip">
<input type="checkbox" class="sr-input" checked>
<span class="chip__fill" aria-hidden="true"></span>
<span class="chip__label">무료배송</span>
</label>
<label class="chip">
<input type="checkbox" class="sr-input">
<span class="chip__fill" aria-hidden="true"></span>
<span class="chip__label">당일출고</span>
</label>
<label class="chip">
<input type="checkbox" class="sr-input">
<span class="chip__fill" aria-hidden="true"></span>
<span class="chip__label">베스트</span>
</label>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue-deep;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.sr-input {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.chips { display: flex; flex-wrap: wrap; gap: clamp(6px, 2vw, 10px); justify-content: center; }
.chip {
position: relative; overflow: hidden; display: inline-flex; align-items: center;
padding: 8px clamp(12px, 3.6vw, 18px); border-radius: $r-pill; border: 1.5px solid rgba($subject-cream, .35);
cursor: pointer; transition: transform 120ms $ease-out;
}
.chip:active { transform: scale(.96); }
.chip__fill {
position: absolute; inset: 0; background: $color; z-index: 0;
transform: scaleX(0); transform-origin: center; transition: transform $duration $easing;
}
.sr-input:checked ~ .chip__fill { transform: scaleX(1); }
.sr-input:focus-visible ~ .chip__fill { box-shadow: inset 0 0 0 2px rgba($subject-cream, .6); }
.chip__label {
position: relative; z-index: 1; font-weight: 600; font-size: clamp(12px, 3.4vw, 13px); line-height: 1; font-family: inherit; color: $subject-cream;
transition: color $duration $easing;
}
.sr-input:checked ~ .chip__label { color: $subject-cream; }
@media (prefers-reduced-motion: reduce) {
// 접근성: 움직임 줄이기 설정이면 트랜지션을 즉시 끝낸다
.chip__fill, .chip__label, .chip { transition-duration: 1ms !important; }
}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);
}
.sr-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: clamp(6px, 2vw, 10px);
justify-content: center;
}
.chip {
position: relative;
overflow: hidden;
display: inline-flex;
align-items: center;
padding: 8px clamp(12px, 3.6vw, 18px);
border-radius: 999px;
border: 1.5px solid rgba(255, 247, 230, 0.35);
cursor: pointer;
transition: transform 120ms ease-out;
}
.chip:active {
transform: scale(0.96);
}
.chip__fill {
position: absolute;
inset: 0;
background: #0a4fe8;
z-index: 0;
transform: scaleX(0);
transform-origin: center;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.sr-input:checked ~ .chip__fill {
transform: scaleX(1);
}
.sr-input:focus-visible ~ .chip__fill {
box-shadow: inset 0 0 0 2px rgba(255, 247, 230, 0.6);
}
.chip__label {
position: relative;
z-index: 1;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 13px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
transition: color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.sr-input:checked ~ .chip__label {
color: #fff7e6;
}
@media (prefers-reduced-motion: reduce) {
.chip__fill, .chip__label, .chip {
transition-duration: 1ms !important;
}
}// .is-demo 데모용 자동 토글. 실제로는 클릭이 :checked 를 바꾼다 (4줄)
document.querySelectorAll('.is-demo .sr-input').forEach(function (el) {
var t = function () { el.checked = !el.checked; };
t(); setInterval(t, 600);
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ChipToggle.module.scss";
export type Chip = { value: string; label: string };
export type ChipToggleProps = {
chips?: Chip[];
defaultSelected?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ChipToggle({
chips = [
{ value: "free-shipping", label: "Free shipping" },
{ value: "ships-today", label: "Ships today" },
{ value: "best", label: "Best seller" },
],
defaultSelected = ["free-shipping"],
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#0a4fe8",
}: ChipToggleProps) {
const [selected, setSelected] = useState<string[]>(defaultSelected);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const toggle = (v: string) => {
setSelected((prev) => (prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v]));
};
return (
<div className={styles.chips} style={vars}>
{chips.map((c) => {
const on = selected.includes(c.value);
return (
<label className={styles.chip} key={c.value}>
<input
type="checkbox"
className={styles.srInput}
checked={on}
onChange={() => toggle(c.value)}
/>
<span className={styles.fill} data-checked={on} aria-hidden="true" />
<span className={styles.label} data-checked={on}>{c.label}</span>
</label>
);
})}
</div>
);
}09 · 칩 토글 — 측정 누적면적 4.197% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.chip__fill {
position: absolute; inset: 0;
background: $color; z-index: 0;
transform: scaleX(0);
transform-origin: center;
transition: transform $duration $easing;
}
.sr-input:checked ~ .chip__fill { transform: scaleX(1); }
.chip:active { transform: scale(.96); }
어디서 깨지는가 — 시각적으로 숨긴 input 이 진짜 함정입니다
숨기되 지우지 않는다
아홉 개 전부 진짜 <input type="checkbox"> 또는 <input type="radio"> 를 position: absolute; width: 1px; height: 1px; clip: rect(0,0,0,0); 로 시각적으로만 숨기고, display: none 이나 visibility: hidden 은 쓰지 않았습니다. 이 둘은 요소를 포커스 순서에서 완전히 빼버려서 키보드만 쓰는 사람은 체크박스 자체를 Tab 으로 만날 수 없게 됩니다.
:has() 를 못 읽는 브라우저
05 카드 선택형과 07 색상 스와치 선택은 :has() 로 부모 요소를 스타일링하는데, 이 선택자는 Chrome·Edge 105+, Safari 15.4+, Firefox 121+ 에서만 동작하고 그 아래 버전에서는 카드 테두리가 그대로 남아 선택 여부만 덜 보일 뿐 클릭 자체는 멀쩡히 작동합니다.
05 카드 선택형처럼 테두리 대신 그림자 두 겹으로 눌린 상태를 보여주는 방식은 뉴모피즘 디자인 랜딩 UI 키트에 섹션 단위로 정리해 두었습니다.
데모의 자동 재생 스크립트
데모에서 저절로 켜졌다 꺼지는 움직임은 각 index.html 안의 4~6줄짜리 자바스크립트가 checked 값을 주기적으로 뒤집는 것뿐이고, 실제 페이지에 붙일 때는 이 스크립트를 지우고 사람의 클릭이 :checked 를 바꾸게 두면 됩니다. 아홉 개 파일이 그대로 담긴 zip 의 압축 비밀번호는 8zg24hjv 이고, 그 파일 안의 index.html 에는 이 자동 재생 스크립트가 빠져 있습니다.
접근성
키보드와 스크린 리더
아홉 개 모두 실제 <input> 을 숨기되 지우지 않았기 때문에 스크린 리더는 체크박스나 라디오를 있는 그대로 읽고, Tab 과 Space·화살표 키만으로 상태를 바꿀 수 있습니다.
동작 줄이기에서
prefers-reduced-motion: reduce 에서는 트랜지션 지속시간을 1ms 로 줄이거나(02는 바운스 애니메이션 자체를 꺼서) 움직임은 없애되 체크·선택 상태 자체는 그대로 남깁니다. 포커스가 입력에 가면 MDN :focus-visible 로 옆 도형에 링이 뜨도록 아홉 개 전부에 넣었습니다.
.sr-input:focus-visible ~ .box {
box-shadow: 0 0 0 3px rgba($subject-cream, .5);
}
@media (prefers-reduced-motion: reduce) {
.box, .box__path { transition-duration: 1ms !important; }
}
체크박스·라디오 말고 다른 폼 부품 이야기는 CSS 애니메이션 카테고리에 모여 있고, 이 사이트가 무엇을 다루는지는 소개에 있습니다.
FAQ
css 라디오 버튼 디자인과 체크박스 중 뭘 써야 하나요?
하나만 켜고 끄는 독립된 상태라면 체크박스, 여러 옵션 중 정확히 하나만 남아야 한다면 라디오입니다. 04 스위치형 체크박스는 겉모습만 스위치를 빌렸을 뿐 실제로는 체크박스라서 여러 개를 각자 켜고 끌 수 있습니다. 상태가 남지 않고 누른 그 찰나에만 반응하면 되는 버튼이라면 CSS 버튼 눌림 효과 아홉 개를 대신 보면 됩니다.
label 없이 aria-label 만 붙여도 되나요?
짧은 스와치나 별점처럼 시각적으로 의미가 다 드러나는 경우엔 aria-label 로 충분하지만, 01 체크박스처럼 문장이 붙는 항목은 <label> 로 감싸 텍스트까지 클릭 영역에 포함시키는 쪽이 터치 사용성이 좋습니다.
React 판에서는 :checked 선택자가 안 통하는데 어떻게 하나요?
CSS Module 로 컴파일된 클래스에서 :checked 선택자 자체는 그대로 살아 있지만, React 컴포넌트는 checked 값을 useState 로 관리하고 onChange 로 값을 바꿔야 제어 컴포넌트가 됩니다. 이 zip 의 react/ 폴더 아홉 개가 그 패턴을 그대로 보여줍니다.








