08スクロール到達で開く同意ゲート
規約ボックスを最後まで下げると上の進捗バーが満ち、それまで薄かった同意ボタンが色を得て少しだけ大きくなります。
使いどころ最後まで読ませる規約同意、必須告知の確認ステップ
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>当サイトはサービスの提供に必要な必須クッキーを使用します。</p>
<p>統計クッキーは訪問数と滞在時間を匿名で収集します。</p>
<p>広告クッキーは使用せず、第三者に情報を販売することもありません。</p>
<p>保存されたクッキーはブラウザの設定でいつでも削除できます。</p>
<p>同意は12か月間有効で、フッターのリンクからいつでも変更できます。</p>
<p>最後までスクロールすると下の同意ボタンが有効になります。</p>
</div>
</div>
<button class="sg__btn" type="button" disabled>
<span class="sg__face sg__face--off">同意して続ける</span>
<span class="sg__face sg__face--on" aria-hidden="true">同意して続ける</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 = '同意済み';
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 スクロール到達で開く同意ゲート — 計測 累積面積 26.667% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · クッキー同意バナーUI 9選 — コピペで使う同意管理コード
このデモが属する記事
クッキー同意バナーUI 9選 — コピペで使う同意管理コードクッキー同意バナーとは、サイトがCookieを使うことを訪問者に知らせ、その選択を受け取るUIで、ここにある9つはバナー・パネル・モーダル・トーストのすべてを実際に押せる状態で作ったものです。そのうち1つは、規約を最後まで読まないとボタンが この記事のデモをすべて見る


