08Scroll-to-end consent gate
The progress bar above fills only when the terms box is scrolled to its end, and the consent button that was dimmed until then takes on color and grows slightly.
Best forTerms consent that must be read to the end, and mandatory notice confirmation steps
scaleX 0→1트랙만 translateYradius 12

<main class="stage">
<div class="sg is-demo">
<div class="sg__bar" aria-hidden="true"><i class="sg__fill"></i></div>
<div class="sg__box">
<div class="sg__track">
<p>This site uses only the cookies strictly needed to run the service.</p>
<p>Analytics cookies collect visit counts and time on page anonymously.</p>
<p>We use no advertising cookies and never sell your data to third parties.</p>
<p>You can clear stored cookies at any time in your browser settings.</p>
<p>Your choice lasts 12 months and can be changed from the footer link.</p>
<p>Scroll to the end and the consent button below turns on.</p>
</div>
</div>
<button class="sg__btn" type="button" disabled>
<span class="sg__face sg__face--off">Agree and continue</span>
<span class="sg__face sg__face--on" aria-hidden="true">Agree and continue</span>
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.sg {
--p: 0;
box-sizing: border-box;
width: clamp(224px, 78vw, 320px);
display: grid;
gap: $sp-3;
padding: clamp(12px, 3vw, 16px);
border: 1px solid rgba(23, 20, 26, .10);
border-radius: $r-card;
background: $stage-paper;
box-shadow: 0 2px 8px rgba(23, 20, 26, .10);
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// ── 진행 막대: 트랙 + scaleX 로 차는 바 (width 금지) ──
.sg__bar {
height: 4px;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
overflow: hidden;
}
.sg__fill {
display: block;
height: 100%;
border-radius: inherit;
background: $color;
transform: scaleX(var(--p));
transform-origin: left; // 규칙8: 스크롤 동의 막대 좌측 채움
transition: transform $dur-quick $ease-out;
}
// ── 진짜 스크롤되는 약관 상자. overflow 걸린 요소 자체엔 transform 을 걸지 않는다 (051 실측) ──
.sg__box {
height: clamp(96px, 30vh, 132px);
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
border-radius: $r-sm;
background: rgba(23, 20, 26, .03);
padding: $sp-2;
}
.sg__track {
display: grid;
gap: $sp-2;
}
.sg__track p {
margin: 0;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.5;
color: rgba($stage-ink, .72);
word-break: keep-all; // 한국어가 낱말 가운데서 끊기지 않게
overflow-wrap: anywhere;
}
// ── 동의 버튼: 꺼진 얼굴 위에 켜진 얼굴을 opacity 로 덮는다 (색도 글자색도 같이 바뀐다) ──
.sg__btn {
position: relative;
overflow: hidden;
border: 0;
border-radius: $r-control;
background: rgba(23, 20, 26, .06);
padding: $sp-2 $sp-4;
font-family: inherit;
font-size: clamp(11px, 2.8vw, 12px);
font-weight: 700;
cursor: not-allowed;
}
.sg__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.sg__face--off {
display: block;
color: rgba(23, 20, 26, .40);
}
.sg__face--on {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: inherit;
background: $subject-blue-deep;
color: $subject-cream;
opacity: 0;
transition: opacity $dur-quick $ease-out;
}
.sg.is-ready .sg__btn { cursor: pointer; }
.sg.is-ready .sg__face--on { opacity: 1; }
.sg.is-ready .sg__btn { animation: sg-pop $dur-base $ease-pop 1; }
// ── 자동 루프: 스크롤은 건드리지 않고 안쪽 트랙만 위로 흘린다. 막대·버튼이 같은 박자 ──
.sg.is-demo .sg__track { animation: sg-roll $duration $easing infinite; }
.sg.is-demo .sg__fill { animation: sg-fill $duration $easing infinite; }
.sg.is-demo .sg__face--on { animation: sg-on $duration $easing infinite; }
.sg.is-demo .sg__btn { animation: sg-beat $duration $easing infinite; }
@keyframes sg-roll {
0% { transform: translateY(0); }
50% { transform: translateY(-40%); }
100% { transform: translateY(0); }
}
@keyframes sg-fill {
0% { transform: scaleX(0); }
50% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
@keyframes sg-on {
0% { opacity: 0; }
40% { opacity: 0; }
52% { opacity: 1; }
72% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes sg-beat {
0% { transform: scale(1); }
44% { transform: scale(1); }
52% { transform: scale(1.04); }
62% { transform: scale(1); }
100% { transform: scale(1); }
}
@keyframes sg-pop {
0% { transform: scale(1); }
50% { transform: scale(1.04); }
100% { transform: scale(1); }
}
@media (max-width: 340px) {
.sg { gap: $sp-2; width: 88vw; padding: $sp-2; }
.sg__box { height: 76px; }
.sg__btn { padding: $sp-2 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.sg.is-demo .sg__track,
.sg.is-demo .sg__fill,
.sg.is-demo .sg__face--on,
.sg.is-demo .sg__btn,
.sg.is-ready .sg__btn { animation: none !important; }
// 루프는 끄고 "끝까지 읽은 뒤" 상태는 남긴다
.sg.is-demo .sg__fill { transform: scaleX(1); }
.sg.is-demo .sg__face--on { opacity: 1; }
.sg__fill, .sg__face--on { transition: none; }
}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);
}
.sg {
--p: 0;
box-sizing: border-box;
width: clamp(224px, 78vw, 320px);
display: grid;
gap: 12px;
padding: clamp(12px, 3vw, 16px);
border: 1px solid rgba(23, 20, 26, 0.1);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 2px 8px rgba(23, 20, 26, 0.1);
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sg__bar {
height: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
overflow: hidden;
}
.sg__fill {
display: block;
height: 100%;
border-radius: inherit;
background: #2f6df6;
transform: scaleX(var(--p));
transform-origin: left;
transition: transform 220ms ease-out;
}
.sg__box {
height: clamp(96px, 30vh, 132px);
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
border-radius: 8px;
background: rgba(23, 20, 26, 0.03);
padding: 8px;
}
.sg__track {
display: grid;
gap: 8px;
}
.sg__track p {
margin: 0;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.5;
color: rgba(23, 20, 26, 0.72);
word-break: keep-all;
overflow-wrap: anywhere;
}
.sg__btn {
position: relative;
overflow: hidden;
border: 0;
border-radius: 12px;
background: rgba(23, 20, 26, 0.06);
padding: 8px 16px;
font-family: inherit;
font-size: clamp(11px, 2.8vw, 12px);
font-weight: 700;
cursor: not-allowed;
}
.sg__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.sg__face--off {
display: block;
color: rgba(23, 20, 26, 0.4);
}
.sg__face--on {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: inherit;
background: #0a4fe8;
color: #fff7e6;
opacity: 0;
transition: opacity 220ms ease-out;
}
.sg.is-ready .sg__btn {
cursor: pointer;
}
.sg.is-ready .sg__face--on {
opacity: 1;
}
.sg.is-ready .sg__btn {
animation: sg-pop 300ms cubic-bezier(0.2, 1.4, 0.4, 1) 1;
}
.sg.is-demo .sg__track {
animation: sg-roll 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.sg.is-demo .sg__fill {
animation: sg-fill 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.sg.is-demo .sg__face--on {
animation: sg-on 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.sg.is-demo .sg__btn {
animation: sg-beat 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes sg-roll {
0% {
transform: translateY(0);
}
50% {
transform: translateY(-40%);
}
100% {
transform: translateY(0);
}
}
@keyframes sg-fill {
0% {
transform: scaleX(0);
}
50% {
transform: scaleX(1);
}
100% {
transform: scaleX(0);
}
}
@keyframes sg-on {
0% {
opacity: 0;
}
40% {
opacity: 0;
}
52% {
opacity: 1;
}
72% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes sg-beat {
0% {
transform: scale(1);
}
44% {
transform: scale(1);
}
52% {
transform: scale(1.04);
}
62% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@keyframes sg-pop {
0% {
transform: scale(1);
}
50% {
transform: scale(1.04);
}
100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.sg {
gap: 8px;
width: 88vw;
padding: 8px;
}
.sg__box {
height: 76px;
}
.sg__btn {
padding: 8px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.sg.is-demo .sg__track,
.sg.is-demo .sg__fill,
.sg.is-demo .sg__face--on,
.sg.is-demo .sg__btn,
.sg.is-ready .sg__btn {
animation: none !important;
}
.sg.is-demo .sg__fill {
transform: scaleX(1);
}
.sg.is-demo .sg__face--on {
opacity: 1;
}
.sg__fill, .sg__face--on {
transition: none;
}
}// 실제 동작: 약관 상자의 진짜 scrollTop 으로 진행률을 갱신하고, 끝에 닿아야 버튼이 켜진다.
var sg = document.querySelector('.sg');
var sgBox = sg.querySelector('.sg__box');
var sgBtn = sg.querySelector('.sg__btn');
var sgFaces = sg.querySelectorAll('.sg__face');
var SG_DONE = 'Agreed';
function sgSetLabel(t) {
for (var i = 0; i < sgFaces.length; i++) { sgFaces[i].textContent = t; }
}
function sgReady() {
if (!sgBtn.disabled) { return; }
sgBtn.disabled = false;
sg.classList.add('is-ready');
}
sgBox.addEventListener('scroll', function () {
sg.classList.remove('is-demo'); // 사람이 스크롤하면 자동 루프를 끈다
var max = sgBox.scrollHeight - sgBox.clientHeight;
var p = max > 0 ? sgBox.scrollTop / max : 1;
sg.style.setProperty('--p', String(p < 1 ? p : 1));
if (sgBox.scrollTop + sgBox.clientHeight >= sgBox.scrollHeight - 4) { sgReady(); }
});
sgBtn.addEventListener('click', function () {
sg.classList.remove('is-demo');
sgSetLabel(SG_DONE);
try { localStorage.setItem('gd-consent-08', '1'); } catch (e) {}
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ScrollConsentGate.module.scss";
export type ScrollConsentGateProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--p"?: number;
};
const KEY = "gd-consent-08";
const LABEL = "Agree and continue";
const DONE = "Agreed";
const TERMS = [
"This site uses only the cookies strictly needed to run the service.",
"Analytics cookies collect visit counts and time on page anonymously.",
"We use no advertising cookies and never sell your data to third parties.",
"You can clear stored cookies at any time in your browser settings.",
"Your choice lasts 12 months and can be changed from the footer link.",
"Scroll to the end and the consent button below turns on.",
];
export default function ScrollConsentGate({
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ScrollConsentGateProps) {
const boxRef = useRef<HTMLDivElement>(null);
const [demo, setDemo] = useState(true);
const [ready, setReady] = useState(false);
const [agreed, setAgreed] = useState(false);
const [p, setP] = useState(0);
function onScroll() {
const box = boxRef.current;
if (!box) {
return;
}
setDemo(false); // 사람이 스크롤하면 자동 루프를 끈다
const max = box.scrollHeight - box.clientHeight;
const next = max > 0 ? box.scrollTop / max : 1;
setP(next < 1 ? next : 1);
if (box.scrollTop + box.clientHeight >= box.scrollHeight - 4) {
setReady(true);
}
}
function agree() {
setDemo(false);
setAgreed(true);
try {
localStorage.setItem(KEY, "1");
} catch {
/* 무시 */
}
}
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--p": p,
};
const label = agreed ? DONE : LABEL;
return (
<div
className={`${styles.sg} ${demo ? styles.isDemo : ""} ${ready ? styles.isReady : ""}`}
style={vars}
>
<div className={styles.bar} aria-hidden="true">
<i className={styles.fill} />
</div>
<div className={styles.box} ref={boxRef} onScroll={onScroll}>
<div className={styles.track}>
{TERMS.map((t) => (
<p key={t}>{t}</p>
))}
</div>
</div>
<button type="button" className={styles.btn} disabled={!ready} onClick={agree}>
<span className={styles.faceOff}>{label}</span>
<span className={styles.faceOn} aria-hidden="true">
{label}
</span>
</button>
</div>
);
}08 Scroll-to-end consent gate — Measured area 26.667% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Cookie Consent Banner UI — 9 Patterns to Paste In
The post this demo lives in
Cookie Consent Banner UI — 9 Patterns to Paste InA cookie consent banner ui is the strip or card that tells visitors a site stores cookies and records their choice. Thes See all demos in this post


