유튜브 임베드 코드 9종 — SNS 카드도 담은 플레이스홀더 UI
프레임 폭에 맞춰 세로 비율이 다시 계산되고, 화면에 머무른 시간이 일정 값을 넘으면 가벼운 카드가 스스로 완전한 프레임으로 바뀌고, 최근 소셜 임베드는 등장 뒤 지난 시간만큼 배지 숫자가 조금씩 올라가는 임베드 UI 9종입니다.
01 embed-youtube-responsive
02 embed-youtube-lite-facade
03 embed-youtube-nocookie
04 embed-shorts-vertical
05 embed-tiktok
06 embed-instagram
07 embed-x-post
08 embed-lazy-click-to-load
09 embed-consent-placeholder
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 유튜브 반응형 임베드 — 폭에 맞춰 비율을 다시 구하는 영상 프레임
- 02 유튜브 라이트 파사드 — 경과 시간을 재서 완전한 프레임으로 넘어가는 임베드
- 03 유튜브 프라이버시 강화 임베드 — 밝기를 주기적으로 다시 구해 맥동하는 배지
- 04 세로형 쇼츠 임베드 — 남는 폭만큼 배경 흐림을 다시 구하는 프레임
- 05 틱톡 임베드 목업 — 경과 시간만큼 좋아요 숫자를 다시 구하는 카드
- 06 인스타그램 포스트 임베드 카드 — 각도를 계속 다시 구해 흐르는 로딩 그라디언트
- 07 X 포스트 임베드 프레임 — 테두리 밝기를 주기적으로 다시 구해 빛나는 카드
- 08 클릭 후 로드 임베드 — 크기 차이를 계산해 부드럽게 커지는 플레이스홀더
- 09 동의 전 임베드 플레이스홀더 — 안내 문구 높이를 계산해 접히는 카드
아홉 화면은 폭 → 비율, 경과 시간 → 상태, 주기 → 밝기·각도라는 세 갈래 계산으로 나뉘고, 아래 표는 그 계산이 실제로 얼마나 화면을 흔들었는지를 잰 값입니다. 순서는 이 흔들림이 작은 것에서 큰 것 쪽으로가 아니라, 유튜브 계열(01~03) → 세로·SNS 계열(04~07) → 상호작용 계열(08~09) 순으로 묶어 실제 서비스에서 마주치는 배치와 가깝게 잡았습니다.
데모
아홉 화면은 requestAnimationFrame 루프가 흘려보내는 하나의 시간값을 각 화면의 apply 함수가 저마다 다른 공식으로 계산해 화면에 다시 씁니다. 실제 유튜브 iframe 이나 SNS SDK 호출은 하나도 없고, 그 자리에 어떤 값이 들어가면 UI 가 어떤 모습으로 바뀌는지만 자동 재생으로 보여줍니다. 실제 서비스에 붙일 때는 유튜브 iframe 반응형 레이아웃에 쓰는 aspect-ratio 속성이나 지연 로드 패턴을 이 계산 자리에 그대로 옮겨 쓰면 됩니다.
실측 한눈에
| 항목 | 초점 | 누적면적 % | 강도 평균 | 움직인 프레임 |
|---|---|---|---|---|
| 01 유튜브 반응형 임베드 | 폭 → 비율 재계산 | 26.864 | 84.4 | 23/23 |
| 02 유튜브 라이트 파사드 | 경과 시간 → 상태 전환 | 7.068 | 100.6 | 23/23 |
| 03 유튜브 프라이버시 강화 임베드 | 주기 → 밝기 맥동 | 1.876 | 59.5 | 23/23 |
| 04 세로형 쇼츠 임베드 | 여백 → 배경 흐림 | 25.935 | 82.9 | 23/23 |
| 05 틱톡 임베드 목업 | 간격 → 좋아요 카운트 | 13.598 | 42.6 | 23/23 |
| 06 인스타그램 포스트 임베드 카드 | 진행률 → 그라디언트 각도 | 18.658 | 25.4 | 23/23 |
| 07 X 포스트 임베드 프레임 | 주기 → 테두리 밝기 | 1.878 | 59.4 | 23/23 |
| 08 클릭 후 로드 임베드 | 클릭 시점 → 높이 보간 | 31.389 | 70.0 | 22/23 |
| 09 동의 전 임베드 플레이스홀더 | 동의 시점 → 높이 맞바꿈 | 37.148 | 128.4 | 19/23 |
08·09 는 이벤트가 실제로 눌리기 전까지는 화면 대부분이 값을 바꾸지 않아 다른 일곱 개보다 움직인 프레임이 적게 잡힙니다. 09 는 동의 시점(700ms)과 접힘 200ms 가 지나면 안내 문구·프레임 높이가 이미 완료 값에 도달해 그대로 있고 상태 점의 pulse 도 동의 뒤로는 갱신을 멈춰서, 남은 구간 대부분은 진행바만 움직이는 19/23 이 됩니다.
임베드 UI 의 해부 — 아홉 화면이 계산하는 것
같은 "임베드 카드" 라도 계산이 어디에 걸리는지에 따라 세 갈래로 나뉩니다.
폭이 비율로 이어지는 축
01·04 는 프레임 폭을 먼저 정하고 그 폭에서 남는 값을 다시 다른 값으로 바꿉니다. 01 은 사인 곡선으로 프레임 폭을 흔든 뒤 getBoundingClientRect() 로 실제 높이를 읽어 16:9 라벨을 다시 씁니다. 04 는 반대로 폭이 커질수록 남는 여백(stageEl.clientWidth - 프레임 너비)이 줄고, 그 여백을 배경 흐림 강도로 바꿔 씁니다.
경과 시간이 상태를 넘기는 축
02·08·09 는 경과 시간이 어떤 값을 넘는 순간 상태 자체가 바뀝니다. 02 는 FACADE_DELAY 를 넘기면 cardEl.dataset.state 가 lite 에서 frame 으로 바뀌고, 08·09 는 클릭·동의라는 실제 이벤트가 시점을 정한 뒤 그 시점부터 easeOutCubic 곡선으로 높이를 보간합니다.
주기가 밝기·각도를 다시 구하는 축
03·05·06·07 은 클릭 없이 주기 함수만으로 화면을 계속 고쳐 씁니다. 03·07 은 사인 곡선으로 밝기(glow)를 맥동시키고, 05 는 LIKE_INTERVAL 마다 좋아요 숫자를 정수로 올리며 하트를 튕기고, 06 은 진행률을 그대로 그라디언트 각도(--spin)로 바꿔 회전시킵니다.
화면별 코드
01유튜브 반응형 임베드 — 폭에 맞춰 비율을 다시 구하는 영상 프레임
프레임 폭을 사인 곡선으로 흔든 뒤 실제 렌더 높이를 되읽어 16:9 라벨을 다시 씁니다.
폭을 흔들고 실제 높이를 되읽는 계산
apply(t) 는 매 프레임 BASE + RANGE*Math.sin(2*Math.PI*t/CYCLE_MS) 로 프레임 폭을 정하고, getBoundingClientRect() 로 브라우저가 실제로 그린 높이를 다시 읽어 "16:9 · 가로 × 세로px" 라벨을 채웁니다. reduce(prefers-reduced-motion) 가 켜져 있으면 apply(0) 한 번만 실행하고 루프를 시작하지 않습니다.
function apply(t) {
var w = BASE + RANGE * Math.sin(2 * Math.PI * t / CYCLE_MS);
frameEl.style.width = w.toFixed(1) + 'px';
var rect = frameEl.getBoundingClientRect();
readoutEl.textContent = '16:9 · ' + Math.round(w) + ' × ' + Math.round(rect.height) + 'px';
}
apply(0);
if (reduce) return;
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

