08すべて同意が下を順に点ける規約のまとまり
すべて同意を入れると下の四項目のチェックが順に描かれ、そのうち一つを外すとすべて同意だけが静かに外れます。
使いどころ決済前の規約同意のまとまり、会員登録の必須・任意同意
stroke-dashoffsetanimation-delayindeterminate

<main class="stage">
<section class="ta is-demo" id="ta" aria-label="規約同意のまとまり">
<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">すべて同意</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">利用規約への同意 (必須)</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">個人情報の収集 (必須)</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">決済代行サービス (必須)</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">マーケティング情報の受信 (任意)</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 すべて同意が下を順に点ける規約のまとまり — 計測 累積面積 1.8% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · 決済画面 デザイン 9選 — カートから支払いまでコピペOK
このデモが属する記事
決済画面 デザイン 9選 — カートから支払いまでコピペOK決済画面 デザインとは、買い物かごに入れた人を「これが欲しい」から支払いボタンの手前まで案内する一連の画面のことです。数量を変えると合計が追いかける行、クーポンで割り込む割引行、選んだ支払い方法の下に開く詳細枠。九つともHTML・SCSS・ この記事のデモをすべて見る


