9 CSS Checkbox Radio Animations — Copy-Paste
A CSS checkbox radio keeps the browser's real input in place and lets :checked alone redraw whatever sits next to it, one line, copy-paste onto a form that
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Checkmark stroke draw
- 02 Bouncy check
- 03 Radio fill animation
- 04 Switch-style checkbox
- 05 Card-select style
- 06 Todo strikethrough
- 07 Color swatch pick
- 08 Star-rate input
- 09 Chip toggle
The order below tracks how many boxes a click in a custom radio checkbox css set actually touches. 01, 02, 06, and 09 each toggle one independent checkbox on and off. 03, 05, 07, and 08 are radios, where picking one always clears whichever option lit up before it. 04 sits between the two: it's wired as a checkbox, but its knob-and-track shape borrows the look everyone reads as a switch. If that borrowed knob-and-track shape is all you came for, nine CSS toggle switches cover it properly, and if item 08's stars are closer to the mark, the CSS heart animations handle likes and ratings as their whole subject.
01Checkmark stroke draw
How it works
Checking the box drains an SVG path's stroke-dashoffset from 20 down to 0, so the checkmark looks hand-drawn from its bottom-left point up to its tip rather than just appearing.
Where it fits
A terms-agreement checkbox or a single to-do item both benefit from just over half a second of "I saw this happen."

<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">I agree to the terms</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 · Checkmark stroke draw — Measured area 0.683% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
.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; }
02Bouncy check
How the css checkbox animation bounces
The instant the box is checked, its icon scales from 0 up to 1.3 and settles back at 1 through a single @keyframes bounce, instead of the plain stroke draw item 01 uses.
Where it fits
Multi-select settings rows and an add-to-cart checkbox both want that extra snap since the click is the whole point of the interaction.

<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">Get notifications</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 · Bouncy check — Measured area 0.583% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
.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; }
}
03Radio fill animation
How it works
Three radios share one name, so picking a second option flips its sibling's :checked off in the same instant. The same rule that grows the new dot from scale(0) to scale(1) shrinks the old one back down.
Where it fits
Payment-method pickers and shipping-option lists rely on exactly one dot ever being visible.

<main class="stage">
<fieldset class="radio-group is-demo">
<legend class="radio-group__legend">Payment method</legend>
<label class="radio"><input type="radio" name="pay" class="sr-input" checked><span class="radio__dot" aria-hidden="true"></span><span>Card</span></label>
<label class="radio"><input type="radio" name="pay" class="sr-input"><span class="radio__dot" aria-hidden="true"></span><span>Bank transfer</span></label>
<label class="radio"><input type="radio" name="pay" class="sr-input"><span class="radio__dot" aria-hidden="true"></span><span>Phone</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 · Radio fill animation — Measured area 0.258% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
.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); }
04Switch-style checkbox
How it works
This is a checkbox pretending to be a switch: the knob never moves, and a clip-path: circle() layer underneath it grows from a 0% dot to a 75% circle to fill the track with color.
Where it fits
Two small icons inside the knob cross-fade through opacity and scale at the same time, so a moon or sun swaps in without the knob sliding an inch.

<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">Mute notifications</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 · Switch-style checkbox — Measured area 0.803% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
.fill {
position: absolute; inset: 0;
background: $color;
clip-path: circle(0% at 50% 50%);
transition: clip-path $duration $easing;
}
.sr-input:checked ~ .track .fill {
clip-path: circle(75% at 50% 50%);
}
05Card-select style
How it works
:has(.sr-input:checked) reaches up from a buried radio to style the whole .card wrapper around it, lighting the border and popping a check badge into view with no shared class or JavaScript in between.
Where it fits
Pricing-tier cards and shipping-method cards both need the entire card to read as selected, not just a small dot in the corner.

<main class="stage">
<fieldset class="cards is-demo">
<legend class="sr-only">Choose a plan</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">Basic</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">Pro</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">Team</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 · Card-select style — Measured area 10.862% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
.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);
}
06Todo strikethrough
How it works
Checking a to-do draws a scaleX(0) to scaleX(1) line across the label from an ::after pseudo-element while the text itself fades to 45% opacity. The strike and the fade run on the same clock instead of one snapping ahead of the other.
Where it fits
Any checklist or task list uses the pairing to mark an item done without deleting the row outright.

<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">Submit report</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 · Todo strikethrough — Measured area 0.999% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
.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 .strike { transform: scaleX(1); }
.sr-input:checked ~ .todo__text .label { opacity: .45; }
07Color swatch pick
How it works
Each swatch is its own label, and :has(.sr-input:checked) grows a ring around whichever one holds the checked radio, from scale(0) to scale(1) with a matching opacity fade.
Where it fits
Product color pickers and theme selectors both skip adding a border to every swatch up front, since the ring only exists once something is actually chosen.

<main class="stage">
<fieldset class="swatches is-demo">
<legend class="sr-only">Choose a color</legend>
<label class="swatch"><input type="radio" name="color" class="sr-input" checked aria-label="Color 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="Color 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="Color 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="Color 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 · Color swatch pick — Measured area 1.397% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
.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; }
08Star-rate input
How it works
The five radios sit in the markup 5 down to 1, and flex-direction: row-reverse flips that back to reading 1 through 5 on screen. That's the trick that lets one sibling selector light every star up to and including the checked one, not just the star itself.
Where it fits
Review forms and satisfaction surveys use the same five inputs a <select> would need, minus the dropdown.