<main class="stage">
<section class="panel">
<div class="frame" id="frame">
<canvas class="thumb" id="thumb" width="320" height="180" aria-hidden="true"></canvas>
<div class="playBtn" role="button" aria-label="재생">
<svg class="playIcon" viewBox="0 0 24 24" aria-hidden="true"><polygon points="7,4 20,12 7,20"></polygon></svg>
</div>
<div class="scrim">
<span class="scrimTitle">임베드 영상 미리보기</span>
<span class="scrimChannel">Channel A</span>
</div>
</div>
<div class="readout" id="readout">16:9 · 200 × 113px</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.frame {
position: relative;
width: 200px;
aspect-ratio: 16 / 9;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.thumb {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.playBtn {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.playIcon {
width: 34px;
height: 34px;
filter: drop-shadow(0 2px 4px rgba(23, 20, 26, .4));
polygon {
fill: $subject-cream;
}
}
.scrim {
position: absolute;
inset: auto 0 0 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
padding: $sp-2 $sp-3;
background: rgba(23, 20, 26, .66);
}
.scrimTitle,
.scrimChannel {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
opacity: .96;
}
.scrimTitle {
font-weight: 600;
font-size: 11px;
line-height: 1.3;
}
.scrimChannel {
font-weight: 500;
font-size: 10px;
line-height: 1.3;
}
.readout {
display: inline-flex;
align-items: center;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.3;
}
@media (max-width: 340px) {
.frame { width: 140px; }
.playIcon { width: 24px; height: 24px; }
.scrimTitle { font-size: 10px; }
.scrimChannel { font-size: 9px; }
.readout { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.frame { transition: none; }
}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;
gap: 12px;
}
.frame {
position: relative;
width: 200px;
aspect-ratio: 16/9;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.thumb {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.playBtn {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.playIcon {
width: 34px;
height: 34px;
filter: drop-shadow(0 2px 4px rgba(23, 20, 26, 0.4));
}
.playIcon polygon {
fill: #fff7e6;
}
.scrim {
position: absolute;
inset: auto 0 0 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
background: rgba(23, 20, 26, 0.66);
}
.scrimTitle,
.scrimChannel {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
opacity: 0.96;
}
.scrimTitle {
font-weight: 600;
font-size: 11px;
line-height: 1.3;
}
.scrimChannel {
font-weight: 500;
font-size: 10px;
line-height: 1.3;
}
.readout {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.3;
}
@media (max-width: 340px) {
.frame {
width: 140px;
}
.playIcon {
width: 24px;
height: 24px;
}
.scrimTitle {
font-size: 10px;
}
.scrimChannel {
font-size: 9px;
}
.readout {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.frame {
transition: none;
}
}// 반응형 영상 프레임(01) — 폭을 sin 파형으로 바꾸면 aspect-ratio 가 세로를 다시 계산하고,
// 그 실측 높이를 매 프레임 getBoundingClientRect 로 읽어 리드아웃에 반영한다.
// 계산 겹: 폭 → 비율 재계산 → 실측 읽기 (transform·opacity 만 쓰는 원칙의 의도적 예외).
// 실키·실서버 요청 없음 — 폭 궤적은 고정 시나리오로 자동 재생한다.
(function () {
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);
}
var frameEl = document.getElementById('frame');
var thumbEl = document.getElementById('thumb');
var readoutEl = document.getElementById('readout');
if (!frameEl || !thumbEl || !readoutEl) return;
drawSeedThumb(thumbEl, 101);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var narrow = window.matchMedia('(max-width: 340px)').matches;
var CYCLE_MS = 2000;
var BASE = narrow ? 140 : 200;
var RANGE = narrow ? 36 : 56;
function apply(t) {
var w = BASE + RANGE * Math.sin(2 * Math.PI * t / CYCLE_MS);
frameEl.style.width = w.toFixed(1) + 'px';
var rect = frameEl.getBoundingClientRect();
readoutEl.textContent = '16:9 · ' + Math.round(w) + ' × ' + Math.round(rect.height) + 'px';
}
apply(0);
if (reduce) return;
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./EmbedYoutubeResponsive.module.scss";
// 반응형 영상 프레임 — 폭을 sin 파형으로 바꾸면 aspect-ratio 가 세로를 다시 계산하고,
// 그 실측 높이를 매 프레임 getBoundingClientRect 로 읽어 리드아웃에 반영한다.
// 계산 겹: 폭 → 비율 재계산 → 실측 읽기. 실키·실서버 요청 없음.
const CYCLE_MS = 2000;
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, 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, 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 (let j = 0; j < h; j += 4) ctx.fillRect(0, j, w, 1);
}
export default function EmbedYoutubeResponsive() {
const frameRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLCanvasElement>(null);
const readoutRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const frame = frameRef.current;
const thumb = thumbRef.current;
const readout = readoutRef.current;
if (!frame || !thumb || !readout) return;
drawSeedThumb(thumb, 101);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const narrow = window.matchMedia("(max-width: 340px)").matches;
const BASE = narrow ? 140 : 200;
const RANGE = narrow ? 36 : 56;
function apply(t: number) {
const w = BASE + RANGE * Math.sin((2 * Math.PI * t) / CYCLE_MS);
frame!.style.width = w.toFixed(1) + "px";
const rect = frame!.getBoundingClientRect();
readout!.textContent = "16:9 · " + Math.round(w) + " × " + Math.round(rect.height) + "px";
}
apply(0);
if (reduce) return;
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.frame} ref={frameRef}>
<canvas className={styles.thumb} ref={thumbRef} width={320} height={180} aria-hidden="true" />
<div className={styles.playBtn} role="button" aria-label="재생">
<svg className={styles.playIcon} viewBox="0 0 24 24" aria-hidden="true">
<polygon points="7,4 20,12 7,20" />
</svg>
</div>
<div className={styles.scrim}>
<span className={styles.scrimTitle}>임베드 영상 미리보기</span>
<span className={styles.scrimChannel}>Channel A</span>
</div>
</div>
<div className={styles.readout} ref={readoutRef}>16:9 · 200 × 113px</div>
</section>
</div>
);
}01 · 유튜브 반응형 임베드 — 폭에 맞춰 비율을 다시 구하는 영상 프레임 — 측정 누적면적 26.864% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
02유튜브 라이트 파사드 — 경과 시간을 재서 완전한 프레임으로 넘어가는 임베드
카드는 처음엔 미리보기 이미지 한 장짜리 "라이트" 상태로 있다가, 경과 시간이 FACADE_DELAY 를 넘는 순간 진짜 프레임으로 넘어갑니다.
지연 시간을 넘기면 상태가 바뀌는 계산
isFrame = t >= FACADE_DELAY 로 상태를 나누고, 프레임 상태일 때만 진행률을 0~1 로 계산해 진행바 폭(scaleX)과 "0:00 / 0:12" 형태의 시간 라벨을 채웁니다. 리드아웃에는 경과 초와 전환 소요 초를 함께 적습니다.
function apply(t) {
var isFrame = t >= FACADE_DELAY;
cardEl.dataset.state = isFrame ? 'frame' : 'lite';
var progress = isFrame ? (t - FACADE_DELAY) / (CYCLE_MS - FACADE_DELAY) : 0;
progressFillEl.style.transform = 'scaleX(' + progress.toFixed(4) + ')';
var sec = Math.floor(progress * TOTAL_SEC);
timeLabelEl.textContent = '0:' + String(sec).padStart(2, '0') + ' / 0:12';
readoutEl.textContent = '경과 ' + (t / 1000).toFixed(2) + '초 · 전환 ' + transitionSec + '초';
}
apply(0);
if (reduce) return;
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

<main class="stage">
<section class="panel">
<div class="card" id="card" data-state="lite">
<div class="thumbWrap">
<canvas class="thumb" id="thumb" width="320" height="180" aria-hidden="true"></canvas>
<div class="playBtn" role="button" aria-label="재생">
<svg class="playIcon" viewBox="0 0 24 24" aria-hidden="true"><polygon points="7,4 20,12 7,20"></polygon></svg>
</div>
<span class="badgeLite">라이트</span>
<span class="cardTitleLite">가벼운 썸네일 카드</span>
<div class="frameTop">
<span class="frameTopTitle">재생 준비 완료</span>
</div>
<div class="frameBottom">
<div class="progressTrack">
<div class="progressFill" id="progressFill"></div>
</div>
<span class="timeLabel" id="timeLabel">0:00 / 0:12</span>
</div>
</div>
</div>
<div class="readout" id="readout" aria-live="polite">경과 0.00초 · 전환 1.2초</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.card {
position: relative;
}
.thumbWrap {
position: relative;
width: 190px;
aspect-ratio: 16 / 9;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.thumb {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.playBtn {
position: absolute;
inset: 0;
display: grid;
place-items: center;
transition: opacity $dur-quick $ease-out;
}
.playIcon {
width: 30px;
height: 30px;
filter: drop-shadow(0 2px 4px rgba(23, 20, 26, .4));
polygon {
fill: $subject-cream;
}
}
.badgeLite {
position: absolute;
top: $sp-2;
left: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .66);
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 9px;
line-height: 1.3;
transition: opacity $dur-quick $ease-out;
}
.cardTitleLite {
position: absolute;
inset: auto 0 0 0;
padding: $sp-2 $sp-3;
background: rgba(23, 20, 26, .66);
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 11px;
line-height: 1.3;
transition: opacity $dur-quick $ease-out;
}
.frameTop {
position: absolute;
inset: 0 0 auto 0;
padding: $sp-2 $sp-3;
background: rgba(23, 20, 26, .66);
opacity: 0;
transition: opacity $dur-quick $ease-out;
}
.frameTopTitle {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 11px;
line-height: 1.3;
color: $subject-cream;
opacity: .96;
}
.frameBottom {
position: absolute;
inset: auto 0 0 0;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
background: rgba(23, 20, 26, .66);
opacity: 0;
transition: opacity $dur-quick $ease-out;
}
.progressTrack {
flex: 1 1 auto;
height: 4px;
border-radius: $r-xs;
background: rgba($subject-cream, .3);
overflow: hidden;
}
.progressFill {
width: 100%;
height: 100%;
transform-origin: left center;
transform: scaleX(0);
background: $subject-cream;
}
.timeLabel {
flex: 0 0 auto;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 9px;
line-height: 1.3;
color: $subject-cream;
opacity: .96;
}
.card[data-state='frame'] .badgeLite,
.card[data-state='frame'] .cardTitleLite,
.card[data-state='frame'] .playBtn {
opacity: 0;
}
.card[data-state='frame'] .frameTop,
.card[data-state='frame'] .frameBottom {
opacity: 1;
}
.readout {
display: inline-flex;
align-items: center;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.3;
}
@media (max-width: 340px) {
.thumbWrap { width: 138px; }
.playIcon { width: 22px; height: 22px; }
.badgeLite { font-size: 8px; }
.cardTitleLite { font-size: 10px; }
.frameTopTitle { font-size: 10px; }
.timeLabel { font-size: 8px; }
.readout { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.playBtn,
.badgeLite,
.cardTitleLite,
.frameTop,
.frameBottom {
transition: none;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
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;
}
.thumbWrap {
position: relative;
width: 190px;
aspect-ratio: 16/9;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.thumb {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.playBtn {
position: absolute;
inset: 0;
display: grid;
place-items: center;
transition: opacity 220ms ease-out;
}
.playIcon {
width: 30px;
height: 30px;
filter: drop-shadow(0 2px 4px rgba(23, 20, 26, 0.4));
}
.playIcon polygon {
fill: #fff7e6;
}
.badgeLite {
position: absolute;
top: 8px;
left: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.66);
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 9px;
line-height: 1.3;
transition: opacity 220ms ease-out;
}
.cardTitleLite {
position: absolute;
inset: auto 0 0 0;
padding: 8px 12px;
background: rgba(23, 20, 26, 0.66);
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 11px;
line-height: 1.3;
transition: opacity 220ms ease-out;
}
.frameTop {
position: absolute;
inset: 0 0 auto 0;
padding: 8px 12px;
background: rgba(23, 20, 26, 0.66);
opacity: 0;
transition: opacity 220ms ease-out;
}
.frameTopTitle {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 11px;
line-height: 1.3;
color: #fff7e6;
opacity: 0.96;
}
.frameBottom {
position: absolute;
inset: auto 0 0 0;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(23, 20, 26, 0.66);
opacity: 0;
transition: opacity 220ms ease-out;
}
.progressTrack {
flex: 1 1 auto;
height: 4px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.3);
overflow: hidden;
}
.progressFill {
width: 100%;
height: 100%;
transform-origin: left center;
transform: scaleX(0);
background: #fff7e6;
}
.timeLabel {
flex: 0 0 auto;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 9px;
line-height: 1.3;
color: #fff7e6;
opacity: 0.96;
}
.card[data-state=frame] .badgeLite,
.card[data-state=frame] .cardTitleLite,
.card[data-state=frame] .playBtn {
opacity: 0;
}
.card[data-state=frame] .frameTop,
.card[data-state=frame] .frameBottom {
opacity: 1;
}
.readout {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.3;
}
@media (max-width: 340px) {
.thumbWrap {
width: 138px;
}
.playIcon {
width: 22px;
height: 22px;
}
.badgeLite {
font-size: 8px;
}
.cardTitleLite {
font-size: 10px;
}
.frameTopTitle {
font-size: 10px;
}
.timeLabel {
font-size: 8px;
}
.readout {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.playBtn,
.badgeLite,
.cardTitleLite,
.frameTop,
.frameBottom {
transition: none;
}
}// 가벼운 파사드 임베드(02) — 경과 시간이 임계값(FACADE_DELAY)을 넘는 순간
// data-state 를 'lite' → 'frame' 으로 바꾸는 상태 머신. 실키·실서버 요청 없음.
(function () {
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);
}
var cardEl = document.getElementById('card');
var thumbEl = document.getElementById('thumb');
var progressFillEl = document.getElementById('progressFill');
var timeLabelEl = document.getElementById('timeLabel');
var readoutEl = document.getElementById('readout');
if (!cardEl || !thumbEl || !progressFillEl || !timeLabelEl || !readoutEl) return;
drawSeedThumb(thumbEl, 102);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var CYCLE_MS = 2000;
var FACADE_DELAY = 1200;
var TOTAL_SEC = 12;
var transitionSec = (FACADE_DELAY / 1000).toFixed(1);
function apply(t) {
var isFrame = t >= FACADE_DELAY;
cardEl.dataset.state = isFrame ? 'frame' : 'lite';
var progress = isFrame ? (t - FACADE_DELAY) / (CYCLE_MS - FACADE_DELAY) : 0;
progressFillEl.style.transform = 'scaleX(' + progress.toFixed(4) + ')';
var sec = Math.floor(progress * TOTAL_SEC);
timeLabelEl.textContent = '0:' + String(sec).padStart(2, '0') + ' / 0:12';
readoutEl.textContent = '경과 ' + (t / 1000).toFixed(2) + '초 · 전환 ' + transitionSec + '초';
}
apply(0);
if (reduce) return;
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./EmbedYoutubeLiteFacade.module.scss";
// 가벼운 파사드 임베드 — 경과 시간이 임계값(FACADE_DELAY)을 넘는 순간
// data-state 를 'lite' → 'frame' 으로 바꾸는 상태 머신. 실키·실서버 요청 없음.
const CYCLE_MS = 2000;
const FACADE_DELAY = 1200;
const TOTAL_SEC = 12;
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, 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, 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 (let j = 0; j < h; j += 4) ctx.fillRect(0, j, w, 1);
}
export default function EmbedYoutubeLiteFacade() {
const cardRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLCanvasElement>(null);
const progressFillRef = useRef<HTMLDivElement>(null);
const timeLabelRef = useRef<HTMLSpanElement>(null);
const readoutRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const card = cardRef.current;
const thumb = thumbRef.current;
const progressFill = progressFillRef.current;
const timeLabel = timeLabelRef.current;
const readout = readoutRef.current;
if (!card || !thumb || !progressFill || !timeLabel || !readout) return;
drawSeedThumb(thumb, 102);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const transitionSec = (FACADE_DELAY / 1000).toFixed(1);
function apply(t: number) {
const isFrame = t >= FACADE_DELAY;
card!.dataset.state = isFrame ? "frame" : "lite";
const progress = isFrame ? (t - FACADE_DELAY) / (CYCLE_MS - FACADE_DELAY) : 0;
progressFill!.style.transform = "scaleX(" + progress.toFixed(4) + ")";
const sec = Math.floor(progress * TOTAL_SEC);
timeLabel!.textContent = "0:" + String(sec).padStart(2, "0") + " / 0:12";
readout!.textContent = "경과 " + (t / 1000).toFixed(2) + "초 · 전환 " + transitionSec + "초";
}
apply(0);
if (reduce) return;
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.card} ref={cardRef} data-state="lite">
<div className={styles.thumbWrap}>
<canvas className={styles.thumb} ref={thumbRef} width={320} height={180} aria-hidden="true" />
<div className={styles.playBtn} role="button" aria-label="재생">
<svg className={styles.playIcon} viewBox="0 0 24 24" aria-hidden="true">
<polygon points="7,4 20,12 7,20" />
</svg>
</div>
<span className={styles.badgeLite}>라이트</span>
<span className={styles.cardTitleLite}>가벼운 썸네일 카드</span>
<div className={styles.frameTop}>
<span className={styles.frameTopTitle}>재생 준비 완료</span>
</div>
<div className={styles.frameBottom}>
<div className={styles.progressTrack}>
<div className={styles.progressFill} ref={progressFillRef} />
</div>
<span className={styles.timeLabel} ref={timeLabelRef}>0:00 / 0:12</span>
</div>
</div>
</div>
<div className={styles.readout} ref={readoutRef} aria-live="polite">경과 0.00초 · 전환 1.2초</div>
</section>
</div>
);
}02 · 유튜브 라이트 파사드 — 경과 시간을 재서 완전한 프레임으로 넘어가는 임베드 — 측정 누적면적 7.068% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
03유튜브 프라이버시 강화 임베드 — 밝기를 주기적으로 다시 구해 맥동하는 배지
"추적 쿠키 없음" 배지가 사인 곡선을 따라 밝기와 크기를 함께 바꾸며 은근하게 숨 쉬는 것처럼 보입니다.
사인 값 하나로 투명도·크기를 같이 바꾸는 계산
pulse = 0.5 + 0.5*Math.sin(2*Math.PI*t/700) 값 하나를 배지 글로우의 투명도와 배지 자체의 scale 에 함께 대입해, 밝기와 크기가 어긋나지 않고 같은 박자로 움직입니다.
function apply(t) {
var pulse = 0.5 + 0.5 * Math.sin(2 * Math.PI * t / 700);
badgeGlowEl.style.opacity = (0.1 + 0.45 * pulse).toFixed(3);
badgeEl.style.transform = 'scale(' + (1 + 0.04 * pulse).toFixed(4) + ')';
readoutEl.textContent = '밝기 ' + Math.round(pulse * 100) + '%';
}
apply(0);
if (reduce) return;
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

<main class="stage">
<section class="panel">
<div class="frame" id="frame">
<canvas class="thumb" id="thumb" width="320" height="180" aria-hidden="true"></canvas>
<div class="playBtn" role="button" aria-label="재생">
<svg class="playIcon" viewBox="0 0 24 24" aria-hidden="true"><polygon points="7,4 20,12 7,20"></polygon></svg>
</div>
<div class="badge" id="badge">
<div class="badgeGlow" id="badgeGlow"></div>
<span class="badgeIcon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160zm-68-56a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg></span>
<span class="badgeLabel">추적 쿠키 없음</span>
</div>
<div class="scrim">
<span class="scrimTitle">추적 없는 영상 임베드</span>
</div>
</div>
<div class="readout" id="readout" aria-live="polite">밝기 50%</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.frame {
position: relative;
width: 200px;
aspect-ratio: 16 / 9;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.thumb {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.playBtn {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.playIcon {
width: 34px;
height: 34px;
filter: drop-shadow(0 2px 4px rgba(23, 20, 26, .4));
polygon {
fill: $subject-cream;
}
}
.badge {
position: absolute;
top: $sp-2;
right: $sp-2;
display: inline-flex;
align-items: center;
gap: $sp-1;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: $subject-blue-deep;
overflow: hidden;
}
.badgeGlow {
position: absolute;
inset: 0;
background: $subject-cream;
opacity: .1;
pointer-events: none;
}
.badgeIcon {
position: relative;
display: block;
width: 12px;
height: 12px;
svg {
display: block;
width: 100%;
height: 100%;
}
path {
fill: $subject-cream;
}
}
.badgeLabel {
position: relative;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 9px;
line-height: 1.3;
color: $subject-cream;
}
.scrim {
position: absolute;
inset: auto 0 0 0;
padding: $sp-2 $sp-3;
background: rgba(23, 20, 26, .66);
}
.scrimTitle {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 11px;
line-height: 1.3;
color: $subject-cream;
opacity: .96;
}
.readout {
display: inline-flex;
align-items: center;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.3;
}
@media (max-width: 340px) {
.frame { width: 140px; }
.playIcon { width: 24px; height: 24px; }
.badgeIcon { width: 10px; height: 10px; }
.badgeLabel { font-size: 8px; }
.scrimTitle { font-size: 10px; }
.readout { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.badge { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
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;
}
.frame {
position: relative;
width: 200px;
aspect-ratio: 16/9;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.thumb {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.playBtn {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.playIcon {
width: 34px;
height: 34px;
filter: drop-shadow(0 2px 4px rgba(23, 20, 26, 0.4));
}
.playIcon polygon {
fill: #fff7e6;
}
.badge {
position: absolute;
top: 8px;
right: 8px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: #0a4fe8;
overflow: hidden;
}
.badgeGlow {
position: absolute;
inset: 0;
background: #fff7e6;
opacity: 0.1;
pointer-events: none;
}
.badgeIcon {
position: relative;
display: block;
width: 12px;
height: 12px;
}
.badgeIcon svg {
display: block;
width: 100%;
height: 100%;
}
.badgeIcon path {
fill: #fff7e6;
}
.badgeLabel {
position: relative;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 9px;
line-height: 1.3;
color: #fff7e6;
}
.scrim {
position: absolute;
inset: auto 0 0 0;
padding: 8px 12px;
background: rgba(23, 20, 26, 0.66);
}
.scrimTitle {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 11px;
line-height: 1.3;
color: #fff7e6;
opacity: 0.96;
}
.readout {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.3;
}
@media (max-width: 340px) {
.frame {
width: 140px;
}
.playIcon {
width: 24px;
height: 24px;
}
.badgeIcon {
width: 10px;
height: 10px;
}
.badgeLabel {
font-size: 8px;
}
.scrimTitle {
font-size: 10px;
}
.readout {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.badge {
transition: none;
}
}// 추적 없는 임베드(03) — 배지 광택은 매 프레임 sin 파형(pulse)을 다시 계산해
// 광택 레이어 불투명도와 배지 자체 스케일(자기 중심 기준)에 함께 반영한다.
// 계산 겹: 700ms 위상의 pulse 재계산 → 광택·스케일 동시 반영. 실키·실서버 요청 없음.
(function () {
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);
}
var thumbEl = document.getElementById('thumb');
var badgeEl = document.getElementById('badge');
var badgeGlowEl = document.getElementById('badgeGlow');
var readoutEl = document.getElementById('readout');
if (!thumbEl || !badgeEl || !badgeGlowEl || !readoutEl) return;
drawSeedThumb(thumbEl, 103);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var CYCLE_MS = 2000;
function apply(t) {
var pulse = 0.5 + 0.5 * Math.sin(2 * Math.PI * t / 700);
badgeGlowEl.style.opacity = (0.1 + 0.45 * pulse).toFixed(3);
badgeEl.style.transform = 'scale(' + (1 + 0.04 * pulse).toFixed(4) + ')';
readoutEl.textContent = '밝기 ' + Math.round(pulse * 100) + '%';
}
apply(0);
if (reduce) return;
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./EmbedYoutubeNocookie.module.scss";
// 추적 없는 임베드 — 배지 광택은 매 프레임 sin 파형(pulse)을 다시 계산해
// 광택 레이어 불투명도와 배지 자체 스케일(자기 중심 기준)에 함께 반영한다.
// 계산 겹: 700ms 위상의 pulse 재계산 → 광택·스케일 동시 반영. 실키·실서버 요청 없음.
const CYCLE_MS = 2000;
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, 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, 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 (let j = 0; j < h; j += 4) ctx.fillRect(0, j, w, 1);
}
export default function EmbedYoutubeNocookie() {
const thumbRef = useRef<HTMLCanvasElement>(null);
const badgeRef = useRef<HTMLDivElement>(null);
const badgeGlowRef = useRef<HTMLDivElement>(null);
const readoutRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const thumb = thumbRef.current;
const badge = badgeRef.current;
const badgeGlow = badgeGlowRef.current;
const readout = readoutRef.current;
if (!thumb || !badge || !badgeGlow || !readout) return;
drawSeedThumb(thumb, 103);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function apply(t: number) {
const pulse = 0.5 + 0.5 * Math.sin((2 * Math.PI * t) / 700);
badgeGlow!.style.opacity = (0.1 + 0.45 * pulse).toFixed(3);
badge!.style.transform = "scale(" + (1 + 0.04 * pulse).toFixed(4) + ")";
readout!.textContent = "밝기 " + Math.round(pulse * 100) + "%";
}
apply(0);
if (reduce) return;
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.frame}>
<canvas className={styles.thumb} ref={thumbRef} width={320} height={180} aria-hidden="true" />
<div className={styles.playBtn} role="button" aria-label="재생">
<svg className={styles.playIcon} viewBox="0 0 24 24" aria-hidden="true">
<polygon points="7,4 20,12 7,20" />
</svg>
</div>
<div className={styles.badge} ref={badgeRef}>
<div className={styles.badgeGlow} ref={badgeGlowRef} />
<span className={styles.badgeIcon} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<g>
<path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2" />
<path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160zm-68-56a12 12 0 1 1-12-12a12 12 0 0 1 12 12" />
</g>
</svg>
</span>
<span className={styles.badgeLabel}>추적 쿠키 없음</span>
</div>
<div className={styles.scrim}>
<span className={styles.scrimTitle}>추적 없는 영상 임베드</span>
</div>
</div>
<div className={styles.readout} ref={readoutRef} aria-live="polite">밝기 50%</div>
</section>
</div>
);
}03 · 유튜브 프라이버시 강화 임베드 — 밝기를 주기적으로 다시 구해 맥동하는 배지 — 측정 누적면적 1.876% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
04세로형 쇼츠 임베드 — 남는 폭만큼 배경 흐림을 다시 구하는 프레임
세로 9:16 캡션이 붙은 프레임이 커졌다 작아지는 동안, 그 뒤로 보이는 배경의 흐림 강도가 함께 바뀝니다.
여백을 흐림 강도로 바꾸는 계산
토큰(--base-fw·--amp-fw)을 매 프레임 다시 읽어 프레임 폭을 정한 뒤, 무대 폭에서 실제 프레임 너비를 뺀 여백(leftover)을 BG_BLUR_PX + leftover/14 로 바꿔 배경에 blur() 로 적용합니다.
function apply(t) {
var frameWrapStyle = getComputedStyle(frameWrapEl);
var BASE_FW = parseFloat(frameWrapStyle.getPropertyValue('--base-fw')) || 118;
var AMP_FW = parseFloat(frameWrapStyle.getPropertyValue('--amp-fw')) || 16;
var fw = BASE_FW + AMP_FW * Math.sin(2 * Math.PI * t / CYCLE_MS);
frameEl.style.width = fw.toFixed(1) + 'px';
var leftover = stageEl.clientWidth - frameEl.getBoundingClientRect().width;
var blur = BG_BLUR_PX + leftover / 14;
bgEl.style.filter = 'blur(' + blur.toFixed(1) + 'px)';
readoutEl.textContent = '여백 ' + Math.round(leftover) + 'px · 흐림 ' + blur.toFixed(1) + 'px';
}

<main class="stage">
<canvas class="bg" id="bg" width="480" height="300" aria-hidden="true"></canvas>
<section class="panel">
<div class="frameWrap">
<div class="frame" id="frame">
<canvas class="thumb" id="thumb" width="120" height="213"></canvas>
<div class="playBtn" role="button" aria-label="재생">
<span class="playShape" aria-hidden="true"></span>
</div>
<div class="captionBar">
<span class="captionText">세로 영상 9:16</span>
</div>
</div>
</div>
<div class="readout" id="readout">여백 148px · 흐림 17px</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 세로형 임베드 프레임 — 프레임 폭 애니메이션은 JS 계산 겹(leftover → blur)의 재료라 rule1 예외.
// CSS keyframes 는 두지 않는다 — 전부 rAF 실시간 루프.
@include stage($stage-paper);
.stage {
position: relative;
overflow: hidden;
}
.bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 0;
will-change: filter;
}
.panel {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.frameWrap {
display: grid;
place-items: center;
--base-fw: 118;
--amp-fw: 16;
}
.frame {
position: relative;
aspect-ratio: 9 / 16;
border-radius: $r-card;
overflow: hidden;
background: $stage-ink;
box-shadow: $shadow-raised;
}
.thumb {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.playBtn {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.playShape {
width: 0;
height: 0;
border-style: solid;
border-width: 11px 0 11px 17px;
border-color: transparent transparent transparent $subject-cream;
filter: drop-shadow(0 1px 2px rgba(23, 20, 26, .4));
opacity: .92;
}
.captionBar {
position: absolute;
inset-inline: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
padding: $sp-2 $sp-1;
background: rgba(23, 20, 26, .66);
backdrop-filter: blur(4px);
}
.captionText {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 11px;
line-height: 1.3;
color: $subject-cream;
opacity: .9;
}
.readout {
display: inline-flex;
align-items: center;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-blue-deep;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.3;
color: $subject-cream;
}
@media (max-width: 340px) {
.frameWrap { --base-fw: 62; --amp-fw: 10; }
.panel { gap: $sp-1; }
.captionText { font-size: 10px; }
.readout { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bg { filter: blur(8px); }
}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);
}
.stage {
position: relative;
overflow: hidden;
}
.bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 0;
will-change: filter;
}
.panel {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.frameWrap {
display: grid;
place-items: center;
--base-fw: 118;
--amp-fw: 16;
}
.frame {
position: relative;
aspect-ratio: 9/16;
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.thumb {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.playBtn {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.playShape {
width: 0;
height: 0;
border-style: solid;
border-width: 11px 0 11px 17px;
border-color: transparent transparent transparent #fff7e6;
filter: drop-shadow(0 1px 2px rgba(23, 20, 26, 0.4));
opacity: 0.92;
}
.captionBar {
position: absolute;
inset-inline: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 8px 4px;
background: rgba(23, 20, 26, 0.66);
backdrop-filter: blur(4px);
}
.captionText {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 11px;
line-height: 1.3;
color: #fff7e6;
opacity: 0.9;
}
.readout {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 999px;
background: #0a4fe8;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.3;
color: #fff7e6;
}
@media (max-width: 340px) {
.frameWrap {
--base-fw: 62;
--amp-fw: 10;
}
.panel {
gap: 4px;
}
.captionText {
font-size: 10px;
}
.readout {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bg {
filter: blur(8px);
}
}// 세로형 임베드 프레임(04) — 프레임 폭이 숨쉬듯 늘고 줄면, 남는 스테이지 폭만큼
// 뒤 배경이 흐려진다(초점이 앞으로 온 느낌). 실키·실서버 요청 없음 — 자동 재생 시나리오.
(function () {
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);
}
var stageEl = document.querySelector('.stage');
var bgEl = document.getElementById('bg');
var thumbEl = document.getElementById('thumb');
var frameEl = document.getElementById('frame');
var readoutEl = document.getElementById('readout');
if (!stageEl || !bgEl || !thumbEl || !frameEl || !readoutEl) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
drawSeedThumb(bgEl, 104);
drawSeedThumb(thumbEl, 140);
var CYCLE_MS = 2000;
var BG_BLUR_PX = 8;
// 폭 밑값·진폭은 scss 커스텀 프로퍼티에서 프레임마다 다시 읽는다 — 320px 미디어쿼리가 값을 낮추는데
// 시작 폭에서 한 번만 캡처하면 뷰포트가 줄어도 큰 값이 남아 BD9 가 깨진다(04 실측 2026-09-19: 134×238px → 289px).
var frameWrapEl = frameEl.parentElement;
function apply(t) {
var frameWrapStyle = getComputedStyle(frameWrapEl);
var BASE_FW = parseFloat(frameWrapStyle.getPropertyValue('--base-fw')) || 118;
var AMP_FW = parseFloat(frameWrapStyle.getPropertyValue('--amp-fw')) || 16;
var fw = BASE_FW + AMP_FW * Math.sin(2 * Math.PI * t / CYCLE_MS);
frameEl.style.width = fw.toFixed(1) + 'px';
// 계산 겹: 남는 스테이지 폭 → 배경 흐림 강도. 프레임이 커질수록 뒤 배경은 덜 흐리다.
var leftover = stageEl.clientWidth - frameEl.getBoundingClientRect().width;
var blur = BG_BLUR_PX + leftover / 14;
bgEl.style.filter = 'blur(' + blur.toFixed(1) + 'px)';
readoutEl.textContent = '여백 ' + Math.round(leftover) + 'px · 흐림 ' + blur.toFixed(1) + 'px';
}
if (reduce) {
apply(0);
return;
}
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./EmbedShortsVertical.module.scss";
// 세로형 임베드 프레임 — 프레임 폭이 숨쉬듯 늘고 줄면, 남는 스테이지 폭만큼
// 뒤 배경이 흐려진다(초점이 앞으로 온 느낌). 실키·실서버 요청 없음.
const CYCLE_MS = 2000;
const BG_BLUR_PX = 8;
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);
}
export default function EmbedShortsVertical() {
const stageRef = useRef<HTMLDivElement>(null);
const bgRef = useRef<HTMLCanvasElement>(null);
const thumbRef = useRef<HTMLCanvasElement>(null);
const frameWrapRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<HTMLDivElement>(null);
const readoutRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const stageEl = stageRef.current;
const bgEl = bgRef.current;
const thumbEl = thumbRef.current;
const frameWrapEl = frameWrapRef.current;
const frameEl = frameRef.current;
const readoutEl = readoutRef.current;
if (!stageEl || !bgEl || !thumbEl || !frameWrapEl || !frameEl || !readoutEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
drawSeedThumb(bgEl, 104);
drawSeedThumb(thumbEl, 140);
function apply(t: number) {
// 폭 밑값·진폭은 매 프레임 다시 읽는다 — 320px 미디어쿼리가 낮춘 값을 시작 폭 캡처로 놓치면 BD9 가 깨진다.
const frameWrapStyle = getComputedStyle(frameWrapEl!);
const baseFw = parseFloat(frameWrapStyle.getPropertyValue("--base-fw")) || 118;
const ampFw = parseFloat(frameWrapStyle.getPropertyValue("--amp-fw")) || 16;
const fw = baseFw + ampFw * Math.sin((2 * Math.PI * t) / CYCLE_MS);
frameEl!.style.width = fw.toFixed(1) + "px";
// 계산 겹: 남는 스테이지 폭 → 배경 흐림 강도.
const leftover = stageEl!.clientWidth - frameEl!.getBoundingClientRect().width;
const blur = BG_BLUR_PX + leftover / 14;
bgEl!.style.filter = "blur(" + blur.toFixed(1) + "px)";
readoutEl!.textContent = "여백 " + Math.round(leftover) + "px · 흐림 " + blur.toFixed(1) + "px";
}
if (reduce) {
apply(0);
return;
}
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage} ref={stageRef}>
<canvas className={styles.bg} ref={bgRef} width={480} height={300} aria-hidden="true" />
<section className={styles.panel}>
<div className={styles.frameWrap} ref={frameWrapRef}>
<div className={styles.frame} ref={frameRef}>
<canvas className={styles.thumb} ref={thumbRef} width={120} height={213} />
<div className={styles.playBtn} role="button" aria-label="재생">
<span className={styles.playShape} aria-hidden="true" />
</div>
<div className={styles.captionBar}>
<span className={styles.captionText}>세로 영상 9:16</span>
</div>
</div>
</div>
<div className={styles.readout} ref={readoutRef}>
여백 148px · 흐림 17px
</div>
</section>
</div>
);
}04 · 세로형 쇼츠 임베드 — 남는 폭만큼 배경 흐림을 다시 구하는 프레임 — 측정 누적면적 25.935% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
05틱톡 임베드 목업 — 경과 시간만큼 좋아요 숫자를 다시 구하는 카드
하트가 일정 간격마다 튕기면서 좋아요 숫자가 그 간격만큼 정수로 올라갑니다.
간격마다 숫자를 올리고 튕김을 겹치는 계산
likes = LIKE_BASE + Math.floor(t/LIKE_INTERVAL)*LIKE_STEP 로 숫자를 계단식으로 올리고, 같은 간격 안의 경과(since)를 하트 팝 애니메이션·버스트 확산·플래시 세 가지 투명도·크기 계산에 나눠 씁니다.
function apply(t) {
var likes = LIKE_BASE + Math.floor(t / LIKE_INTERVAL) * LIKE_STEP;
var since = t % LIKE_INTERVAL;
var pop = since < 180 ? 1 + 0.28 * (1 - since / 180) : 1;
heartWrapEl.style.transform = 'scale(' + pop.toFixed(3) + ')';
likeCountEl.textContent = likes.toLocaleString('en-US');
readoutEl.textContent = '간격 ' + (LIKE_INTERVAL / 1000).toFixed(1) + '초 · 좋아요 ' + likes.toLocaleString('en-US');
var prog = since / LIKE_INTERVAL;
var burstScale = 0.3 + prog * 2.3;
var burstOpacity = Math.max(0, 0.85 * (1 - prog * 1.15));
burstEl.style.transform = 'translate(-50%, -50%) scale(' + burstScale.toFixed(3) + ')';
burstEl.style.opacity = burstOpacity.toFixed(3);
var flashOpacity = since < 160 ? 0.4 * (1 - since / 160) : 0;
flashEl.style.opacity = flashOpacity.toFixed(3);
}

<main class="stage">
<div class="card">
<div class="left">
<div class="thumbWrap" id="thumbWrap">
<canvas class="thumb" id="thumb" width="132" height="176"></canvas>
<div class="flash" id="flash" aria-hidden="true"></div>
<div class="burst" id="burst" aria-hidden="true">
<svg class="burstIcon" viewBox="0 0 256 256"><g fill="currentColor">
<path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/>
<path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/>
</g></svg>
</div>
</div>
<p class="caption">@channelA · 오리지널 사운드</p>
</div>
<div class="rail">
<div class="heartWrap" id="heartWrap">
<svg class="heartIcon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor">
<path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/>
<path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/>
</g></svg>
</div>
<span class="likeCount" id="likeCount">1,247</span>
<span class="railLabel">좋아요</span>
</div>
</div>
<div class="readout" id="readout">간격 0.4초 · 좋아요 1,247</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 세로형 카드 임베드 — 좋아요 계단 카운터(계산 겹)와 하트 팝은 JS 계산 겹의 재료.
// CSS keyframes 는 두지 않는다 — 전부 rAF 실시간 루프.
@include stage($stage-ink);
.stage {
overflow: hidden;
}
.card {
display: flex;
align-items: stretch;
gap: $sp-3;
padding: $sp-3;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
--thumb-w: 120px;
}
.left {
display: flex;
flex-direction: column;
gap: $sp-2;
min-width: 0;
width: var(--thumb-w, 120px);
flex-shrink: 0;
}
.thumbWrap {
position: relative;
width: var(--thumb-w, 120px);
aspect-ratio: 3 / 4;
border-radius: $r-sm;
overflow: hidden;
}
.thumb {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.flash {
position: absolute;
inset: 0;
background: $subject-cream;
opacity: 0;
pointer-events: none;
}
.burst {
position: absolute;
top: 50%;
left: 50%;
width: 46px;
height: 46px;
color: $subject-cream;
opacity: 0;
pointer-events: none;
}
.burstIcon {
width: 100%;
height: 100%;
display: block;
filter: drop-shadow(0 2px 6px rgba(23, 20, 26, .5));
}
.caption {
margin: 0;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 11px;
line-height: 1.4;
color: $subject-ink;
overflow: hidden;
text-overflow: clip;
white-space: nowrap;
}
.rail {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-1;
width: 40px;
flex-shrink: 0;
}
.heartWrap {
width: 24px;
height: 24px;
display: block;
}
.heartIcon {
width: 100%;
height: 100%;
display: block;
}
.heartIcon path {
fill: $subject-pink;
}
.likeCount {
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.2;
color: $subject-ink;
}
.railLabel {
writing-mode: vertical-rl;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 9px;
line-height: 1;
color: $subject-ink;
opacity: .72;
}
.readout {
position: relative;
margin-top: $sp-3;
display: inline-flex;
align-items: center;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-blue-deep;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.3;
color: $subject-cream;
}
@media (max-width: 340px) {
.card {
padding: $sp-2;
gap: $sp-2;
--thumb-w: 56px;
}
.rail {
width: 30px;
}
.heartWrap {
width: 20px;
height: 20px;
}
.caption {
font-size: 10px;
}
.likeCount {
font-size: 10px;
}
.railLabel {
font-size: 8px;
}
.readout {
font-size: 10px;
padding: $sp-1 $sp-2;
margin-top: $sp-2;
}
}
@media (prefers-reduced-motion: reduce) {
.burst,
.flash {
opacity: 0;
}
}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);
}
.stage {
overflow: hidden;
}
.card {
display: flex;
align-items: stretch;
gap: 12px;
padding: 12px;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
--thumb-w: 120px;
}
.left {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
width: var(--thumb-w, 120px);
flex-shrink: 0;
}
.thumbWrap {
position: relative;
width: var(--thumb-w, 120px);
aspect-ratio: 3/4;
border-radius: 8px;
overflow: hidden;
}
.thumb {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.flash {
position: absolute;
inset: 0;
background: #fff7e6;
opacity: 0;
pointer-events: none;
}
.burst {
position: absolute;
top: 50%;
left: 50%;
width: 46px;
height: 46px;
color: #fff7e6;
opacity: 0;
pointer-events: none;
}
.burstIcon {
width: 100%;
height: 100%;
display: block;
filter: drop-shadow(0 2px 6px rgba(23, 20, 26, 0.5));
}
.caption {
margin: 0;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 11px;
line-height: 1.4;
color: #17141a;
overflow: hidden;
text-overflow: clip;
white-space: nowrap;
}
.rail {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
width: 40px;
flex-shrink: 0;
}
.heartWrap {
width: 24px;
height: 24px;
display: block;
}
.heartIcon {
width: 100%;
height: 100%;
display: block;
}
.heartIcon path {
fill: #ff9ebb;
}
.likeCount {
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.2;
color: #17141a;
}
.railLabel {
writing-mode: vertical-rl;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 9px;
line-height: 1;
color: #17141a;
opacity: 0.72;
}
.readout {
position: relative;
margin-top: 12px;
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 999px;
background: #0a4fe8;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.3;
color: #fff7e6;
}
@media (max-width: 340px) {
.card {
padding: 8px;
gap: 8px;
--thumb-w: 56px;
}
.rail {
width: 30px;
}
.heartWrap {
width: 20px;
height: 20px;
}
.caption {
font-size: 10px;
}
.likeCount {
font-size: 10px;
}
.railLabel {
font-size: 8px;
}
.readout {
font-size: 10px;
padding: 4px 8px;
margin-top: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.burst,
.flash {
opacity: 0;
}
}// 세로형 카드 임베드(05) — 좋아요는 상수 간격마다 계단으로 오르고, 오른 순간 하트가 튕긴다.
// 엄지가 두 번 두드릴 때 나오는 큰 하트도 같은 간격으로 썸네일 위에서 커지며 사라진다.
// 실키·실서버 요청 없음 — 좌표·수치는 고정 시나리오로 자동 재생한다.
(function () {
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);
}
var thumbEl = document.getElementById('thumb');
var flashEl = document.getElementById('flash');
var burstEl = document.getElementById('burst');
var heartWrapEl = document.getElementById('heartWrap');
var likeCountEl = document.getElementById('likeCount');
var readoutEl = document.getElementById('readout');
if (!thumbEl || !flashEl || !burstEl || !heartWrapEl || !likeCountEl || !readoutEl) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
drawSeedThumb(thumbEl, 105);
var CYCLE_MS = 2000;
var LIKE_INTERVAL = 400;
var LIKE_STEP = 3;
var LIKE_BASE = 1247;
function apply(t) {
// 계산 겹: 간격마다 좋아요 수를 다시 계산 — 트윈이 아니라 계단식 카운터
var likes = LIKE_BASE + Math.floor(t / LIKE_INTERVAL) * LIKE_STEP;
var since = t % LIKE_INTERVAL;
var pop = since < 180 ? 1 + 0.28 * (1 - since / 180) : 1;
heartWrapEl.style.transform = 'scale(' + pop.toFixed(3) + ')';
likeCountEl.textContent = likes.toLocaleString('en-US');
readoutEl.textContent = '간격 ' + (LIKE_INTERVAL / 1000).toFixed(1) + '초 · 좋아요 ' + likes.toLocaleString('en-US');
// 두 번 두드림 하트 — 같은 간격에서 0→1로 커지며 옅어진다(썸네일 위 큰 겹)
var prog = since / LIKE_INTERVAL;
var burstScale = 0.3 + prog * 2.3;
var burstOpacity = Math.max(0, 0.85 * (1 - prog * 1.15));
burstEl.style.transform = 'translate(-50%, -50%) scale(' + burstScale.toFixed(3) + ')';
burstEl.style.opacity = burstOpacity.toFixed(3);
// 좋아요가 오르는 순간 썸네일 전체가 짧게 밝아진다(플래시) — 하트 겹과 같은 since 를 공유
var flashOpacity = since < 160 ? 0.4 * (1 - since / 160) : 0;
flashEl.style.opacity = flashOpacity.toFixed(3);
}
if (reduce) {
apply(0);
return;
}
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./EmbedTiktok.module.scss";
// 세로형 카드 임베드 — 좋아요는 상수 간격마다 계단으로 오르고, 오른 순간 하트가 튕긴다.
// 두 번 두드림 하트도 같은 간격으로 썸네일 위에서 커지며 사라진다. 실키·실서버 요청 없음.
const CYCLE_MS = 2000;
const LIKE_INTERVAL = 400;
const LIKE_STEP = 3;
const LIKE_BASE = 1247;
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);
}
export default function EmbedTiktok() {
const thumbRef = useRef<HTMLCanvasElement>(null);
const flashRef = useRef<HTMLDivElement>(null);
const burstRef = useRef<HTMLDivElement>(null);
const heartWrapRef = useRef<HTMLDivElement>(null);
const likeCountRef = useRef<HTMLSpanElement>(null);
const readoutRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const thumbEl = thumbRef.current;
const flashEl = flashRef.current;
const burstEl = burstRef.current;
const heartWrapEl = heartWrapRef.current;
const likeCountEl = likeCountRef.current;
const readoutEl = readoutRef.current;
if (!thumbEl || !flashEl || !burstEl || !heartWrapEl || !likeCountEl || !readoutEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
drawSeedThumb(thumbEl, 105);
function apply(t: number) {
// 계산 겹: 간격마다 좋아요 수를 다시 계산 — 트윈이 아니라 계단식 카운터
const likes = LIKE_BASE + Math.floor(t / LIKE_INTERVAL) * LIKE_STEP;
const since = t % LIKE_INTERVAL;
const pop = since < 180 ? 1 + 0.28 * (1 - since / 180) : 1;
heartWrapEl!.style.transform = "scale(" + pop.toFixed(3) + ")";
likeCountEl!.textContent = likes.toLocaleString("en-US");
readoutEl!.textContent = "간격 " + (LIKE_INTERVAL / 1000).toFixed(1) + "초 · 좋아요 " + likes.toLocaleString("en-US");
// 두 번 두드림 하트 — 같은 간격에서 0→1로 커지며 옅어진다(썸네일 위 큰 겹)
const prog = since / LIKE_INTERVAL;
const burstScale = 0.3 + prog * 2.3;
const burstOpacity = Math.max(0, 0.85 * (1 - prog * 1.15));
burstEl!.style.transform = "translate(-50%, -50%) scale(" + burstScale.toFixed(3) + ")";
burstEl!.style.opacity = burstOpacity.toFixed(3);
// 좋아요가 오르는 순간 썸네일 전체가 짧게 밝아진다(플래시) — 하트 겹과 같은 since 를 공유
const flashOpacity = since < 160 ? 0.4 * (1 - since / 160) : 0;
flashEl!.style.opacity = flashOpacity.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 t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage}>
<div className={styles.card}>
<div className={styles.left}>
<div className={styles.thumbWrap}>
<canvas className={styles.thumb} ref={thumbRef} width={132} height={176} />
<div className={styles.flash} ref={flashRef} aria-hidden="true" />
<div className={styles.burst} ref={burstRef} aria-hidden="true">
<svg className={styles.burstIcon} viewBox="0 0 256 256">
<g fill="currentColor">
<path
d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54"
opacity=".2"
/>
<path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8" />
</g>
</svg>
</div>
</div>
<p className={styles.caption}>@channelA · 오리지널 사운드</p>
</div>
<div className={styles.rail}>
<div className={styles.heartWrap} ref={heartWrapRef}>
<svg className={styles.heartIcon} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path
d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54"
opacity=".2"
/>
<path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8" />
</g>
</svg>
</div>
<span className={styles.likeCount} ref={likeCountRef}>
1,247
</span>
<span className={styles.railLabel}>좋아요</span>
</div>
</div>
<div className={styles.readout} ref={readoutRef}>
간격 0.4초 · 좋아요 1,247
</div>
</div>
);
}05 · 틱톡 임베드 목업 — 경과 시간만큼 좋아요 숫자를 다시 구하는 카드 — 측정 누적면적 13.598% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
06인스타그램 포스트 임베드 카드 — 각도를 계속 다시 구해 흐르는 로딩 그라디언트
로딩 자리의 그라디언트가 각도를 계속 다시 구하며 회전하고, 그 위로 반짝임 한 줄이 폭을 가로질러 지나갑니다.
진행률 하나로 각도와 반짝임 위치를 같이 구하는 계산
angle = Math.floor((t/CYCLE_MS)*360) 을 CSS 변수 --spin 에 그대로 넘겨 그라디언트를 돌리고, 같은 진행률(prog)을 반짝임 줄의 가로 이동(sweep) 계산에도 같이 씁니다.
function apply(t) {
var angle = Math.floor((t / CYCLE_MS) * 360);
slotEl.style.setProperty('--spin', angle + 'deg');
var prog = t / CYCLE_MS;
var sweep = -30 + prog * 160;
shimmerEl.style.transform = 'translateX(' + sweep.toFixed(1) + '%)';
readoutEl.textContent = '각도 ' + angle + '°';
}

<main class="stage">
<div class="card">
<div class="topBar">
<canvas class="avatar" id="avatar" width="64" height="64"></canvas>
<span class="handle">channel.a</span>
<span class="follow">팔로우</span>
</div>
<div class="slot" id="slot">
<div class="shimmer" id="shimmer" aria-hidden="true"></div>
</div>
<div class="bottomRow">
<svg class="heartIcon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor">
<path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/>
<path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/>
</g></svg>
<span class="likeLabel">좋아요 128</span>
<span class="caption">새 게시물 미리보기</span>
</div>
</div>
<div class="readout" id="readout">각도 214°</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 사각 카드 임베드 — 그라디언트 회전각(계산 겹)과 shimmer 이동이 JS 계산 겹의 재료.
// CSS keyframes 는 두지 않는다 — 전부 rAF 실시간 루프.
@include stage($stage-yellow);
.stage {
overflow: hidden;
}
.card {
display: flex;
flex-direction: column;
width: 220px;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
overflow: hidden;
}
.topBar {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
}
.avatar {
display: block;
width: 28px;
height: 28px;
border-radius: 50%;
flex-shrink: 0;
}
.handle {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 12px;
line-height: 1.3;
color: $subject-ink;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: clip;
white-space: nowrap;
}
.follow {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 11px;
line-height: 1.3;
color: $subject-blue-deep;
flex-shrink: 0;
}
.slot {
position: relative;
height: 120px;
overflow: hidden;
--spin: 0deg;
background: linear-gradient(var(--spin), $subject-pink, $subject-sky, $subject-lilac);
}
.shimmer {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 40%;
background: linear-gradient(
90deg,
rgba(255, 247, 230, 0) 0%,
rgba(255, 247, 230, .38) 50%,
rgba(255, 247, 230, 0) 100%
);
pointer-events: none;
}
.bottomRow {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
}
.heartIcon {
width: 18px;
height: 18px;
display: block;
flex-shrink: 0;
}
.heartIcon path {
fill: $subject-pink;
}
.likeLabel {
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.3;
color: $subject-ink;
flex-shrink: 0;
}
.caption {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
color: $subject-ink;
opacity: .72;
overflow: hidden;
text-overflow: clip;
white-space: nowrap;
min-width: 0;
}
.readout {
margin-top: $sp-3;
display: inline-flex;
align-items: center;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.3;
color: $subject-ink;
}
@media (max-width: 340px) {
.card {
width: 156px;
}
.slot {
height: 64px;
}
.topBar {
padding: $sp-1 $sp-2;
}
.avatar {
width: 22px;
height: 22px;
}
.bottomRow {
padding: $sp-1 $sp-2;
}
.handle {
font-size: 11px;
}
.caption {
font-size: 10px;
}
.readout {
font-size: 10px;
padding: $sp-1 $sp-2;
margin-top: $sp-2;
}
}
@media (prefers-reduced-motion: reduce) {
.shimmer {
opacity: 0;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
overflow: hidden;
}
.card {
display: flex;
flex-direction: column;
width: 220px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.topBar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
}
.avatar {
display: block;
width: 28px;
height: 28px;
border-radius: 50%;
flex-shrink: 0;
}
.handle {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 12px;
line-height: 1.3;
color: #17141a;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: clip;
white-space: nowrap;
}
.follow {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 11px;
line-height: 1.3;
color: #0a4fe8;
flex-shrink: 0;
}
.slot {
position: relative;
height: 120px;
overflow: hidden;
--spin: 0deg;
background: linear-gradient(var(--spin), #ff9ebb, #6ec8ff, #b9a0ff);
}
.shimmer {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 40%;
background: linear-gradient(90deg, rgba(255, 247, 230, 0) 0%, rgba(255, 247, 230, 0.38) 50%, rgba(255, 247, 230, 0) 100%);
pointer-events: none;
}
.bottomRow {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
}
.heartIcon {
width: 18px;
height: 18px;
display: block;
flex-shrink: 0;
}
.heartIcon path {
fill: #ff9ebb;
}
.likeLabel {
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.3;
color: #17141a;
flex-shrink: 0;
}
.caption {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
color: #17141a;
opacity: 0.72;
overflow: hidden;
text-overflow: clip;
white-space: nowrap;
min-width: 0;
}
.readout {
margin-top: 12px;
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.3;
color: #17141a;
}
@media (max-width: 340px) {
.card {
width: 156px;
}
.slot {
height: 64px;
}
.topBar {
padding: 4px 8px;
}
.avatar {
width: 22px;
height: 22px;
}
.bottomRow {
padding: 4px 8px;
}
.handle {
font-size: 11px;
}
.caption {
font-size: 10px;
}
.readout {
font-size: 10px;
padding: 4px 8px;
margin-top: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.shimmer {
opacity: 0;
}
}// 사각 카드 임베드(06) — 이미지 자리는 각도가 도는 그라디언트(계산 겹)로 채우고,
// 그 위를 옅은 띠(shimmer)가 가로질러 로딩 느낌을 더한다. 실키·실서버 요청 없음.
(function () {
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);
}
var avatarEl = document.getElementById('avatar');
var slotEl = document.getElementById('slot');
var shimmerEl = document.getElementById('shimmer');
var readoutEl = document.getElementById('readout');
if (!avatarEl || !slotEl || !shimmerEl || !readoutEl) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
drawSeedThumb(avatarEl, 106);
var CYCLE_MS = 2000;
function apply(t) {
// 계산 겹: 경과 시간 비율 → 그라디언트 회전각(0~360도)
var angle = Math.floor((t / CYCLE_MS) * 360);
slotEl.style.setProperty('--spin', angle + 'deg');
var prog = t / CYCLE_MS;
var sweep = -30 + prog * 160;
shimmerEl.style.transform = 'translateX(' + sweep.toFixed(1) + '%)';
readoutEl.textContent = '각도 ' + angle + '°';
}
if (reduce) {
apply(0);
return;
}
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./EmbedInstagram.module.scss";
// 사각 카드 임베드 — 이미지 자리는 각도가 도는 그라디언트(계산 겹)로 채우고,
// 그 위를 옅은 띠(shimmer)가 가로질러 로딩 느낌을 더한다. 실키·실서버 요청 없음.
const CYCLE_MS = 2000;
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);
}
export default function EmbedInstagram() {
const avatarRef = useRef<HTMLCanvasElement>(null);
const slotRef = useRef<HTMLDivElement>(null);
const shimmerRef = useRef<HTMLDivElement>(null);
const readoutRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const avatarEl = avatarRef.current;
const slotEl = slotRef.current;
const shimmerEl = shimmerRef.current;
const readoutEl = readoutRef.current;
if (!avatarEl || !slotEl || !shimmerEl || !readoutEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
drawSeedThumb(avatarEl, 106);
function apply(t: number) {
// 계산 겹: 경과 시간 비율 → 그라디언트 회전각(0~360도)
const angle = Math.floor((t / CYCLE_MS) * 360);
slotEl!.style.setProperty("--spin", angle + "deg");
const prog = t / CYCLE_MS;
const sweep = -30 + prog * 160;
shimmerEl!.style.transform = "translateX(" + sweep.toFixed(1) + "%)";
readoutEl!.textContent = "각도 " + angle + "°";
}
if (reduce) {
apply(0);
return;
}
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage}>
<div className={styles.card}>
<div className={styles.topBar}>
<canvas className={styles.avatar} ref={avatarRef} width={64} height={64} />
<span className={styles.handle}>channel.a</span>
<span className={styles.follow}>팔로우</span>
</div>
<div className={styles.slot} ref={slotRef}>
<div className={styles.shimmer} ref={shimmerRef} aria-hidden="true" />
</div>
<div className={styles.bottomRow}>
<svg className={styles.heartIcon} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path
d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54"
opacity=".2"
/>
<path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8" />
</g>
</svg>
<span className={styles.likeLabel}>좋아요 128</span>
<span className={styles.caption}>새 게시물 미리보기</span>
</div>
</div>
<div className={styles.readout} ref={readoutRef}>
각도 214°
</div>
</div>
);
}06 · 인스타그램 포스트 임베드 카드 — 각도를 계속 다시 구해 흐르는 로딩 그라디언트 — 측정 누적면적 18.658% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
07X 포스트 임베드 프레임 — 테두리 밝기를 주기적으로 다시 구해 빛나는 카드
인용문 카드 테두리가 사인 곡선을 따라 밝아졌다 어두워졌다를 반복합니다.
밝기 값 하나를 CSS 변수로 넘기는 계산
glow = 0.5 + 0.5*Math.sin(2*Math.PI*t/GLOW_MS) 를 CSS 변수 --glow 에 그대로 대입해 테두리 그라디언트 밝기를 정하고, 같은 값을 백분율로 바꿔 리드아웃에도 적습니다.
function apply(t) {
var glow = 0.5 + 0.5 * Math.sin(2 * Math.PI * t / GLOW_MS);
cardEl.style.setProperty('--glow', glow.toFixed(3));
glowValueEl.textContent = Math.round(glow * 100) + '%';
}
if (reduce) {
apply(0);
return;
}
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}

<main class="stage">
<section class="panel">
<div class="card" id="card">
<div class="frameLine" aria-hidden="true"></div>
<div class="avatarRow">
<canvas class="avatar" id="avatar" width="32" height="32" aria-hidden="true"></canvas>
<div class="who">
<span class="name">Channel A</span>
<span class="handle">@channel_a</span>
</div>
</div>
<p class="quoteBody">임베드로 가져온 인용 문장이 이 자리에 들어갑니다.</p>
<p class="meta">오후 2:14 · 인용됨</p>
</div>
<div class="readout" id="readout" aria-live="polite">
<span class="readoutLabel">테두리 밝기</span>
<span class="readoutValue" id="glowValue">50%</span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.card {
position: relative;
box-sizing: border-box;
width: 240px;
padding: $sp-4;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
color: $subject-ink;
}
.frameLine {
position: absolute;
inset: 0;
border: 2px solid $subject-ink;
border-radius: $r-card;
// 계산 겹: JS 가 매 프레임 사인파로 다시 구한 --glow 값을 opacity 로 반영해 테두리를 맥동시킨다
opacity: calc(0.14 + 0.5 * var(--glow, 0));
pointer-events: none;
}
.avatarRow {
display: flex;
align-items: center;
gap: $sp-2;
margin-bottom: $sp-3;
}
.avatar {
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: $r-pill;
}
.who {
display: flex;
flex-direction: column;
gap: $sp-1;
}
.name {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 13px;
line-height: 1.3;
}
.handle {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 400;
font-size: 12px;
line-height: 1.3;
opacity: .72;
}
.quoteBody {
margin: 0 0 $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 400;
font-size: 14px;
line-height: 1.5;
}
.meta {
margin: 0;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 400;
font-size: 11px;
line-height: 1.3;
opacity: .78;
}
.readout {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
}
.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; padding: $sp-2 $sp-3; }
.avatar { width: 26px; height: 26px; }
.avatarRow { margin-bottom: $sp-2; }
.name { font-size: 12px; }
.handle { font-size: 11px; }
.quoteBody { margin-bottom: $sp-1; font-size: 12px; }
.meta { font-size: 10px; }
.readoutLabel { font-size: 10px; }
.readoutValue { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.frameLine { opacity: .3; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
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: 16px;
}
.card {
position: relative;
box-sizing: border-box;
width: 240px;
padding: 16px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
color: #17141a;
}
.frameLine {
position: absolute;
inset: 0;
border: 2px solid #17141a;
border-radius: 16px;
opacity: calc(0.14 + 0.5 * var(--glow, 0));
pointer-events: none;
}
.avatarRow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.avatar {
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: 999px;
}
.who {
display: flex;
flex-direction: column;
gap: 4px;
}
.name {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 13px;
line-height: 1.3;
}
.handle {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 400;
font-size: 12px;
line-height: 1.3;
opacity: 0.72;
}
.quoteBody {
margin: 0 0 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 400;
font-size: 14px;
line-height: 1.5;
}
.meta {
margin: 0;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 400;
font-size: 11px;
line-height: 1.3;
opacity: 0.78;
}
.readout {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
}
.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;
padding: 8px 12px;
}
.avatar {
width: 26px;
height: 26px;
}
.avatarRow {
margin-bottom: 8px;
}
.name {
font-size: 12px;
}
.handle {
font-size: 11px;
}
.quoteBody {
margin-bottom: 4px;
font-size: 12px;
}
.meta {
font-size: 10px;
}
.readoutLabel {
font-size: 10px;
}
.readoutValue {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.frameLine {
opacity: 0.3;
}
}// 인용 게시물 임베드(07) — 카드 테두리 발광 강도를 매 프레임 사인파로 다시 계산해
// CSS 변수로 주입한다. 실키·실서버 요청 없음, 리스너 없음(자동 재생만).
(function () {
var cardEl = document.getElementById('card');
var avatarEl = document.getElementById('avatar');
var glowValueEl = document.getElementById('glowValue');
if (!cardEl || !avatarEl || !glowValueEl) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var CYCLE_MS = 2000;
var GLOW_MS = 900;
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);
}
drawSeedThumb(avatarEl, 107);
// 계산 겹: 테두리 밝기를 사인파로 매 프레임 재계산해 --glow 로 주입, opacity 로 맥동시킨다
function apply(t) {
var glow = 0.5 + 0.5 * Math.sin(2 * Math.PI * t / GLOW_MS);
cardEl.style.setProperty('--glow', glow.toFixed(3));
glowValueEl.textContent = Math.round(glow * 100) + '%';
}
if (reduce) {
apply(0);
return;
}
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./EmbedXPost.module.scss";
// 인용 게시물 임베드 — 카드 테두리 발광 강도를 매 프레임 사인파로 다시 계산해
// CSS 변수로 주입한다. 실키·실서버 요청 없음. 리스너 없음(자동 재생만).
const CYCLE_MS = 2000;
const GLOW_MS = 900;
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);
}
export default function EmbedXPost() {
const cardRef = useRef<HTMLDivElement>(null);
const avatarRef = useRef<HTMLCanvasElement>(null);
const glowValueRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const card = cardRef.current;
const avatar = avatarRef.current;
const glowValueEl = glowValueRef.current;
if (!card || !avatar || !glowValueEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
drawSeedThumb(avatar, 107);
function apply(t: number) {
const glow = 0.5 + 0.5 * Math.sin((2 * Math.PI * t) / GLOW_MS);
card!.style.setProperty("--glow", glow.toFixed(3));
glowValueEl!.textContent = Math.round(glow * 100) + "%";
}
if (reduce) {
apply(0);
return;
}
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.card} ref={cardRef}>
<div className={styles.frameLine} aria-hidden="true" />
<div className={styles.avatarRow}>
<canvas className={styles.avatar} ref={avatarRef} width={32} height={32} aria-hidden="true" />
<div className={styles.who}>
<span className={styles.name}>Channel A</span>
<span className={styles.handle}>@channel_a</span>
</div>
</div>
<p className={styles.quoteBody}>임베드로 가져온 인용 문장이 이 자리에 들어갑니다.</p>
<p className={styles.meta}>오후 2:14 · 인용됨</p>
</div>
<div className={styles.readout} aria-live="polite">
<span className={styles.readoutLabel}>테두리 밝기</span>
<span className={styles.readoutValue} ref={glowValueRef}>50%</span>
</div>
</section>
</div>
);
}07 · X 포스트 임베드 프레임 — 테두리 밝기를 주기적으로 다시 구해 빛나는 카드 — 측정 누적면적 1.878% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
08클릭 후 로드 임베드 — 크기 차이를 계산해 부드럽게 커지는 플레이스홀더
누르기 전까지는 자리표시자 높이 그대로 있다가, 누르는 순간부터 실제 프레임 높이까지 곡선을 그리며 커집니다.
누른 시점부터 높이 차이를 보간하는 계산
누르기 전에는 placeholderEl.offsetHeight 를 그대로 카드 높이로 쓰고 힌트 화살표만 옅게 pulse 합니다. 누른 뒤에는 easeOutCubic 로 진행률 p 를 구해 phH + (FRAME_H - phH) * p 로 높이를 채우고, 진행률 55% 를 넘는 지점부터 프레임을 서서히 드러내며 리드아웃의 "현재 높이" 값(예: 168px)도 같이 갱신합니다.
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';
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
09동의 전 임베드 플레이스홀더 — 안내 문구 높이를 계산해 접히는 카드
"동의하고 불러오기" 를 누르기 전까지는 안내 문구가 자리를 차지하고, 누른 뒤에는 그 높이가 접히면서 프레임 높이로 그대로 넘어갑니다.
안내 높이를 프레임 높이로 맞바꾸는 계산
동의 전에는 상태 점 투명도만 사인 곡선으로 pulse 합니다. 동의 시점부터는 easeOutCubic 진행률 p 로 안내 높이(noticeH*(1-p))를 줄이는 동시에 프레임 높이(FRAME_BASE_H + noticeH*p)를 늘려, 줄어드는 만큼 정확히 늘어나는 맞바꿈으로 만듭니다.
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) + ')';
}

<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">이 영상은 외부 서비스에서 불러옵니다.</span>
</div>
<p class="noticeSub">동의하면 이 자리에 불러옵니다.</p>
<button type="button" class="consentBtn" id="consentBtn" aria-expanded="true">동의하고 불러오기</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">동의 후 재생되는 영상</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');
}
// 계산 겹: 동의 순간 안내 블록의 실측 높이(noticeH)를 접힘 진행도(p)에 맞춰
// 그대로 프레임 높이 증가분으로 넘긴다 — 안내가 접히는 만큼 프레임이 자란다.
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 · 동의 전 임베드 플레이스홀더 — 안내 문구 높이를 계산해 접히는 카드 — 측정 누적면적 37.148% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디서 쓰이나 — 블로그 본문과 상품 상세 페이지의 영상 자리
블로그 글 중간에 영상 하나를 넣을 때는 보통 01 의 반응형 프레임을 그대로 쓴다. 본문 폭이 화면 크기에 따라 달라지는 구조에서는 프레임 자체가 폭을 흔들 때마다 높이를 되읽어야, 유튜브 반응형 html 코드처럼 화면이 좁아져도 영상이 잘리지 않는다.
이커머스 상품 상세 페이지에서 후기 섹션 옆에 세로형 영상(04)을 붙이는 경우는 조금 다르다. 상품 이미지 갤러리와 폭을 나눠 써야 하므로 남는 공간이 매번 달라지고, 그 남는 폭만큼 배경을 흐리는 계산이 있어야 세로 프레임이 좌우 빈 공간에서 붕 뜬 것처럼 보이지 않는다.
개인정보 동의 배너를 먼저 띄우는 팀이라면 09 의 동의 전 플레이스홀더를 쿠키 배너 로직에 그대로 연결해 두면, 방문자가 동의 버튼을 누르기 전까지는 실제 임베드가 화면에 나타나지 않는다.
느린 네트워크·연속 클릭·동의 취소에서 계산이 깨지는 지점
느린 네트워크 — 라이트 파사드가 늦게 넘어간다
02 는 시간이 FACADE_DELAY 를 넘는지만 보고 상태를 바꾸므로, 실제 네트워크에서 진짜 iframe 로드가 그보다 오래 걸리면 화면은 이미 "완전한 프레임" 표시로 넘어갔는데 실제 영상은 아직 준비되지 않는 틈이 생길 수 있습니다. 유튜브 임베드 자동재생 옵션을 켜 둔 서비스라면 이 틈이 곧바로 재생 시작 지연으로 이어집니다.
연속 클릭 — 사이클이 돌아오면 처음부터 다시 시작된다
08 은 pressed 플래그가 참이 된 뒤로는 같은 사이클 안에서 클릭을 다시 읽지 않으므로, phy8esss 압축 파일 안의 원본 소스를 보면 알 수 있듯 한 사이클 안에서 몇 번을 눌러도 높이 보간은 이미 시작된 시점을 그대로 따라갑니다. 대신 루프가 2초 주기의 처음으로 돌아오는 순간 자리표시자 상태로 초기화되어, 다음 사이클의 자동 시점(PRESS_AT)이나 새 클릭을 다시 기다립니다.
동의 취소 — 접힘 뒤 다음 사이클에서 되돌아온다
09 는 동의가 이루어진 사이클 안에서는 접힘이 다시 펼쳐지지 않지만, 루프가 2초 주기의 처음으로 돌아오면 동의 전 상태로 초기화되어 안내 문구가 다시 나타납니다. 실제 서비스에서 "한번 동의하면 끝까지 유지"되는 모습을 원한다면 이 초기화를 건너뛰는 별도 처리를 직접 추가해야 합니다.
값이 화면을 바꾸는 곳 — 비율·경과 시간·밝기
같은 세 종류의 값이 아홉 화면에 반복해서 나타나므로, 어디서 무엇으로 바뀌는지를 따로 모아 둡니다.
비율 — 폭에서 높이·여백을 되구하는 곳
01 은 폭을 흔든 뒤 getBoundingClientRect() 로 실제 높이를 되읽어 16:9 라벨을 채우고, 04 는 같은 폭 계산을 반대로 써서 남는 여백을 배경 흐림 강도로 바꿉니다. 두 화면 모두 CSS 가 이미 정한 비율을 JS 가 다시 재는 방식이라, 실제 유튜브 반응형 html 코드에서 aspect-ratio 로 처리하는 부분과 값의 흐름이 같습니다.
경과 시간 — 지연·클릭·동의 시점을 기준으로 삼는 곳
02 는 FACADE_DELAY(1200ms) 를 넘는 순간 lite 에서 frame 으로 상태를 바꾸고, 08 은 클릭 시점(PRESS_AT) 뒤 EXPAND_MS 동안, 09 는 동의 시점(CONSENT_AT) 뒤 CONSENT_FOLD_MS 동안 각각 높이를 보간합니다. 세 화면 모두 "그 시점 이전엔 정적, 이후엔 곡선" 이라는 같은 뼈대를 씁니다.
밝기 — 사인 주기 하나로 여러 속성을 같이 흔드는 곳
03·07 은 각각 배지·테두리의 밝기를 사인 곡선 하나로 구하고, 그 값을 크기(03)나 CSS 변수(07)에 그대로 얹습니다. 트위터 영상 임베드처럼 07 도 밝기 강조 하나만으로 카드 등장을 눈에 띄게 하고, 05 의 하트 팝은 사인형이 아니라 선형 감쇠지만 "하나의 진행값을 여러 속성에 나눠 쓴다" 는 계산 구조는 같습니다.
세 가지 변형과 reduced-motion 을 같이 넣는 이유
| 변형 | 바꾸는 값 | 바뀌는 느낌 |
|---|---|---|
| 라이트 파사드 전환 지연 | 02 FACADE_DELAY 1200ms → 3000ms |
라이트 카드가 훨씬 오래 머물러, 진짜 로드를 기다리는 듯한 느낌이 강해집니다 |
| 동의 오버레이 접힘 시간 | 09 CONSENT_FOLD_MS 200ms → 500ms |
안내 문구가 천천히 접히면서 높이가 늘어나는 과정이 눈에 더 잘 보입니다 |
| 세로 프레임 배경 흐림 강도 | 04 BG_BLUR_PX 8px → 24px |
배경이 훨씬 흐려져 세로 프레임이 앞으로 튀어나오는 대비가 커집니다 |
reduced-motion 이 끄는 것과 남기는 것
아홉 화면 모두 prefers-reduced-motion 을 읽어, 켜져 있으면 apply(0) 한 번만 그리고 루프를 시작하지 않습니다. 08·09 처럼 실제 클릭·동의로 상태가 바뀌는 화면은 버튼에 키보드 포커스와 Enter/Space 입력을 함께 연결해, 마우스 없이도 같은 전환을 볼 수 있게 해 두었습니다.
임베드를 붙이기 전에 자주 나오는 질문
이 데모로 실제 유튜브 영상이 재생되나요?
아니요. 아홉 화면 모두 실제 iframe 이나 SDK 호출 없이 시간·클릭 값을 계산해 화면만 바꾸는 시뮬레이션입니다. 실제 재생이 필요하면 이 계산 위에 진짜 embed URL 을 얹어야 합니다.
라이트 파사드와 클릭 후 로드는 뭐가 다른가요?
02 는 시간이 지나면 저절로 완전한 프레임으로 넘어가고, 08 은 사용자가 실제로 눌러야만 높이 보간이 시작됩니다. 자동 전환이 필요하면 02, 데이터를 아끼면서 사용자 의도를 기다리려면 08 쪽 계산이 더 가깝습니다.
세로형 임베드나 재생 버튼까지 더 넣고 싶으면 어디를 보면 되나요?
세로 비율 계산을 더 다양한 화면비로 넓히려면 블로그 위젯 디자인 9종 의 카드 레이아웃을, 진행바 옆에 실제 재생·일시정지 버튼까지 붙이려면 동영상 플레이어 만들기 9종 을 함께 보면 이어서 만들 수 있습니다.
더 읽을거리
- 동영상 플레이어 만들기 9종 — 재생 버튼과 진행바를 파일 없이 계산으로 굴리는 아홉 화면
- 블로그 위젯 디자인 9종 — 지도·후기 카드 등 공유용 임베드 위젯 모음
- 드래그 앤 드롭 파일 업로드 UI — 업로드 자리 옆에 붙일 드롭존 아홉 화면
- aspect-ratio - CSS: Cascading Style Sheets | MDN
- prefers-reduced-motion - CSS: Cascading Style Sheets | MDN








