08クリックで読み込む埋め込み — サイズ差を計算し滑らかに広がるプレースホルダー
押された状態になった瞬間のプレースホルダーのサイズと実際のフレームサイズの差を計算し、その差の分だけカードが一気にではなく滑らかに広がり、重いコンテンツの場所へ変わります。
使いどころ複数動画が並ぶ長い一覧ページ、初期読み込みを抑えたい詳細ページ
requestAnimationFrameoffsetHeightaria-pressed

<main class="stage">
<section class="panel">
<div class="card" id="card">
<div class="placeholder" id="placeholder" role="button" tabindex="0" aria-label="再生項目を読み込む" aria-pressed="false">
<span class="playIcon" aria-hidden="true"></span>
<span class="phLabel">押して読み込む</span>
<span class="phSub">約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">読み込んだ動画フレーム</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">現在の高さ</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');
}
// 계산 겹: 자리표시의 실측 高さ(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 = '高さ ' + 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 クリックで読み込む埋め込み — サイズ差を計算し滑らかに広がるプレースホルダー — 計測 累積面積 31.389% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · youtube 埋め込みコード UI 9選 — SNSカードもコピペOK
このデモが属する記事
youtube 埋め込みコード UI 9選 — SNSカードもコピペOK「youtube 埋め込みコード」の検索結果はiframeタグの取得方法ばかりですが、この記事はその外側のUI計算を9種示します。フレーム幅に合わせて縦の比率が求め直され、表示されてからの経過時間が一定値を超えると軽量カードが自動で完全なフ この記事のデモをすべて見る


