09Consent checkbox — a UI that gates activation by measured scroll progress through terms
Measuring how far the terms list has been scrolled works out the point that counts as having read it all, and only past that point does the consent checkbox switch into a clickable state.
Best forTerms-agreement modals, the final consent step of signup
scrollTopaddEventListenersetAttribute

<main class="stage">
<section class="wrap">
<p class="eyebrow">Terms agreement</p>
<div class="panel" id="panel" role="region" aria-label="Terms content" tabindex="0">
<p>This placeholder paragraph is not real terms text.</p>
<p>This placeholder paragraph is not real terms text.</p>
<p>This placeholder paragraph is not real terms text.</p>
<p>This placeholder paragraph is not real terms text.</p>
<p>This placeholder paragraph is not real terms text.</p>
<p>This placeholder paragraph is not real terms text.</p>
<p>This placeholder paragraph is not real terms text.</p>
<p>This placeholder paragraph is not real terms text.</p>
</div>
<div class="consentRow">
<input type="checkbox" id="consent" class="checkbox" disabled aria-disabled="true">
<label for="consent" id="consentLabel" class="label">You must read the terms to the end before you can agree.</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, 아이디어형) — Terms 패널의 실제 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 = 'You have read the terms to the end.';
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 Consent checkbox — a UI that gates activation by measured scroll progress through terms — Measured area 15.528% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 Login UI Design Patterns (Copy-Paste)
The post this demo lives in
9 Login UI Design Patterns (Copy-Paste)Nine login and signup patterns that compute: fill ratio refills a progress ring, remaining time recounts into an arc eve See all demos in this post


