08Click-to-load embed — a placeholder that recomputes the size delta to grow smoothly
The moment the placeholder is switched to its pressed state, the size difference between the placeholder and the real frame is computed, and the card expands smoothly by that difference into the heavier content slot.
Best forLong listing pages packed with multiple videos, detail pages that need to save initial load
requestAnimationFrameoffsetHeightaria-pressed

<main class="stage">
<section class="panel">
<div class="card" id="card">
<div class="placeholder" id="placeholder" role="button" tabindex="0" aria-label="Load play item" aria-pressed="false">
<span class="playIcon" aria-hidden="true"></span>
<span class="phLabel">Press to load</span>
<span class="phSub">Saves about 0.5MB</span>
<span class="hintArrow" id="hintArrow" aria-hidden="true"></span>
</div>
<div class="frame" id="frame" aria-hidden="true">
<canvas class="frameThumb" id="frameThumb" width="240" height="120"></canvas>
<div class="frameScrim"></div>
<div class="frameContent">
<span class="framePlay" aria-hidden="true"></span>
<span class="frameLabel">Loaded video frame</span>
</div>
<div class="progressTrack">
<div class="progressBar" id="progressBar"></div>
</div>
</div>
</div>
<div class="readout" id="readout" aria-live="polite">
<span class="readoutLabel">Current height</span>
<span class="readoutValue" id="heightValue">0px</span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.card {
position: relative;
box-sizing: border-box;
width: 240px;
overflow: hidden;
border-radius: $r-control;
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
box-sizing: border-box;
width: 100%;
padding: $sp-4 $sp-3;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
cursor: pointer;
}
.playIcon {
width: 0;
height: 0;
margin-bottom: $sp-1;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-left: 14px solid $subject-ink;
}
.phLabel {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 13px;
line-height: 1.3;
}
.phSub {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 400;
font-size: 11px;
line-height: 1.3;
opacity: .72;
}
.hintArrow {
width: 0;
height: 0;
margin-top: $sp-1;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 8px solid $subject-ink;
opacity: .86;
}
.frame {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
opacity: 0;
border-radius: $r-control;
overflow: hidden;
}
.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: 9px solid transparent;
border-bottom: 9px solid transparent;
border-left: 16px 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: relative;
width: 100%;
height: 4px;
background: rgba($subject-cream, .24);
}
.progressBar {
height: 100%;
width: 100%;
transform-origin: left center; // 채움 막대가 시작 변에서 자라난다 (BD8 예외 대상)
transform: scaleX(0);
background: $subject-cream;
}
.readout {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
}
.readoutLabel {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
opacity: .84;
}
.readoutValue {
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 13px;
line-height: 1.3;
}
@media (max-width: 340px) {
.panel { gap: $sp-2; }
.card { width: 200px; }
.placeholder { padding: $sp-3 $sp-2; }
.phLabel { font-size: 12px; }
.phSub { font-size: 10px; }
.frameLabel { font-size: 11px; }
.readoutLabel { font-size: 10px; }
.readoutValue { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.hintArrow { opacity: .86; }
}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);
}
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.card {
position: relative;
box-sizing: border-box;
width: 240px;
overflow: hidden;
border-radius: 12px;
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
box-sizing: border-box;
width: 100%;
padding: 16px 12px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
cursor: pointer;
}
.playIcon {
width: 0;
height: 0;
margin-bottom: 4px;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-left: 14px solid #17141a;
}
.phLabel {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 13px;
line-height: 1.3;
}
.phSub {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 400;
font-size: 11px;
line-height: 1.3;
opacity: 0.72;
}
.hintArrow {
width: 0;
height: 0;
margin-top: 4px;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 8px solid #17141a;
opacity: 0.86;
}
.frame {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
opacity: 0;
border-radius: 12px;
overflow: hidden;
}
.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: 9px solid transparent;
border-bottom: 9px solid transparent;
border-left: 16px 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: relative;
width: 100%;
height: 4px;
background: rgba(255, 247, 230, 0.24);
}
.progressBar {
height: 100%;
width: 100%;
transform-origin: left center;
transform: scaleX(0);
background: #fff7e6;
}
.readout {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
}
.readoutLabel {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
opacity: 0.84;
}
.readoutValue {
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 13px;
line-height: 1.3;
}
@media (max-width: 340px) {
.panel {
gap: 8px;
}
.card {
width: 200px;
}
.placeholder {
padding: 12px 8px;
}
.phLabel {
font-size: 12px;
}
.phSub {
font-size: 10px;
}
.frameLabel {
font-size: 11px;
}
.readoutLabel {
font-size: 10px;
}
.readoutValue {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.hintArrow {
opacity: 0.86;
}
}// 클릭으로 불러오는 지연 임베드(08) — 자리표시를 실측한 높이를 기준으로
// easeOutCubic 보간해 카드 높이를 계산으로 늘린다. 실키·실서버 요청 없음.
// 클릭 시 즉시 펼침(실사용자 리스너 있음).
(function () {
var cardEl = document.getElementById('card');
var placeholderEl = document.getElementById('placeholder');
var frameEl = document.getElementById('frame');
var frameThumbEl = document.getElementById('frameThumb');
var hintArrowEl = document.getElementById('hintArrow');
var progressBarEl = document.getElementById('progressBar');
var heightValueEl = document.getElementById('heightValue');
if (!cardEl || !placeholderEl || !frameEl || !frameThumbEl || !hintArrowEl || !progressBarEl || !heightValueEl) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var NARROW = window.matchMedia('(max-width: 340px)').matches;
var CYCLE_MS = 2000;
var PRESS_AT = 900;
var EXPAND_MS = 500;
var FRAME_H = NARROW ? 128 : 168;
var HINT_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, 108);
var pressed = false;
var pressTime = null;
var pressAtOverride = false;
var phH = 0;
var prevT = 0;
function enterPressed(t) {
pressed = true;
pressTime = t;
pressAtOverride = false;
phH = placeholderEl.offsetHeight;
placeholderEl.setAttribute('aria-pressed', 'true');
}
placeholderEl.addEventListener('click', function () {
pressAtOverride = true;
});
placeholderEl.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
pressAtOverride = true;
}
});
function resetToIdle() {
pressed = false;
pressTime = null;
pressAtOverride = false;
phH = 0;
cardEl.style.height = '';
frameEl.style.opacity = '0';
progressBarEl.style.transform = 'scaleX(0)';
placeholderEl.setAttribute('aria-pressed', 'false');
}
// 계산 겹: 자리표시의 실측 height(phH)에서 FRAME_H 까지 easeOutCubic 으로 보간해
// 카드 높이를 계산한다. 절반을 넘으면 실 프레임이 페이드로 등장하고,
// 펼침이 끝난 뒤엔 남은 시간 동안 진행 바가 채워진다.
function apply(t) {
var h;
var hintOpacity = 0.86 + 0.14 * Math.sin(2 * Math.PI * t / HINT_MS);
if (!pressed) {
h = placeholderEl.offsetHeight;
cardEl.style.height = h + 'px';
hintArrowEl.style.opacity = hintOpacity.toFixed(3);
} else {
var elapsed = t - pressTime;
if (elapsed < 0) elapsed += CYCLE_MS;
var delta = FRAME_H - phH;
var p = easeOutCubic(Math.min(1, elapsed / EXPAND_MS));
h = phH + delta * p;
cardEl.style.height = h.toFixed(1) + 'px';
var frameOpacity = p <= 0.55 ? 0 : Math.min(1, (p - 0.55) / 0.45);
frameEl.style.opacity = frameOpacity.toFixed(3);
var sweepSpan = CYCLE_MS - PRESS_AT - EXPAND_MS;
var sweepP = Math.max(0, Math.min(1, (elapsed - EXPAND_MS) / sweepSpan));
progressBarEl.style.transform = 'scaleX(' + sweepP.toFixed(3) + ')';
}
heightValueEl.textContent = 'height ' + Math.round(h) + 'px';
}
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 (!pressed && (pressAtOverride || t >= PRESS_AT)) enterPressed(t);
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./EmbedLazyClickToLoad.module.scss";
// 클릭으로 불러오는 지연 임베드 — 자리표시의 실측 높이를 기준으로
// easeOutCubic 보간해 카드 높이를 계산으로 늘린다. 실키·실서버 요청 없음.
// 클릭 시 즉시 펼침(실사용자 리스너 있음).
const CYCLE_MS = 2000;
const PRESS_AT = 900;
const EXPAND_MS = 500;
const HINT_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 EmbedLazyClickToLoad() {
const cardRef = useRef<HTMLDivElement>(null);
const placeholderRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<HTMLDivElement>(null);
const frameThumbRef = useRef<HTMLCanvasElement>(null);
const hintArrowRef = useRef<HTMLSpanElement>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
const heightValueRef = useRef<HTMLSpanElement>(null);
const pressAtOverrideRef = useRef(false);
useEffect(() => {
const card = cardRef.current;
const placeholder = placeholderRef.current;
const frame = frameRef.current;
const frameThumb = frameThumbRef.current;
const hintArrow = hintArrowRef.current;
const progressBar = progressBarRef.current;
const heightValueEl = heightValueRef.current;
if (!card || !placeholder || !frame || !frameThumb || !hintArrow || !progressBar || !heightValueEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const narrow = window.matchMedia("(max-width: 340px)").matches;
const FRAME_H = narrow ? 128 : 168;
drawSeedThumb(frameThumb, 108);
let pressed = false;
let pressTime = 0;
let phH = 0;
let prevT = 0;
function enterPressed(t: number) {
pressed = true;
pressTime = t;
pressAtOverrideRef.current = false;
phH = placeholder!.offsetHeight;
placeholder!.setAttribute("aria-pressed", "true");
}
function resetToIdle() {
pressed = false;
phH = 0;
card!.style.height = "";
frame!.style.opacity = "0";
progressBar!.style.transform = "scaleX(0)";
placeholder!.setAttribute("aria-pressed", "false");
}
// 계산 겹: 자리표시의 실측 높이(phH)에서 FRAME_H 까지 easeOutCubic 으로 보간해
// 카드 높이를 계산한다. 절반을 넘으면 실 프레임이 페이드로 등장하고,
// 펼침이 끝난 뒤엔 남은 시간 동안 진행 바가 채워진다.
function apply(t: number) {
let h: number;
const hintOpacity = 0.86 + 0.14 * Math.sin((2 * Math.PI * t) / HINT_MS);
if (!pressed) {
h = placeholder!.offsetHeight;
card!.style.height = h + "px";
hintArrow!.style.opacity = hintOpacity.toFixed(3);
} else {
let elapsed = t - pressTime;
if (elapsed < 0) elapsed += CYCLE_MS;
const delta = FRAME_H - phH;
const p = easeOutCubic(Math.min(1, elapsed / EXPAND_MS));
h = phH + delta * p;
card!.style.height = h.toFixed(1) + "px";
const frameOpacity = p <= 0.55 ? 0 : Math.min(1, (p - 0.55) / 0.45);
frame!.style.opacity = frameOpacity.toFixed(3);
const sweepSpan = CYCLE_MS - PRESS_AT - EXPAND_MS;
const sweepP = Math.max(0, Math.min(1, (elapsed - EXPAND_MS) / sweepSpan));
progressBar!.style.transform = "scaleX(" + sweepP.toFixed(3) + ")";
}
heightValueEl!.textContent = "높이 " + Math.round(h) + "px";
}
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 (!pressed && (pressAtOverrideRef.current || t >= PRESS_AT)) enterPressed(t);
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
function handlePress() {
pressAtOverrideRef.current = true;
}
function handleKeyDown(e: { key: string; preventDefault(): void }) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
pressAtOverrideRef.current = true;
}
}
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.card} ref={cardRef}>
<div
className={styles.placeholder}
ref={placeholderRef}
role="button"
tabIndex={0}
aria-label="재생 항목 불러오기"
aria-pressed="false"
onClick={handlePress}
onKeyDown={handleKeyDown}
>
<span className={styles.playIcon} aria-hidden="true" />
<span className={styles.phLabel}>눌러서 불러오기</span>
<span className={styles.phSub}>약 0.5MB 절약</span>
<span className={styles.hintArrow} ref={hintArrowRef} aria-hidden="true" />
</div>
<div className={styles.frame} ref={frameRef} aria-hidden="true">
<canvas className={styles.frameThumb} ref={frameThumbRef} width={240} height={120} />
<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>
<div className={styles.readout} aria-live="polite">
<span className={styles.readoutLabel}>현재 높이</span>
<span className={styles.readoutValue} ref={heightValueRef}>0px</span>
</div>
</section>
</div>
);
}08 Click-to-load embed — a placeholder that recomputes the size delta to grow smoothly — Measured area 31.389% · moving frames 22/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