<main class="stage">
<fieldset class="stars is-demo">
<legend class="sr-only">Choose a rating</legend>
<input type="radio" id="s5" name="rate" class="sr-input" value="5">
<label for="s5" class="star" aria-label="5 stars"><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 stars"><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 stars"><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 stars"><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 star"><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 · Star-rate input — Measured area 0.636% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
.stars { display: flex; flex-direction: row-reverse; gap: 6px; }
.sr-input:checked ~ .star .star__icon,
.sr-input:checked ~ .star ~ .star .star__icon {
background: $color;
}
09Chip toggle
How it works
Pressing a chip fills its background from the center out via scaleX(0) to scaleX(1), and a separate :active rule scales the whole chip to 0.96 for the length of the press. The fill and the squeeze never fight over the same transform.
Where it fits
Tag pickers and filter bars lean on it since several chips can sit checked at once.

<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">Free shipping</span>
</label>
<label class="chip">
<input type="checkbox" class="sr-input">
<span class="chip__fill" aria-hidden="true"></span>
<span class="chip__label">Ships today</span>
</label>
<label class="chip">
<input type="checkbox" class="sr-input">
<span class="chip__fill" aria-hidden="true"></span>
<span class="chip__label">Best seller</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 · Chip toggle — Measured area 4.197% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
.fill {
position: absolute; inset: 0;
background: $color; z-index: 0;
transform: scaleX(0);
transform-origin: center;
transition: transform $duration $easing;
}
.sr-input:checked ~ .fill { transform: scaleX(1); }
.chip:active { transform: scale(.96); }
Where it breaks — hiding the input isn't the same as removing it
Every one of the nine keeps a genuine <input type="checkbox"> or <input type="radio"> in the markup, hidden with position: absolute; width: 1px; height: 1px; clip: rect(0,0,0,0); rather than display: none or visibility: hidden.
Hiding versus removing
Swap in either of those two and the input drops out of the tab order entirely. A mouse still clicks the drawn shape next to it, but a keyboard user can no longer reach the control at all, since there's nothing left to focus.
The :has() floor
Items 05 and 07 lean on :has() to style a parent from a checked radio buried inside it, and that selector only reaches back to Chrome and Edge 105, Safari 15.4, and Firefox 121. A browser below those versions still lets every card and swatch get clicked and checked, but it just leaves the border and the badge exactly as they were, so the choice registers without ever looking chosen.
Why the stars are reversed
Item 08 takes the input-hiding trick one step further and reorders the radios themselves — 5, 4, 3, 2, 1 in the markup, flipped visually by row-reverse. That reorder exists purely so one ~ sibling selector can light every star up to the pick. Put the radios back in reading order, and that same selector lights the wrong half of the row.
Item 05 marks a chosen card with a border, but the other common answer is to press the card into its own background with two shadows — the neumorphism design kit builds whole sections out of exactly that pair.
What's in the zip
The password below opens a zip where every demo's autoplay script — the four to six lines each index.html uses to flip checked on a loop — has already been stripped back out. What's left is the click-driven version only: 8zg24hjv, typed exactly as it reads here.
Accessibility
What a screen reader hears
Because the real input stays in the document rather than getting deleted, a screen reader announces "checkbox, checked" or "radio button, 2 of 3" the same way it would for an unstyled form. Tab, Space, and the arrow keys move and flip the value without any extra script. All nine carry their own :focus-visible ring on the shape sitting next to the input — item 06's ring sits on its checkbox box, not on the strikethrough itself. Item 08 is also the one built differently under the hood — its label sits right after its own input in the markup. That's why the rule reaches it with + rather than the ~ every other item uses:
.sr-input:focus-visible + .star .star__icon {
box-shadow: 0 0 0 3px rgba($subject-blue, .35);
}
Reduced motion
prefers-reduced-motion: reduce drops every transition on this page to a 1ms duration, so a checked box or a picked radio lands on its final look immediately instead of animating there. Item 02 is the one exception — its bounce runs on a @keyframes animation rather than a transition. Reduced motion switches the animation off outright and jumps straight to the checked end state instead of just speeding it up. Either way, nothing about the checked state itself changes, only how fast — or whether — it arrives:
@media (prefers-reduced-motion: reduce) {
.box, .box__path { transition-duration: 1ms !important; }
}
MDN's guide to :focus-visible covers when browsers draw that ring instead of the default outline. The rest of this site's form and button patterns sit in the CSS category, and what's free to read here versus what's zipped up is on the about page.
FAQ
Should I use a checkbox or a css radio button style for a custom control like this?
Reach for a checkbox when one option flips on and off by itself, and a radio when picking one option has to clear whichever one was picked before. Item 04 is wired as a checkbox even though it looks like a switch, so several of them can sit on at once — a real radio group could never do that. When nothing has to stay on afterward and only the instant of the press matters, the CSS button click feedback effects are the nine to copy instead.
Do I need a <label>, or is aria-label enough?
aria-label alone covers a swatch or a star where the shape already says what it means. But anything carrying its own sentence — item 01's terms line, for instance — reads better wrapped in a <label>, since that pulls the whole sentence into the clickable area instead of only the small box.
The :checked selector doesn't seem to fire in my React version — why not?
The compiled CSS Module class still ships a working :checked rule. What's missing is usually the checked prop itself, since React needs useState and onChange wired up before the input becomes a controlled one at all. The react/ folder in the zip has all nine built that way already, ready to copy the pattern from.








