09同意チェックボックス — 規約のスクロール進捗を測り有効化の時点を決めるUI
規約一覧をスクロールした進捗を実際に測って読み終えたとみなせる地点を自分で計算し、その地点を過ぎて初めて同意チェックボックスが押せる状態に変わるUIです。
使いどころ利用規約同意モーダル、会員登録最後の同意ステップ
scrollTopaddEventListenersetAttribute

<main class="stage">
<section class="wrap">
<p class="eyebrow">利用規約への同意</p>
<div class="panel" id="panel" role="region" aria-label="規約の内容" tabindex="0">
<p>このプレースホルダー段落は実際の利用規約ではありません。</p>
<p>このプレースホルダー段落は実際の利用規約ではありません。</p>
<p>このプレースホルダー段落は実際の利用規約ではありません。</p>
<p>このプレースホルダー段落は実際の利用規約ではありません。</p>
<p>このプレースホルダー段落は実際の利用規約ではありません。</p>
<p>このプレースホルダー段落は実際の利用規約ではありません。</p>
<p>このプレースホルダー段落は実際の利用規約ではありません。</p>
<p>このプレースホルダー段落は実際の利用規約ではありません。</p>
</div>
<div class="consentRow">
<input type="checkbox" id="consent" class="checkbox" disabled aria-disabled="true">
<label for="consent" id="consentLabel" class="label">規約を最後まで確認しないと同意できません。</label>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-ink);
.wrap {
width: 100%;
max-width: 320px;
display: flex;
flex-direction: column;
align-items: stretch;
gap: $sp-3;
}
.eyebrow {
align-self: center;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba($subject-cream, .12);
color: $subject-cream;
font-weight: 500;
font-size: 11px;
letter-spacing: .04em;
}
.panel {
box-sizing: border-box;
height: 120px;
overflow-y: auto;
border-radius: $r-card;
border: 1px solid rgba($subject-cream, .18);
background: rgba($subject-cream, .06);
padding: $sp-3;
}
.panel p {
margin: 0 0 $sp-2;
color: $subject-cream;
font-size: 12px;
line-height: 1.6;
}
.panel p:last-child {
margin-bottom: 0;
}
.consentRow {
position: relative;
display: flex;
align-items: flex-start;
gap: $sp-2;
}
.consentRow::before {
content: "";
position: absolute;
inset: -$sp-2;
border-radius: $r-control;
background: rgba($color, .18);
opacity: 0;
animation: consentGlow $duration $easing infinite;
z-index: -1;
}
.checkbox {
margin-top: $sp-1;
width: 18px;
height: 18px;
accent-color: $color;
}
.checkbox:disabled {
opacity: .5;
}
.label {
margin: 0;
color: $subject-cream;
font-size: 13px;
line-height: 1.5;
text-align: left;
}
@keyframes consentGlow {
0%, 60% { opacity: 0; }
75%, 100% { opacity: .9; }
}
@media (max-width: 340px) {
.wrap { max-width: 280px; }
.panel { height: 100px; }
.panel p, .label { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.consentRow::before { animation: none; opacity: 0; }
}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);
}
.wrap {
width: 100%;
max-width: 320px;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.eyebrow {
align-self: center;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
font-weight: 500;
font-size: 11px;
letter-spacing: 0.04em;
}
.panel {
box-sizing: border-box;
height: 120px;
overflow-y: auto;
border-radius: 16px;
border: 1px solid rgba(255, 247, 230, 0.18);
background: rgba(255, 247, 230, 0.06);
padding: 12px;
}
.panel p {
margin: 0 0 8px;
color: #fff7e6;
font-size: 12px;
line-height: 1.6;
}
.panel p:last-child {
margin-bottom: 0;
}
.consentRow {
position: relative;
display: flex;
align-items: flex-start;
gap: 8px;
}
.consentRow::before {
content: "";
position: absolute;
inset: -8px;
border-radius: 12px;
background: rgba(10, 79, 232, 0.18);
opacity: 0;
animation: consentGlow 2s ease-out infinite;
z-index: -1;
}
.checkbox {
margin-top: 4px;
width: 18px;
height: 18px;
accent-color: #0a4fe8;
}
.checkbox:disabled {
opacity: 0.5;
}
.label {
margin: 0;
color: #fff7e6;
font-size: 13px;
line-height: 1.5;
text-align: left;
}
@keyframes consentGlow {
0%, 60% {
opacity: 0;
}
75%, 100% {
opacity: 0.9;
}
}
@media (max-width: 340px) {
.wrap {
max-width: 280px;
}
.panel {
height: 100px;
}
.panel p, .label {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.consentRow::before {
animation: none;
opacity: 0;
}
}// 동의 체크박스(09, 아이디어형) — 規約 패널의 실제 scroll 이벤트로 진행률(progress)을 구해
// 기준(SCROLL_THRESHOLD)을 넘으면 체크박스를 활성화한다. 자동 재생 루프는 panel.scrollTop 을
// 매 프레임 직접 대입할 뿐이고, 그 대입이 브라우저의 진짜 scroll 이벤트를 발생시키므로
// 실사용자 스크롤과 이 onScroll 하나의 경로를 그대로 통과한다 — 별도 분기 없음.
// 실키·실서버 요청 없음.
(function () {
var SCROLL_THRESHOLD = .7; // 변형: .7 → 1
var RAMP_MS = 1500;
var CYCLE_MS = 2000;
var DONE_TEXT = '規約を最後まで確認しました。';
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
var panel = document.getElementById('panel');
var checkbox = document.getElementById('consent');
var label = document.getElementById('consentLabel');
if (!panel || !checkbox || !label) return;
var lockedText = label.textContent;
var unlocked = false;
function onScroll() {
var maxScroll = panel.scrollHeight - panel.clientHeight;
var progress = maxScroll > 0 ? clamp(panel.scrollTop / maxScroll, 0, 1) : 1;
if (progress >= SCROLL_THRESHOLD && !unlocked) {
unlocked = true;
checkbox.disabled = false;
checkbox.setAttribute('aria-disabled', 'false');
label.textContent = DONE_TEXT;
} else if (progress < SCROLL_THRESHOLD && unlocked) {
unlocked = false;
checkbox.disabled = true;
checkbox.checked = false;
checkbox.setAttribute('aria-disabled', 'true');
label.textContent = lockedText;
}
}
panel.addEventListener('scroll', onScroll);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
panel.scrollTop = panel.scrollHeight;
onScroll();
return;
}
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
var maxScroll = panel.scrollHeight - panel.clientHeight;
panel.scrollTop = maxScroll * clamp(t / RAMP_MS, 0, 1);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./ConsentCheckbox.module.scss";
// 동의 체크박스(09, 아이디어형) — 약관 패널의 실제 scroll 이벤트로 진행률(progress)을 구해
// 기준(SCROLL_THRESHOLD)을 넘으면 체크박스를 활성화한다. 자동 재생 루프는 panel.scrollTop 을
// 매 프레임 직접 대입할 뿐이고, 그 대입이 브라우저의 진짜 scroll 이벤트를 발생시키므로
// 실사용자 스크롤과 이 onScroll 하나의 경로를 그대로 통과한다 — 별도 분기 없음.
// 실키·실서버 요청 없음.
const SCROLL_THRESHOLD = 0.7; // 변형: .7 → 1
const RAMP_MS = 1500;
const CYCLE_MS = 2000;
const LOCKED_TEXT = "약관을 끝까지 확인해야 동의할 수 있습니다.";
const DONE_TEXT = "약관을 끝까지 확인했습니다.";
const PLACEHOLDER_PARAGRAPHS = [0, 1, 2, 3, 4, 5, 6, 7];
function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v));
}
export default function ConsentCheckbox() {
const panelRef = useRef<HTMLDivElement>(null);
const checkboxRef = useRef<HTMLInputElement>(null);
const labelRef = useRef<HTMLLabelElement>(null);
useEffect(() => {
const panel = panelRef.current;
const checkbox = checkboxRef.current;
const label = labelRef.current;
if (!panel || !checkbox || !label) return;
let unlocked = false;
function onScroll() {
const maxScroll = panel!.scrollHeight - panel!.clientHeight;
const progress = maxScroll > 0 ? clamp(panel!.scrollTop / maxScroll, 0, 1) : 1;
if (progress >= SCROLL_THRESHOLD && !unlocked) {
unlocked = true;
checkbox!.disabled = false;
checkbox!.setAttribute("aria-disabled", "false");
label!.textContent = DONE_TEXT;
} else if (progress < SCROLL_THRESHOLD && unlocked) {
unlocked = false;
checkbox!.disabled = true;
checkbox!.checked = false;
checkbox!.setAttribute("aria-disabled", "true");
label!.textContent = LOCKED_TEXT;
}
}
panel.addEventListener("scroll", onScroll);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
panel.scrollTop = panel.scrollHeight;
onScroll();
return () => panel.removeEventListener("scroll", onScroll);
}
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
const maxScroll = panel!.scrollHeight - panel!.clientHeight;
panel!.scrollTop = maxScroll * clamp(t / RAMP_MS, 0, 1);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(raf);
panel.removeEventListener("scroll", onScroll);
};
}, []);
return (
<main className={styles.stage}>
<section className={styles.wrap}>
<p className={styles.eyebrow}>약관 동의</p>
<div className={styles.panel} ref={panelRef} role="region" aria-label="약관 내용" tabIndex={0}>
{PLACEHOLDER_PARAGRAPHS.map((i) => (
<p key={i}>이 자리 표시 문단은 실제 약관이 아닙니다.</p>
))}
</div>
<div className={styles.consentRow}>
<input
type="checkbox"
id="consent"
className={styles.checkbox}
ref={checkboxRef}
disabled
aria-disabled="true"
readOnly
/>
<label htmlFor="consent" ref={labelRef} className={styles.label}>
{LOCKED_TEXT}
</label>
</div>
</section>
</main>
);
}09 同意チェックボックス — 規約のスクロール進捗を測り有効化の時点を決めるUI — 計測 累積面積 15.528% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · ログイン 画面 デザイン9選 — 会員登録からセッション切れまで
このデモが属する記事
ログイン 画面 デザイン9選 — 会員登録からセッション切れまで「ログイン 画面 デザイン」を探すと出てくる多くの参考画像は、色や余白を真似るだけの静止した見本です。ここで扱う9つの画面はその手前に計算の層を1枚重ねます。入力の完成度で進捗表示が求め直され、残り有効時間が毎秒求め直されてリングの角度にな この記事のデモをすべて見る


