08Agree-to-all switch that lights the items below in turn
Turning on agree to all draws the four check marks below one after another, and switching any single item off quietly clears only the master switch.
Best forTerms blocks before payment, required and optional consent at signup
stroke-dashoffsetanimation-delayindeterminate

<main class="stage">
<section class="ta is-demo" id="ta" aria-label="Terms agreement block">
<div class="ta__all">
<span class="ta__ic" aria-hidden="true"><svg viewBox="0 0 256 256" fill="currentColor"><path d="M216,48V208a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V48a8,8,0,0,1,8-8H208A8,8,0,0,1,216,48Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM224,48V208a16,16,0,0,1-16,16H48a16,16,0,0,1-16-16V48A16,16,0,0,1,48,32H208A16,16,0,0,1,224,48ZM208,208V48H48V208H208Z"/></svg></span>
<label class="ta__allText" for="ta-all">Agree to all</label>
<input class="ta__switch" type="checkbox" id="ta-all">
</div>
<ul class="ta__list">
<li class="ta__row">
<input class="ta__box" type="checkbox" id="ta-1">
<label class="ta__mark" for="ta-1" aria-hidden="true"><svg viewBox="0 0 24 24" aria-hidden="true"><polyline class="ta__tick" points="5 12.5 10 17 19 7" pathLength="1" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></label>
<label class="ta__text" for="ta-1">Terms of service (required)</label>
</li>
<li class="ta__row">
<input class="ta__box" type="checkbox" id="ta-2">
<label class="ta__mark" for="ta-2" aria-hidden="true"><svg viewBox="0 0 24 24" aria-hidden="true"><polyline class="ta__tick" points="5 12.5 10 17 19 7" pathLength="1" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></label>
<label class="ta__text" for="ta-2">Personal data collection (required)</label>
</li>
<li class="ta__row">
<input class="ta__box" type="checkbox" id="ta-3">
<label class="ta__mark" for="ta-3" aria-hidden="true"><svg viewBox="0 0 24 24" aria-hidden="true"><polyline class="ta__tick" points="5 12.5 10 17 19 7" pathLength="1" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></label>
<label class="ta__text" for="ta-3">Payment provider terms (required)</label>
</li>
<li class="ta__row">
<input class="ta__box" type="checkbox" id="ta-4">
<label class="ta__mark" for="ta-4" aria-hidden="true"><svg viewBox="0 0 24 24" aria-hidden="true"><polyline class="ta__tick" points="5 12.5 10 17 19 7" pathLength="1" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></label>
<label class="ta__text" for="ta-4">Marketing emails (optional)</label>
</li>
</ul>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 전체 동의를 켜면 아래 네 칸의 체크 표시가 stroke-dashoffset 으로 차례차례 그려진다.
// 시차는 animation-delay 가 주고, 지연 구간을 채우는 건 animation-fill-mode: backwards 다
// — 없으면 첫 프레임에 네 개가 다 그려진 채로 한 번 번쩍인다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-orange);
.ta {
box-sizing: border-box;
width: min(300px, 92vw);
padding: $sp-3;
border-radius: $r-card;
background: $subject-cream;
color: $color;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ta * { box-sizing: border-box; }
// ── 전체 동의 줄 ──
.ta__all {
display: flex;
align-items: center;
gap: $sp-2;
padding: 0 0 $sp-3;
border-bottom: 1px solid rgba(23, 20, 26, .12);
}
.ta__ic {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
color: $color;
}
.ta__ic svg { width: 100%; height: 100%; }
.ta__allText {
margin-right: auto;
font-size: 13px;
font-weight: 700;
line-height: 1.3;
cursor: pointer;
}
.ta__switch {
position: relative;
flex: 0 0 auto;
width: 30px;
height: 16px;
margin: 0;
padding: 0;
border: 0;
border-radius: $r-pill;
background-color: rgba(23, 20, 26, .2);
appearance: none;
-webkit-appearance: none;
cursor: pointer;
transition: background-color $dur-quick $easing;
&::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: $subject-cream;
transition: transform $dur-quick $ease-spring;
}
&:checked { background-color: $color; }
&:checked::after { transform: translateX(14px); }
// 일부만 켜진 상태 — 전체 동의는 풀렸지만 아래는 남아 있다
&:indeterminate { background-color: rgba(23, 20, 26, .45); }
&:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
}
// ── 약관 네 줄 ──
.ta__list {
display: grid;
gap: $sp-2;
margin: $sp-3 0 0;
padding: 0;
list-style: none;
}
.ta__row {
position: relative;
display: flex;
align-items: center;
gap: $sp-2;
}
.ta__row:nth-child(1) { --i: 0; }
.ta__row:nth-child(2) { --i: 1; }
.ta__row:nth-child(3) { --i: 2; }
.ta__row:nth-child(4) { --i: 3; }
.ta__box {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
}
.ta__mark {
position: relative;
flex: 0 0 auto;
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
border: 2px solid rgba(23, 20, 26, .26);
border-radius: $r-xs;
color: $subject-cream;
cursor: pointer;
// 칸이 차는 것은 scale — 켤 때는 한 프레임에 차고, 끌 때는 천천히 줄어든다.
// 투명도가 아니라 크기라서 중간 프레임에 반투명 유령이 안 생긴다
&::before {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
background-color: $color;
transform: scale(0);
transition: transform $dur-quick $easing;
transition-delay: calc(var(--i, 0) * 60ms);
}
}
.ta__mark svg {
position: relative;
z-index: 1;
width: 14px;
height: 14px;
}
// 체크는 폴리라인 한 줄이다 — 길이를 1 로 정규화해 두면 dashoffset 1 → 0 이 곧 「왼쪽부터 그린다」
.ta__tick {
stroke-dasharray: 1;
stroke-dashoffset: 1;
transition: stroke-dashoffset $dur-base $easing;
transition-delay: calc(var(--i, 0) * 60ms);
}
.ta__box:checked ~ .ta__mark::before { transform: scale(1); }
.ta__box:checked ~ .ta__mark .ta__tick { stroke-dashoffset: 0; }
.ta__box:focus-visible ~ .ta__mark {
outline: 2px solid $color;
outline-offset: 2px;
}
.ta__text {
font-size: 11px;
font-weight: 500;
line-height: 1.4;
cursor: pointer;
}
// ── 격자 미리보기 루프 ──
// 켜짐(6% 부터 60ms 씩)은 한 프레임에 톡, 꺼짐(46~80%)은 여덟 프레임에 걸쳐 천천히.
// 그래야 한 프레임에 가장 크게 달라지는 장면이 「차례로 켜지는 중」 이지 「꺼지는 중」 이 아니다.
// 구간마다 다른 이징이 필요하므로 타이밍 함수를 키프레임 안에 적는다
@keyframes taFill {
0% { transform: scale(0); animation-timing-function: steps(1, end); }
6% { transform: scale(0); animation-timing-function: $ease-pop; }
10% { transform: scale(1); animation-timing-function: steps(1, end); }
46% { transform: scale(1); animation-timing-function: linear; }
80%, 100% { transform: scale(0); }
}
@keyframes taDraw {
0%, 10% { stroke-dashoffset: 1; }
24%, 46% { stroke-dashoffset: 0; }
66%, 100% { stroke-dashoffset: 1; }
}
@keyframes taSwitchOn {
0% { background-color: rgba(23, 20, 26, .2); }
2%, 70% { background-color: $color; }
84%, 100% { background-color: rgba(23, 20, 26, .2); }
}
@keyframes taThumb {
0%, 2% { transform: translateX(0); }
10%, 70% { transform: translateX(14px); }
80%, 100% { transform: translateX(0); }
}
.ta.is-demo {
.ta__switch {
animation-name: taSwitchOn;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ta__switch::after {
animation-name: taThumb;
animation-duration: $duration;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.ta__mark::before {
animation-name: taFill;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i, 0) * 60ms);
}
.ta__tick {
animation-name: taDraw;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i, 0) * 60ms);
}
}
@media (max-width: 340px) {
.ta { padding: $sp-2; }
.ta__all { padding-bottom: $sp-2; }
.ta__list { gap: $sp-1; margin-top: $sp-2; }
.ta__allText { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.ta__switch,
.ta__switch::after,
.ta__tick,
.ta__mark::before { transition: none !important; }
.ta.is-demo .ta__switch,
.ta.is-demo .ta__switch::after,
.ta.is-demo .ta__mark::before,
.ta.is-demo .ta__tick { animation: none !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);
}
.ta {
box-sizing: border-box;
width: min(300px, 92vw);
padding: 12px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ta * {
box-sizing: border-box;
}
.ta__all {
display: flex;
align-items: center;
gap: 8px;
padding: 0 0 12px;
border-bottom: 1px solid rgba(23, 20, 26, 0.12);
}
.ta__ic {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
color: #17141a;
}
.ta__ic svg {
width: 100%;
height: 100%;
}
.ta__allText {
margin-right: auto;
font-size: 13px;
font-weight: 700;
line-height: 1.3;
cursor: pointer;
}
.ta__switch {
position: relative;
flex: 0 0 auto;
width: 30px;
height: 16px;
margin: 0;
padding: 0;
border: 0;
border-radius: 999px;
background-color: rgba(23, 20, 26, 0.2);
appearance: none;
-webkit-appearance: none;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.ta__switch::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff7e6;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ta__switch:checked {
background-color: #17141a;
}
.ta__switch:checked::after {
transform: translateX(14px);
}
.ta__switch:indeterminate {
background-color: rgba(23, 20, 26, 0.45);
}
.ta__switch:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.ta__list {
display: grid;
gap: 8px;
margin: 12px 0 0;
padding: 0;
list-style: none;
}
.ta__row {
position: relative;
display: flex;
align-items: center;
gap: 8px;
}
.ta__row:nth-child(1) {
--i: 0;
}
.ta__row:nth-child(2) {
--i: 1;
}
.ta__row:nth-child(3) {
--i: 2;
}
.ta__row:nth-child(4) {
--i: 3;
}
.ta__box {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
}
.ta__mark {
position: relative;
flex: 0 0 auto;
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
border: 2px solid rgba(23, 20, 26, 0.26);
border-radius: 4px;
color: #fff7e6;
cursor: pointer;
}
.ta__mark::before {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
background-color: #17141a;
transform: scale(0);
transition: transform 220ms ease-out;
transition-delay: calc(var(--i, 0) * 60ms);
}
.ta__mark svg {
position: relative;
z-index: 1;
width: 14px;
height: 14px;
}
.ta__tick {
stroke-dasharray: 1;
stroke-dashoffset: 1;
transition: stroke-dashoffset 300ms ease-out;
transition-delay: calc(var(--i, 0) * 60ms);
}
.ta__box:checked ~ .ta__mark::before {
transform: scale(1);
}
.ta__box:checked ~ .ta__mark .ta__tick {
stroke-dashoffset: 0;
}
.ta__box:focus-visible ~ .ta__mark {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.ta__text {
font-size: 11px;
font-weight: 500;
line-height: 1.4;
cursor: pointer;
}
@keyframes taFill {
0% {
transform: scale(0);
animation-timing-function: steps(1, end);
}
6% {
transform: scale(0);
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
}
10% {
transform: scale(1);
animation-timing-function: steps(1, end);
}
46% {
transform: scale(1);
animation-timing-function: linear;
}
80%, 100% {
transform: scale(0);
}
}
@keyframes taDraw {
0%, 10% {
stroke-dashoffset: 1;
}
24%, 46% {
stroke-dashoffset: 0;
}
66%, 100% {
stroke-dashoffset: 1;
}
}
@keyframes taSwitchOn {
0% {
background-color: rgba(23, 20, 26, 0.2);
}
2%, 70% {
background-color: #17141a;
}
84%, 100% {
background-color: rgba(23, 20, 26, 0.2);
}
}
@keyframes taThumb {
0%, 2% {
transform: translateX(0);
}
10%, 70% {
transform: translateX(14px);
}
80%, 100% {
transform: translateX(0);
}
}
.ta.is-demo .ta__switch {
animation-name: taSwitchOn;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ta.is-demo .ta__switch::after {
animation-name: taThumb;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ta.is-demo .ta__mark::before {
animation-name: taFill;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i, 0) * 60ms);
}
.ta.is-demo .ta__tick {
animation-name: taDraw;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i, 0) * 60ms);
}
@media (max-width: 340px) {
.ta {
padding: 8px;
}
.ta__all {
padding-bottom: 8px;
}
.ta__list {
gap: 4px;
margin-top: 8px;
}
.ta__allText {
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.ta__switch,
.ta__switch::after,
.ta__tick,
.ta__mark::before {
transition: none !important;
}
.ta.is-demo .ta__switch,
.ta.is-demo .ta__switch::after,
.ta.is-demo .ta__mark::before,
.ta.is-demo .ta__tick {
animation: none !important;
}
}// 전체 동의는 아래 네 칸을 한꺼번에 켜고, 한 칸만 꺼지면 자기만 조용히 풀린다.
// 「몇 개는 켜져 있다」 는 상태는 indeterminate 가 그대로 갖고 있다 — 별도 클래스가 필요 없다.
var ta = document.getElementById('ta');
var master = document.getElementById('ta-all');
var boxes = ta.querySelectorAll('.ta__box');
function sync() {
var on = 0;
for (var i = 0; i < boxes.length; i++) { if (boxes[i].checked) { on++; } }
master.checked = on === boxes.length;
master.indeterminate = on > 0 && on < boxes.length;
}
master.addEventListener('change', function () {
ta.classList.remove('is-demo');
for (var i = 0; i < boxes.length; i++) { boxes[i].checked = master.checked; }
master.indeterminate = false;
});
for (var n = 0; n < boxes.length; n++) {
boxes[n].addEventListener('change', function () {
ta.classList.remove('is-demo');
sync();
});
}import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TermsAgreeAllCascade.module.scss";
export type TermsAgreeAllCascadeProps = {
allLabel?: string;
items?: string[];
groupLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type RowVars = CSSProperties & { "--i"?: number };
// ambient 가 얇아 DOM 요소 타입을 못 쓴다 — 필요한 꼴만 로컬 타입으로 선언
type Toggle = { indeterminate: boolean };
function CheckSquareIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M216,48V208a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V48a8,8,0,0,1,8-8H208A8,8,0,0,1,216,48Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM224,48V208a16,16,0,0,1-16,16H48a16,16,0,0,1-16-16V48A16,16,0,0,1,48,32H208A16,16,0,0,1,224,48ZM208,208V48H48V208H208Z"/></svg>
);
}
export default function TermsAgreeAllCascade({
allLabel = "전체 동의",
items = [
"이용약관 동의 (필수)",
"개인정보 수집 (필수)",
"결제 대행 서비스 (필수)",
"마케팅 정보 수신 (선택)",
],
groupLabel = "약관 동의 묶음",
demo = true,
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: TermsAgreeAllCascadeProps) {
const [checked, setChecked] = useState<boolean[]>(items.map(() => false));
const [live, setLive] = useState(demo);
const masterRef = useRef<Toggle>(null);
const on = checked.filter(Boolean).length;
const all = on === items.length && on > 0;
// 「몇 개는 켜져 있다」 는 상태는 속성으로만 있다 — 렌더 뒤에 직접 놓는다
useEffect(() => {
const el = masterRef.current;
if (!el) return;
el.indeterminate = on > 0 && on < items.length;
}, [on, items.length]);
function toggleAll(next: boolean) {
setLive(false);
setChecked(items.map(() => next));
}
function toggleOne(index: number, next: boolean) {
setLive(false);
setChecked((prev) => prev.map((was, n) => (n === index ? next : was)));
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [styles.ta, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<div className={styles.all}>
<span className={styles.ic} aria-hidden="true"><CheckSquareIcon /></span>
<label className={styles.allText} htmlFor="ta-all">{allLabel}</label>
<input
className={styles.switch}
type="checkbox"
id="ta-all"
ref={masterRef}
checked={all}
onChange={(event: { target: { checked: boolean } }) => toggleAll(event.target.checked)}
/>
</div>
<ul className={styles.list}>
{items.map((text, n) => {
const rowVars: RowVars = { "--i": n };
const id = `ta-${n + 1}`;
return (
<li className={styles.row} style={rowVars} key={text}>
<input
className={styles.box}
type="checkbox"
id={id}
checked={checked[n]}
onChange={(event: { target: { checked: boolean } }) => toggleOne(n, event.target.checked)}
/>
<label className={styles.mark} htmlFor={id} aria-hidden="true">
<svg viewBox="0 0 24 24" aria-hidden="true">
<polyline
className={styles.tick}
points="5 12.5 10 17 19 7"
pathLength="1"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</label>
<label className={styles.text} htmlFor={id}>{text}</label>
</li>
);
})}
</ul>
</section>
);
}08 Agree-to-all switch that lights the items below in turn — Measured area 1.8% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · Checkout Page Design — 9 Cart Patterns, No Library
The post this demo lives in
Checkout Page Design — 9 Cart Patterns, No LibraryCheckout page design covers every screen between "I want this" and the pay button. These nine patterns are plain HTML, S See all demos in this post
If you like this effect - similar demos
Inputs05 Select-all cascade toggle — when the parent box's select-all value turns on, each child box's checked value flips in order with a short staggerCSS Custom Checkbox: 9 Interaction Patterns
Inputs07 Live criteria-ratio reflection — the strength value follows the ratio of condition values that turn true out of the totalPassword Strength Meter UI: 9 Calculation Patterns
Inputs04 Mask value toggle — when the masking value turns on, all but the first few digit values switch to a symbol valueCredit Card Input UI: 9 Patterns