09Consent-gated embed placeholder — a card that folds by a height computed from its notice text
The instant consent is marked, the height of the notice block is computed, and the block folds up by that exact height while the frame beneath unfolds by the same amount to reveal itself.
Best forEmbed zones on GDPR-facing landing pages, video/social sections that require cookie-notice consent
requestAnimationFrameoffsetHeightaria-expanded

<main class="stage">
<section class="panel">
<div class="card" id="card">
<div class="notice" id="notice">
<div class="noticeHead">
<span class="statusDot" id="statusDot" aria-hidden="true"></span>
<span class="noticeLead">This video loads from an external service.</span>
</div>
<p class="noticeSub">Once you agree, it loads right here.</p>
<button type="button" class="consentBtn" id="consentBtn" aria-expanded="true">Agree and load</button>
</div>
<div class="frame" id="frame" aria-hidden="true">
<canvas class="frameThumb" id="frameThumb" width="240" height="80"></canvas>
<div class="frameScrim"></div>
<div class="frameContent">
<span class="framePlay" aria-hidden="true"></span>
<span class="frameLabel">Video that plays after consent</span>
</div>
<div class="progressTrack">
<div class="progressBar" id="progressBar"></div>
</div>
</div>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.panel {
display: flex;
flex-direction: column;
align-items: center;
}
.card {
box-sizing: border-box;
width: 240px;
padding: $sp-4;
border-radius: $r-card;
background: $subject-cream;
display: flex;
flex-direction: column;
gap: $sp-3;
}
.notice {
overflow: hidden;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.noticeHead {
display: flex;
align-items: center;
gap: $sp-2;
}
.statusDot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: $r-pill;
background: $subject-mint;
opacity: .86;
}
.noticeLead {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 13px;
line-height: 1.4;
color: $subject-ink;
}
.noticeSub {
margin: 0;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 400;
font-size: 12px;
line-height: 1.4;
color: $subject-ink;
opacity: .78;
}
.consentBtn {
align-self: flex-start;
border: 0;
padding: $sp-2 $sp-4;
border-radius: $r-sm;
background: $subject-blue-deep;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 13px;
line-height: 1.3;
cursor: pointer;
}
.frame {
position: relative;
height: 0;
overflow: hidden;
border-radius: $r-sm;
}
.frameThumb {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.frameScrim {
position: absolute;
inset: 0;
background: rgba($stage-ink, .68);
}
.frameContent {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-2;
color: $subject-cream;
}
.framePlay {
width: 0;
height: 0;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-left: 14px solid $subject-cream;
}
.frameLabel {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 12px;
line-height: 1.3;
}
.progressTrack {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba($subject-cream, .24);
}
.progressBar {
height: 100%;
width: 100%;
transform-origin: left center; // 채움 막대가 시작 변에서 자라난다 (BD8 예외 대상)
transform: scaleX(0);
background: $subject-cream;
}
@media (max-width: 340px) {
.card { width: 200px; padding: $sp-3; }
.noticeLead { font-size: 12px; }
.noticeSub { font-size: 11px; }
.consentBtn { padding: $sp-1 $sp-3; font-size: 12px; }
.frameLabel { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.statusDot { opacity: .86; }
}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);
}
.panel {
display: flex;
flex-direction: column;
align-items: center;
}
.card {
box-sizing: border-box;
width: 240px;
padding: 16px;
border-radius: 16px;
background: #fff7e6;
display: flex;
flex-direction: column;
gap: 12px;
}
.notice {
overflow: hidden;
display: flex;
flex-direction: column;
gap: 8px;
}
.noticeHead {
display: flex;
align-items: center;
gap: 8px;
}
.statusDot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 999px;
background: #4cd4a6;
opacity: 0.86;
}
.noticeLead {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 13px;
line-height: 1.4;
color: #17141a;
}
.noticeSub {
margin: 0;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 400;
font-size: 12px;
line-height: 1.4;
color: #17141a;
opacity: 0.78;
}
.consentBtn {
align-self: flex-start;
border: 0;
padding: 8px 16px;
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 13px;
line-height: 1.3;
cursor: pointer;
}
.frame {
position: relative;
height: 0;
overflow: hidden;
border-radius: 8px;
}
.frameThumb {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.frameScrim {
position: absolute;
inset: 0;
background: rgba(23, 20, 26, 0.68);
}
.frameContent {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: #fff7e6;
}
.framePlay {
width: 0;
height: 0;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-left: 14px solid #fff7e6;
}
.frameLabel {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 12px;
line-height: 1.3;
}
.progressTrack {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 247, 230, 0.24);
}
.progressBar {
height: 100%;
width: 100%;
transform-origin: left center;
transform: scaleX(0);
background: #fff7e6;
}
@media (max-width: 340px) {
.card {
width: 200px;
padding: 12px;
}
.noticeLead {
font-size: 12px;
}
.noticeSub {
font-size: 11px;
}
.consentBtn {
padding: 4px 12px;
font-size: 12px;
}
.frameLabel {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.statusDot {
opacity: 0.86;
}
}// 동의 후 불러오는 임베드(09) — 안내 블록의 실측 높이를 접힘 진행도에 맞춰
// 그대로 프레임 높이로 넘긴다(계산 겹). 실키·실서버 요청 없음.
// 동의 버튼 클릭 시 즉시 전환(실사용자 리스너 있음).
(function () {
var cardEl = document.getElementById('card');
var noticeEl = document.getElementById('notice');
var frameEl = document.getElementById('frame');
var frameThumbEl = document.getElementById('frameThumb');
var statusDotEl = document.getElementById('statusDot');
var consentBtnEl = document.getElementById('consentBtn');
var progressBarEl = document.getElementById('progressBar');
if (!cardEl || !noticeEl || !frameEl || !frameThumbEl || !statusDotEl || !consentBtnEl || !progressBarEl) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var NARROW = window.matchMedia('(max-width: 340px)').matches;
var CYCLE_MS = 2000;
var CONSENT_AT = 700;
var CONSENT_FOLD_MS = 200;
var FRAME_BASE_H = NARROW ? 56 : 96;
var DOT_MS = 700;
function mulberry32(seed) {
var a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function drawSeedThumb(canvas, seed) {
var ctx = canvas.getContext('2d');
if (!ctx) return;
var rnd = mulberry32(seed);
var w = canvas.width, h = canvas.height;
var hues = [216, 32, 268, 168];
ctx.fillStyle = 'hsl(' + hues[Math.floor(rnd() * hues.length)] + ', 38%, 22%)';
ctx.fillRect(0, 0, w, h);
for (var i = 0; i < 6; i++) {
var cx = rnd() * w, cy = rnd() * h, r = (0.18 + rnd() * 0.3) * Math.min(w, h);
ctx.fillStyle = 'hsla(' + hues[Math.floor(rnd() * hues.length)] + ', ' + (44 + rnd() * 22).toFixed(1) + '%, ' + (38 + rnd() * 26).toFixed(1) + '%, .5)';
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill();
}
ctx.fillStyle = 'rgba(255, 247, 230, .06)';
for (var j = 0; j < h; j += 4) ctx.fillRect(0, j, w, 1);
}
function easeOutCubic(p) { return 1 - Math.pow(1 - p, 3); }
drawSeedThumb(frameThumbEl, 109);
var consented = false;
var consentTime = null;
var consentOverride = false;
var noticeH = 0;
var prevT = 0;
function enterConsent(t) {
consented = true;
consentTime = t;
consentOverride = false;
noticeH = noticeEl.offsetHeight;
consentBtnEl.setAttribute('aria-expanded', 'false');
}
consentBtnEl.addEventListener('click', function () {
consentOverride = true;
});
function resetToIdle() {
consented = false;
consentTime = null;
consentOverride = false;
noticeH = 0;
noticeEl.style.height = '';
frameEl.style.height = '0px';
progressBarEl.style.transform = 'scaleX(0)';
consentBtnEl.setAttribute('aria-expanded', 'true');
}
// 계산 겹: 동의 순간 안내 블록의 실측 height(noticeH)를 접힘 진행도(p)에 맞춰
// 그대로 프레임 height 증가분으로 넘긴다 — 안내가 접히는 만큼 프레임이 자란다.
function apply(t) {
var dotOpacity = 0.86 + 0.14 * Math.sin(2 * Math.PI * t / DOT_MS);
if (!consented) {
statusDotEl.style.opacity = dotOpacity.toFixed(3);
return;
}
var elapsed = t - consentTime;
if (elapsed < 0) elapsed += CYCLE_MS;
var p = easeOutCubic(Math.min(1, elapsed / CONSENT_FOLD_MS));
noticeEl.style.height = (noticeH * (1 - p)).toFixed(1) + 'px';
frameEl.style.height = (FRAME_BASE_H + noticeH * p).toFixed(1) + 'px';
var sweepSpan = CYCLE_MS - CONSENT_AT - CONSENT_FOLD_MS;
var sweepP = Math.max(0, Math.min(1, (elapsed - CONSENT_FOLD_MS) / sweepSpan));
progressBarEl.style.transform = 'scaleX(' + sweepP.toFixed(3) + ')';
}
if (reduce) {
apply(0);
return;
}
var start = null;
function loop(ts) {
if (start === null) start = ts;
var raw = ts - start;
var t = raw % CYCLE_MS;
if (t < prevT) resetToIdle();
prevT = t;
if (!consented && (consentOverride || t >= CONSENT_AT)) enterConsent(t);
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./EmbedConsentPlaceholder.module.scss";
// 동의 후 불러오는 임베드(09) — 안내 블록의 실측 높이를 접힘 진행도에 맞춰
// 그대로 프레임 높이로 넘긴다(계산 겹). 실키·실서버 요청 없음.
// 동의 버튼 클릭 시 즉시 전환(실사용자 리스너 있음).
const CYCLE_MS = 2000;
const CONSENT_AT = 700;
const CONSENT_FOLD_MS = 200;
const DOT_MS = 700;
function mulberry32(seed: number) {
let a = seed >>> 0;
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function drawSeedThumb(canvas: HTMLCanvasElement, seed: number) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const rnd = mulberry32(seed);
const w = canvas.width;
const h = canvas.height;
const hues = [216, 32, 268, 168];
ctx.fillStyle = "hsl(" + hues[Math.floor(rnd() * hues.length)] + ", 38%, 22%)";
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < 6; i++) {
const cx = rnd() * w;
const cy = rnd() * h;
const r = (0.18 + rnd() * 0.3) * Math.min(w, h);
ctx.fillStyle =
"hsla(" +
hues[Math.floor(rnd() * hues.length)] +
", " +
(44 + rnd() * 22).toFixed(1) +
"%, " +
(38 + rnd() * 26).toFixed(1) +
"%, .5)";
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = "rgba(255, 247, 230, .06)";
for (let j = 0; j < h; j += 4) ctx.fillRect(0, j, w, 1);
}
function easeOutCubic(p: number) {
return 1 - Math.pow(1 - p, 3);
}
export default function EmbedConsentPlaceholder() {
const noticeRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<HTMLDivElement>(null);
const frameThumbRef = useRef<HTMLCanvasElement>(null);
const statusDotRef = useRef<HTMLSpanElement>(null);
const consentBtnRef = useRef<HTMLButtonElement>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
const consentOverrideRef = useRef(false);
useEffect(() => {
const notice = noticeRef.current;
const frame = frameRef.current;
const frameThumb = frameThumbRef.current;
const statusDot = statusDotRef.current;
const consentBtn = consentBtnRef.current;
const progressBar = progressBarRef.current;
if (!notice || !frame || !frameThumb || !statusDot || !consentBtn || !progressBar) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const narrow = window.matchMedia("(max-width: 340px)").matches;
const FRAME_BASE_H = narrow ? 56 : 96;
drawSeedThumb(frameThumb, 109);
let consented = false;
let consentTime = 0;
let noticeH = 0;
let prevT = 0;
function enterConsent(t: number) {
consented = true;
consentTime = t;
consentOverrideRef.current = false;
noticeH = notice!.offsetHeight;
consentBtn!.setAttribute("aria-expanded", "false");
}
function resetToIdle() {
consented = false;
noticeH = 0;
notice!.style.height = "";
frame!.style.height = "0px";
progressBar!.style.transform = "scaleX(0)";
consentBtn!.setAttribute("aria-expanded", "true");
}
// 계산 겹: 동의 순간 안내 블록의 실측 높이(noticeH)를 접힘 진행도(p)에 맞춰
// 그대로 프레임 높이 증가분으로 넘긴다 — 안내가 접히는 만큼 프레임이 자란다.
function apply(t: number) {
const dotOpacity = 0.86 + 0.14 * Math.sin((2 * Math.PI * t) / DOT_MS);
if (!consented) {
statusDot!.style.opacity = dotOpacity.toFixed(3);
return;
}
let elapsed = t - consentTime;
if (elapsed < 0) elapsed += CYCLE_MS;
const p = easeOutCubic(Math.min(1, elapsed / CONSENT_FOLD_MS));
notice!.style.height = (noticeH * (1 - p)).toFixed(1) + "px";
frame!.style.height = (FRAME_BASE_H + noticeH * p).toFixed(1) + "px";
const sweepSpan = CYCLE_MS - CONSENT_AT - CONSENT_FOLD_MS;
const sweepP = Math.max(0, Math.min(1, (elapsed - CONSENT_FOLD_MS) / sweepSpan));
progressBar!.style.transform = "scaleX(" + sweepP.toFixed(3) + ")";
}
if (reduce) {
apply(0);
return;
}
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const raw = ts - start;
const t = raw % CYCLE_MS;
if (t < prevT) resetToIdle();
prevT = t;
if (!consented && (consentOverrideRef.current || t >= CONSENT_AT)) enterConsent(t);
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
function handleConsent() {
consentOverrideRef.current = true;
}
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.card}>
<div className={styles.notice} ref={noticeRef}>
<div className={styles.noticeHead}>
<span className={styles.statusDot} ref={statusDotRef} aria-hidden="true" />
<span className={styles.noticeLead}>이 영상은 외부 서비스에서 불러옵니다.</span>
</div>
<p className={styles.noticeSub}>동의하면 이 자리에 불러옵니다.</p>
<button
type="button"
className={styles.consentBtn}
ref={consentBtnRef}
aria-expanded="true"
onClick={handleConsent}
>
동의하고 불러오기
</button>
</div>
<div className={styles.frame} ref={frameRef} aria-hidden="true">
<canvas className={styles.frameThumb} ref={frameThumbRef} width={240} height={80} />
<div className={styles.frameScrim} />
<div className={styles.frameContent}>
<span className={styles.framePlay} aria-hidden="true" />
<span className={styles.frameLabel}>동의 후 재생되는 영상</span>
</div>
<div className={styles.progressTrack}>
<div className={styles.progressBar} ref={progressBarRef} />
</div>
</div>
</div>
</section>
</div>
);
}09 Consent-gated embed placeholder — a card that folds by a height computed from its notice text — Measured area 37.148% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · 9 YouTube Embed Code UI Patterns (Copy-Paste)
The post this demo lives in
9 YouTube Embed Code UI Patterns (Copy-Paste)Nine embed UIs: a frame's height recomputes from its width, a lite facade times itself into a full frame after a delay, See all demos in this post


