youtube 埋め込みコード UI 9選 — SNSカードもコピペOK
「youtube 埋め込みコード」の検索結果はiframeタグの取得方法ばかりですが、この記事はその外側のUI計算を9種示します。フレーム幅に合わせて縦の比率が求め直され、表示されてからの経過時間が一定値を超えると軽量カードが自動で完全なフレームに切り替わり、SNS埋め込みのバッジ数字が経過時間に応じて少しずつ増えてい
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 YouTubeレスポンシブ埋め込み — 幅に合わせて比率を求め直す動画フレーム
- 02 YouTubeライトファサード — 経過時間を計り完全なフレームへ切り替わる埋め込み
- 03 YouTubeプライバシー強化埋め込み — 明るさを周期的に求め直し脈打つバッジ
- 04 縦型ショート動画埋め込み — 余った幅の分だけ背景のぼかしを求め直すフレーム
- 05 TikTok埋め込みモック — 経過時間の分だけいいね数を求め直すカード
- 06 Instagram投稿埋め込みカード — 角度を求め直し続ける読み込みグラデーション
- 07 X投稿埋め込みフレーム — 枠線の明るさを周期的に求め直し光るカード
- 08 クリックで読み込む埋め込み — サイズ差を計算し滑らかに広がるプレースホルダー
- 09 同意前埋め込みプレースホルダー — 案内文の高さを計算して折りたたむカード
デモ
9項目とも「見た目の演出ではなく、幅の値・経過時間・明るさの値・余った幅のサイズ・間隔の回数・角度・押された瞬間のサイズ差という値を毎フレーム求め直して、フレームの高さ・カードの状態・バッジの光と大きさ・背景のぼかし強度・カウンターの数字・グラデーションの角度・カードの高さに反映する」という同じ前提を共有しています。実測値は次のとおりです。
| 番号 | 項目 | 累積面積 | 強度平均 | 動いたフレーム |
|---|---|---|---|---|
| 01 | YouTubeレスポンシブ埋め込み | 26.864% | 84.4 | 23/23 |
| 02 | YouTubeライトファサード | 7.068% | 100.6 | 23/23 |
| 03 | YouTubeプライバシー強化 | 1.876% | 59.5 | 23/23 |
| 04 | 縦型ショート埋め込み | 25.935% | 82.9 | 23/23 |
| 05 | TikTok埋め込みモック | 13.598% | 42.6 | 23/23 |
| 06 | Instagram投稿カード | 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 |
「youtube 埋め込み レスポンシブ」を探す人が確かめたいのはまさにこの01の動きで、カードの幅がsin波で変わるたびに高さが求め直され、その実測px値が画面に表示されます。09同意プレースホルダーが累積面積37.148%で最も広く、08クリックで読み込む31.389%・01レスポンシブ26.864%・04縦型25.935%・06Instagram18.658%・05TikTok13.598%・07X投稿1.878%・03プライバシーバッジ1.876%の順です(02ライトファサードは7.068%)。24フレームキャプチャ(デルタ23個)のうち動いたフレームは19(09)〜23(01〜07)で、08は22です。08・09だけが残りより少なく動くのは、実際のリスナーで状態が切り替わって初めてアニメーションが始まる状態遷移型だからで、切り替わる前の静止区間は自然な結果です。03プライバシーバッジと07X投稿の累積面積がそれぞれ1.876%・1.878%と9項目中もっとも小さいのも、バッジの脈動や枠線の明るさ変化のように画面上で実際に占めるピクセルの場所自体が小さい要素だからで、欠陥ではありません。強度平均は25.4(06、最も低い)〜128.4(09、最も高い)の間にあり、9項目とも画面が動き続ける下限値を余裕を持って満たしています。
埋め込みUIの解剖 — 9つの画面が計算するもの
「youtube 埋め込み コード 取得」で検索する人の多くは実際のiframeタグの取得手順を求めていますが、この記事が扱うのはその外側にあるUIの計算層です。9つの画面は、幅・経過時間・明るさ・余白・間隔・角度・サイズ差という数値を毎フレーム読み直し、フレームの寸法やバッジの光り方、背景のぼかし具合に変換し直す設計を共有しています。
何が入力で何が出力か
入力はカードの幅の値、表示されてからの経過時間、明るさを表す正弦波の値、画面幅から引いた余りの余白サイズ、固定間隔で割った経過時間、回転角度、押された・同意した瞬間の高さの差という7種類です。出力はDOMに直接書き込むインラインスタイル(幅・高さ・opacity・transform・filter)か、dataset・aria-*属性のどちらかで、キーフレームアニメーション自体は使いません。
01〜07は自動再生、08・09だけに実際のリスナー
9項目のうち01〜07はrequestAnimationFrameの固定周期による自動再生だけで実演されており、実際のクリック・ドラッグ・ポインターのリスナーは一つも付いていません。08(クリックで読み込む)と09(同意プレースホルダー)の2項目だけに実際のリスナー(08はclickとkeydown、09はclick)が付いていて、押された・同意した瞬間を起点に同じ計算が動き出します。
なぜ計算を一枚の層にまとめるのか
FACADE_DELAYやBG_BLUR_PXのような定数をあちこちに散らすと、1つの値を変えるたびに複数箇所を直す必要が出てきます。applyという一つの関数に計算をまとめておけば、定数を差し替えるだけで画面全体のつじつまが揃います。自分の埋め込みUIを実装するときも、同じように計算をひとつの層に集めておくと後から値を調整しやすくなります。
画面別コード
「sns 埋め込み デザイン」で探すと装飾だけを整えた静止画像が多く出てきますが、この9項目はその装飾の手前に計算の層を1枚重ねます。項目名にあるYouTube・TikTok・Instagram・Xという表記はいずれも見た目の系統を指す説明的な参照で、各社の実際の仕様やAPIバージョンを指すものではありません。実際のiframe src・SNSのSDKスクリプト・外部URLの呼び出しはどこにもなく、動画・プロフィール画像の場所はキャンバスに描いた固定シードのサムネイルと仮のチャンネル名(Channel A)で代用しています。似た計算の考え方は、動画ファイルなしで動くカスタム動画プレーヤーUI9種の再生バー計算にも共通しています。
01YouTubeレスポンシブ埋め込み — 幅に合わせて比率を求め直す動画フレーム
カードの幅をsin波で求め直してフレームに適用すると、CSSのaspect-ratio(16:9)が高さを自動で求め直します。getBoundingClientRectで実測した高さを、計算された幅と一緒に画面へ表示します。

<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 · YouTubeレスポンシブ埋め込み — 幅に合わせて比率を求め直す動画フレーム — 計測 累積面積 26.864% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
getBoundingClientRectがaspect-ratioの実測結果を読み直す
applyはBASE200px(狭い画面では140px)を中心にRANGE56px(狭い画面では36px)の振れ幅でCYCLE_MS2000msの周期の幅を求め直し、その幅をframeElに適用した直後にgetBoundingClientRectで実測した高さを読み直します。aspect-ratioはMDNのaspect-ratioの仕様どおり幅から高さを自動計算しますが、その計算結果をJavaScript側で確かめるには実測が必要です。
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);
02YouTubeライトファサード — 経過時間を計り完全なフレームへ切り替わる埋め込み
カードが表示されてから経過した時間をrequestAnimationFrameで求め直し続け、その値がFACADE_DELAY1200msを超えた瞬間だけサムネイルカードを再生フレーム状態へ切り替えます。閾値の前は状態を変えません。

<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 · YouTubeライトファサード — 経過時間を計り完全なフレームへ切り替わる埋め込み — 計測 累積面積 7.068% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
FACADE_DELAYとisFrameが軽量表示から再生フレームへの切り替えを決める
applyはt >= FACADE_DELAYの結果をisFrameという真偽値にし、それをcardEl.dataset.stateへ書き込むことでCSS側の見た目を切り替え、切り替わった後のprogressをTOTAL_SEC12秒の範囲で求め直して再生時間の表示に使います。
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);
03YouTubeプライバシー強化埋め込み — 明るさを周期的に求め直し脈打つバッジ
フレーム隅の鍵バッジの明るさの値を700ms周期の正弦波で求め直し、その値の上下をバッジの光の不透明度・バッジ自体の大きさに一緒に反映して脈打たせます。

<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">トラッキングCookieなし</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 · YouTubeプライバシー強化埋め込み — 明るさを周期的に求め直し脈打つバッジ — 計測 累積面積 1.876% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
700ms周期のpulseがバッジの不透明度と大きさを同時に動かす
applyはMath.sinで求めたpulseという1つの数値を、光の不透明度0.1+0.45*pulseとバッジのscale``1+0.04*pulseという2つのCSS値に同時に反映し、両者が同じ位相でずれずに動くようにしています。
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);
04縦型ショート動画埋め込み — 余った幅の分だけ背景のぼかしを求め直すフレーム
画面幅から縦型フレームの幅を引いた余りの余白サイズを計算し、その値を背景のぼかし強度(px)へ求め直してマッピングします。フレーム自体の幅もsin波で求め直します。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
leftoverの計算がBG_BLUR_PXを背景のぼかし強度に変える
applyはscssのカスタムプロパティから読み直したBASE_FW・AMP_FWでフレーム幅fwを求め、stageEl.clientWidthからその実測幅を引いたleftoverを、BG_BLUR_PX8pxを基準にしたblurという1つの値へ変換し直します。
var CYCLE_MS = 2000;
var BG_BLUR_PX = 8;
// 幅の基準値・振幅はscssのカスタムプロパティからフレームごとに読み直す — 340pxのメディアクエリが値を下げるが、
// 開始時の幅で一度だけ取得すると、ビューポートが縮んでも大きい値が残り縦横比の検証が崩れる(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;
}
05TikTok埋め込みモック — 経過時間の分だけいいね数を求め直すカード
カードが表示されてから経過した時間を固定間隔(LIKE_INTERVAL)で割り、その間隔を過ぎるたびにいいね数をLIKE_STEPだけ足した値を求め直して表示します。

<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 · TikTok埋め込みモック — 経過時間の分だけいいね数を求め直すカード — 計測 累積面積 13.598% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
LIKE_INTERVALで割った商がいいね数を階段状に増やす
applyはMath.floor(t / LIKE_INTERVAL)という商をLIKE_STEP3倍してLIKE_BASE1247に足すことで、連続したトゥイーンではなく400ms間隔で3ずつ増える階段状のカウンターを作ります。
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);
}
06Instagram投稿埋め込みカード — 角度を求め直し続ける読み込みグラデーション
画像の場所のグラデーション角度(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 · Instagram投稿埋め込みカード — 角度を求め直し続ける読み込みグラデーション — 計測 累積面積 18.658% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
angleとsweepが読み込み中グラデーションとハイライトを同時に回す
applyはt / CYCLE_MSという経過時間の比率をangleという0〜360度の値に変換して--spinへ注入し、同じ比率から求めたsweepでハイライトの横位置も一緒にずらします。
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;
}
07X投稿埋め込みフレーム — 枠線の明るさを周期的に求め直し光るカード
カードの枠線の明るさの値を900ms周期の正弦波で求め直し、その値の上下の分だけ枠線がほのかに光っては落ち着くことを繰り返します。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
GLOW_MSの正弦波がCSS変数--glowへ枠線の明るさを注入する
applyはGLOW_MS900ms周期のMath.sinでglowを求め、それをcardElの--glowというCSS変数にsetPropertyで注入するだけで、枠線の光り方の計算とCSS側の描画を分けています。
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);
}
08クリックで読み込む埋め込み — サイズ差を計算し滑らかに広がるプレースホルダー
実際のクリックまたはキーボード(Enter/Space)入力を受け取り、押された状態に切り替わった瞬間のプレースホルダーの高さと実際のフレームの高さ(FRAME_H168px、狭い画面128px)の差を計算し、EXPAND_MS500msの間で補間してカードを広げます。

<main class="stage">
<section class="panel">
<div class="card" id="card">
<div class="placeholder" id="placeholder" role="button" tabindex="0" aria-label="再生項目を読み込む" aria-pressed="false">
<span class="playIcon" aria-hidden="true"></span>
<span class="phLabel">押して読み込む</span>
<span class="phSub">約0.5MB節約</span>
<span class="hintArrow" id="hintArrow" aria-hidden="true"></span>
</div>
<div class="frame" id="frame" aria-hidden="true">
<canvas class="frameThumb" id="frameThumb" width="240" height="120"></canvas>
<div class="frameScrim"></div>
<div class="frameContent">
<span class="framePlay" aria-hidden="true"></span>
<span class="frameLabel">読み込んだ動画フレーム</span>
</div>
<div class="progressTrack">
<div class="progressBar" id="progressBar"></div>
</div>
</div>
</div>
<div class="readout" id="readout" aria-live="polite">
<span class="readoutLabel">現在の高さ</span>
<span class="readoutValue" id="heightValue">0px</span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.card {
position: relative;
box-sizing: border-box;
width: 240px;
overflow: hidden;
border-radius: $r-control;
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
box-sizing: border-box;
width: 100%;
padding: $sp-4 $sp-3;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
cursor: pointer;
}
.playIcon {
width: 0;
height: 0;
margin-bottom: $sp-1;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-left: 14px solid $subject-ink;
}
.phLabel {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 13px;
line-height: 1.3;
}
.phSub {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 400;
font-size: 11px;
line-height: 1.3;
opacity: .72;
}
.hintArrow {
width: 0;
height: 0;
margin-top: $sp-1;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 8px solid $subject-ink;
opacity: .86;
}
.frame {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
opacity: 0;
border-radius: $r-control;
overflow: hidden;
}
.frameThumb {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.frameScrim {
position: absolute;
inset: 0;
background: rgba($stage-ink, .68);
}
.frameContent {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-2;
color: $subject-cream;
}
.framePlay {
width: 0;
height: 0;
border-top: 9px solid transparent;
border-bottom: 9px solid transparent;
border-left: 16px solid $subject-cream;
}
.frameLabel {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 12px;
line-height: 1.3;
}
.progressTrack {
position: relative;
width: 100%;
height: 4px;
background: rgba($subject-cream, .24);
}
.progressBar {
height: 100%;
width: 100%;
transform-origin: left center; // 채움 막대가 시작 변에서 자라난다 (BD8 예외 대상)
transform: scaleX(0);
background: $subject-cream;
}
.readout {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
}
.readoutLabel {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
opacity: .84;
}
.readoutValue {
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 13px;
line-height: 1.3;
}
@media (max-width: 340px) {
.panel { gap: $sp-2; }
.card { width: 200px; }
.placeholder { padding: $sp-3 $sp-2; }
.phLabel { font-size: 12px; }
.phSub { font-size: 10px; }
.frameLabel { font-size: 11px; }
.readoutLabel { font-size: 10px; }
.readoutValue { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.hintArrow { opacity: .86; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.card {
position: relative;
box-sizing: border-box;
width: 240px;
overflow: hidden;
border-radius: 12px;
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
box-sizing: border-box;
width: 100%;
padding: 16px 12px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
cursor: pointer;
}
.playIcon {
width: 0;
height: 0;
margin-bottom: 4px;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-left: 14px solid #17141a;
}
.phLabel {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 13px;
line-height: 1.3;
}
.phSub {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 400;
font-size: 11px;
line-height: 1.3;
opacity: 0.72;
}
.hintArrow {
width: 0;
height: 0;
margin-top: 4px;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 8px solid #17141a;
opacity: 0.86;
}
.frame {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
opacity: 0;
border-radius: 12px;
overflow: hidden;
}
.frameThumb {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.frameScrim {
position: absolute;
inset: 0;
background: rgba(23, 20, 26, 0.68);
}
.frameContent {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: #fff7e6;
}
.framePlay {
width: 0;
height: 0;
border-top: 9px solid transparent;
border-bottom: 9px solid transparent;
border-left: 16px solid #fff7e6;
}
.frameLabel {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 12px;
line-height: 1.3;
}
.progressTrack {
position: relative;
width: 100%;
height: 4px;
background: rgba(255, 247, 230, 0.24);
}
.progressBar {
height: 100%;
width: 100%;
transform-origin: left center;
transform: scaleX(0);
background: #fff7e6;
}
.readout {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
}
.readoutLabel {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
opacity: 0.84;
}
.readoutValue {
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 13px;
line-height: 1.3;
}
@media (max-width: 340px) {
.panel {
gap: 8px;
}
.card {
width: 200px;
}
.placeholder {
padding: 12px 8px;
}
.phLabel {
font-size: 12px;
}
.phSub {
font-size: 10px;
}
.frameLabel {
font-size: 11px;
}
.readoutLabel {
font-size: 10px;
}
.readoutValue {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.hintArrow {
opacity: 0.86;
}
}// 클릭으로 불러오는 지연 임베드(08) — 자리표시를 실측한 높이를 기준으로
// easeOutCubic 보간해 카드 높이를 계산으로 늘린다. 실키·실서버 요청 없음.
// 클릭 시 즉시 펼침(실사용자 리스너 있음).
(function () {
var cardEl = document.getElementById('card');
var placeholderEl = document.getElementById('placeholder');
var frameEl = document.getElementById('frame');
var frameThumbEl = document.getElementById('frameThumb');
var hintArrowEl = document.getElementById('hintArrow');
var progressBarEl = document.getElementById('progressBar');
var heightValueEl = document.getElementById('heightValue');
if (!cardEl || !placeholderEl || !frameEl || !frameThumbEl || !hintArrowEl || !progressBarEl || !heightValueEl) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var NARROW = window.matchMedia('(max-width: 340px)').matches;
var CYCLE_MS = 2000;
var PRESS_AT = 900;
var EXPAND_MS = 500;
var FRAME_H = NARROW ? 128 : 168;
var HINT_MS = 700;
function mulberry32(seed) {
var a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function drawSeedThumb(canvas, seed) {
var ctx = canvas.getContext('2d');
if (!ctx) return;
var rnd = mulberry32(seed);
var w = canvas.width, h = canvas.height;
var hues = [216, 32, 268, 168];
ctx.fillStyle = 'hsl(' + hues[Math.floor(rnd() * hues.length)] + ', 38%, 22%)';
ctx.fillRect(0, 0, w, h);
for (var i = 0; i < 6; i++) {
var cx = rnd() * w, cy = rnd() * h, r = (0.18 + rnd() * 0.3) * Math.min(w, h);
ctx.fillStyle = 'hsla(' + hues[Math.floor(rnd() * hues.length)] + ', ' + (44 + rnd() * 22).toFixed(1) + '%, ' + (38 + rnd() * 26).toFixed(1) + '%, .5)';
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill();
}
ctx.fillStyle = 'rgba(255, 247, 230, .06)';
for (var j = 0; j < h; j += 4) ctx.fillRect(0, j, w, 1);
}
function easeOutCubic(p) { return 1 - Math.pow(1 - p, 3); }
drawSeedThumb(frameThumbEl, 108);
var pressed = false;
var pressTime = null;
var pressAtOverride = false;
var phH = 0;
var prevT = 0;
function enterPressed(t) {
pressed = true;
pressTime = t;
pressAtOverride = false;
phH = placeholderEl.offsetHeight;
placeholderEl.setAttribute('aria-pressed', 'true');
}
placeholderEl.addEventListener('click', function () {
pressAtOverride = true;
});
placeholderEl.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
pressAtOverride = true;
}
});
function resetToIdle() {
pressed = false;
pressTime = null;
pressAtOverride = false;
phH = 0;
cardEl.style.height = '';
frameEl.style.opacity = '0';
progressBarEl.style.transform = 'scaleX(0)';
placeholderEl.setAttribute('aria-pressed', 'false');
}
// 계산 겹: 자리표시의 실측 高さ(phH)에서 FRAME_H 까지 easeOutCubic 으로 보간해
// 카드 높이를 계산한다. 절반을 넘으면 실 프레임이 페이드로 등장하고,
// 펼침이 끝난 뒤엔 남은 시간 동안 진행 바가 채워진다.
function apply(t) {
var h;
var hintOpacity = 0.86 + 0.14 * Math.sin(2 * Math.PI * t / HINT_MS);
if (!pressed) {
h = placeholderEl.offsetHeight;
cardEl.style.height = h + 'px';
hintArrowEl.style.opacity = hintOpacity.toFixed(3);
} else {
var elapsed = t - pressTime;
if (elapsed < 0) elapsed += CYCLE_MS;
var delta = FRAME_H - phH;
var p = easeOutCubic(Math.min(1, elapsed / EXPAND_MS));
h = phH + delta * p;
cardEl.style.height = h.toFixed(1) + 'px';
var frameOpacity = p <= 0.55 ? 0 : Math.min(1, (p - 0.55) / 0.45);
frameEl.style.opacity = frameOpacity.toFixed(3);
var sweepSpan = CYCLE_MS - PRESS_AT - EXPAND_MS;
var sweepP = Math.max(0, Math.min(1, (elapsed - EXPAND_MS) / sweepSpan));
progressBarEl.style.transform = 'scaleX(' + sweepP.toFixed(3) + ')';
}
heightValueEl.textContent = '高さ ' + Math.round(h) + 'px';
}
if (reduce) {
apply(0);
return;
}
var start = null;
function loop(ts) {
if (start === null) start = ts;
var raw = ts - start;
var t = raw % CYCLE_MS;
if (t < prevT) resetToIdle();
prevT = t;
if (!pressed && (pressAtOverride || t >= PRESS_AT)) enterPressed(t);
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./EmbedLazyClickToLoad.module.scss";
// 클릭으로 불러오는 지연 임베드 — 자리표시의 실측 높이를 기준으로
// easeOutCubic 보간해 카드 높이를 계산으로 늘린다. 실키·실서버 요청 없음.
// 클릭 시 즉시 펼침(실사용자 리스너 있음).
const CYCLE_MS = 2000;
const PRESS_AT = 900;
const EXPAND_MS = 500;
const HINT_MS = 700;
function mulberry32(seed: number) {
let a = seed >>> 0;
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function drawSeedThumb(canvas: HTMLCanvasElement, seed: number) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const rnd = mulberry32(seed);
const w = canvas.width;
const h = canvas.height;
const hues = [216, 32, 268, 168];
ctx.fillStyle = "hsl(" + hues[Math.floor(rnd() * hues.length)] + ", 38%, 22%)";
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < 6; i++) {
const cx = rnd() * w;
const cy = rnd() * h;
const r = (0.18 + rnd() * 0.3) * Math.min(w, h);
ctx.fillStyle =
"hsla(" +
hues[Math.floor(rnd() * hues.length)] +
", " +
(44 + rnd() * 22).toFixed(1) +
"%, " +
(38 + rnd() * 26).toFixed(1) +
"%, .5)";
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = "rgba(255, 247, 230, .06)";
for (let j = 0; j < h; j += 4) ctx.fillRect(0, j, w, 1);
}
function easeOutCubic(p: number) {
return 1 - Math.pow(1 - p, 3);
}
export default function EmbedLazyClickToLoad() {
const cardRef = useRef<HTMLDivElement>(null);
const placeholderRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<HTMLDivElement>(null);
const frameThumbRef = useRef<HTMLCanvasElement>(null);
const hintArrowRef = useRef<HTMLSpanElement>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
const heightValueRef = useRef<HTMLSpanElement>(null);
const pressAtOverrideRef = useRef(false);
useEffect(() => {
const card = cardRef.current;
const placeholder = placeholderRef.current;
const frame = frameRef.current;
const frameThumb = frameThumbRef.current;
const hintArrow = hintArrowRef.current;
const progressBar = progressBarRef.current;
const heightValueEl = heightValueRef.current;
if (!card || !placeholder || !frame || !frameThumb || !hintArrow || !progressBar || !heightValueEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const narrow = window.matchMedia("(max-width: 340px)").matches;
const FRAME_H = narrow ? 128 : 168;
drawSeedThumb(frameThumb, 108);
let pressed = false;
let pressTime = 0;
let phH = 0;
let prevT = 0;
function enterPressed(t: number) {
pressed = true;
pressTime = t;
pressAtOverrideRef.current = false;
phH = placeholder!.offsetHeight;
placeholder!.setAttribute("aria-pressed", "true");
}
function resetToIdle() {
pressed = false;
phH = 0;
card!.style.height = "";
frame!.style.opacity = "0";
progressBar!.style.transform = "scaleX(0)";
placeholder!.setAttribute("aria-pressed", "false");
}
// 계산 겹: 자리표시의 실측 높이(phH)에서 FRAME_H 까지 easeOutCubic 으로 보간해
// 카드 높이를 계산한다. 절반을 넘으면 실 프레임이 페이드로 등장하고,
// 펼침이 끝난 뒤엔 남은 시간 동안 진행 바가 채워진다.
function apply(t: number) {
let h: number;
const hintOpacity = 0.86 + 0.14 * Math.sin((2 * Math.PI * t) / HINT_MS);
if (!pressed) {
h = placeholder!.offsetHeight;
card!.style.height = h + "px";
hintArrow!.style.opacity = hintOpacity.toFixed(3);
} else {
let elapsed = t - pressTime;
if (elapsed < 0) elapsed += CYCLE_MS;
const delta = FRAME_H - phH;
const p = easeOutCubic(Math.min(1, elapsed / EXPAND_MS));
h = phH + delta * p;
card!.style.height = h.toFixed(1) + "px";
const frameOpacity = p <= 0.55 ? 0 : Math.min(1, (p - 0.55) / 0.45);
frame!.style.opacity = frameOpacity.toFixed(3);
const sweepSpan = CYCLE_MS - PRESS_AT - EXPAND_MS;
const sweepP = Math.max(0, Math.min(1, (elapsed - EXPAND_MS) / sweepSpan));
progressBar!.style.transform = "scaleX(" + sweepP.toFixed(3) + ")";
}
heightValueEl!.textContent = "높이 " + Math.round(h) + "px";
}
if (reduce) {
apply(0);
return;
}
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const raw = ts - start;
const t = raw % CYCLE_MS;
if (t < prevT) resetToIdle();
prevT = t;
if (!pressed && (pressAtOverrideRef.current || t >= PRESS_AT)) enterPressed(t);
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
function handlePress() {
pressAtOverrideRef.current = true;
}
function handleKeyDown(e: { key: string; preventDefault(): void }) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
pressAtOverrideRef.current = true;
}
}
return (
<div className={styles.stage}>
<section className={styles.panel}>
<div className={styles.card} ref={cardRef}>
<div
className={styles.placeholder}
ref={placeholderRef}
role="button"
tabIndex={0}
aria-label="재생 항목 불러오기"
aria-pressed="false"
onClick={handlePress}
onKeyDown={handleKeyDown}
>
<span className={styles.playIcon} aria-hidden="true" />
<span className={styles.phLabel}>눌러서 불러오기</span>
<span className={styles.phSub}>약 0.5MB 절약</span>
<span className={styles.hintArrow} ref={hintArrowRef} aria-hidden="true" />
</div>
<div className={styles.frame} ref={frameRef} aria-hidden="true">
<canvas className={styles.frameThumb} ref={frameThumbRef} width={240} height={120} />
<div className={styles.frameScrim} />
<div className={styles.frameContent}>
<span className={styles.framePlay} aria-hidden="true" />
<span className={styles.frameLabel}>불러온 영상 프레임</span>
</div>
<div className={styles.progressTrack}>
<div className={styles.progressBar} ref={progressBarRef} />
</div>
</div>
</div>
<div className={styles.readout} aria-live="polite">
<span className={styles.readoutLabel}>현재 높이</span>
<span className={styles.readoutValue} ref={heightValueRef}>0px</span>
</div>
</section>
</div>
);
}08 · クリックで読み込む埋め込み — サイズ差を計算し滑らかに広がるプレースホルダー — 計測 累積面積 31.389% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
deltaとeaseOutCubicがプレースホルダーの高さをFRAME_Hへ補間する
applyはPRESS_AT900msを過ぎるか実際のクリックで押された瞬間の実測高さphHを基準に、FRAME_Hとの差deltaをeaseOutCubicで滑らかに補間し、途中から実フレームをフェードで重ねます。
// 計算層: プレースホルダーの実測高さ(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';
}
09同意前埋め込みプレースホルダー — 案内文の高さを計算して折りたたむカード
実際のクリック入力で同意が示された瞬間の案内文欄の高さ(noticeH)を計算し、CONSENT_FOLD_MS200msの間で案内欄を折りたたみ、同じ高さだけ下のフレームを広げます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
noticeHの実測値がCONSENT_FOLD_MSの間で案内欄を折りたたむ
enterConsentは同意された瞬間にnoticeEl.offsetHeightをnoticeHとして一度だけ実測し、applyはその値をeaseOutCubicの進行度pに掛けて案内欄の高さを減らしながら、FRAME_BASE_H96px(狭い画面56px)を基準にフレームの高さへ同じ分を足します。
// 計算層: 同意した瞬間の案内ブロックの実測高さ(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) + ')';
}
どこに置くか — ブログ本文と商品詳細ページ
ブログ記事の本文に動画を1本挟むだけなら、01のレスポンシブ埋め込みをそのまま使うことが多い。本文の幅が画面サイズで変わる構成では、フレーム自身が幅の変化のたびに高さを求め直さないと、狭い画面で映像が切れて見える。
ECサイトの商品詳細ページでレビュー欄の横に縦型動画(04)を置く場合は事情が違う。商品画像ギャラリーと横幅を分け合うため余った幅が画面ごとに変わり、その余った幅を背景のぼかし強度に変える計算がないと、縦型フレームだけが余白に浮いて見えてしまう。
同意バナーを先に出す方針のチームなら、09の同意前プレースホルダーをそのバナーのロジックにそのままつなげておけば、訪問者が同意ボタンを押すまで実際の埋め込みは画面に現れない。
低速回線や同意の取り消しでどこが壊れるか
低速回線でもライトファサードの切り替えタイミングは変わらない
02ライトファサードのFACADE_DELAYは表示されてからの経過時間だけを基準にしており、実際のネットワーク速度や本物の動画の読み込み完了は計算に入っていません。低速回線で実際の再生開始が遅れる場面を再現したい場合、この計算層だけでは足りず、実測の読み込み完了イベントを別に待つ処理が必要になります。
連続クリックしてもクリックロードの状態は一度しか切り替わらない
08クリックで読み込むのenterPressedは!pressedの条件付きでしか呼ばれないため、押された状態になった後にプレースホルダーを何度クリックしてもphHは上書きされず、広がるアニメーションが最初からやり直されることもありません。ただしCYCLE_MS2000msの周期が一巡してloopのtが0へ戻るときはresetToIdle()が呼ばれ、pressedがfalseへ初期化されて次の周期で同じ展開が最初から実演されます。しきい値を超えた瞬間に状態が切り替わるという考え方は、ドロップゾーンUI9種の状態遷移とも共通しています。
同意を取り消す入力自体がこの実装にはない
09同意プレースホルダーのconsentBtnElにはconsentOverrideをtrueにするクリックリスナーしか付いておらず、同意を取り消す専用の入力はこの9項目のどこにも実装されていません。ただしloopのtが周期の先頭へ戻るときはresetToIdle()が呼ばれ、consentedがfalseへ初期化されて案内欄がもう一度現れます。これは周期の巻き戻しによる自動初期化であって取り消し操作ではないため、実際の同意管理UIで一度同意した状態を維持するには、この初期化を飛ばす処理を別に用意する必要があります。
数値が画面を変える場所 — 比率・経過時間・明るさ
幅の比率がフレームの高さを動かす場所
01レスポンシブ埋め込みでは、フレーム幅という1つの数値がaspect-ratioによって高さという別の数値へ自動で計算し直され、その実測結果をgetBoundingClientRectがもう一度読み直して画面に表示します。幅を直接高さに書き込むのではなく、ブラウザの計算結果を確かめ直すという1段階を挟んでいる点がポイントです。
経過時間の閾値が状態を切り替える場所
02ライトファサードと08クリックで読み込むでは、経過時間という連続した数値がFACADE_DELAYやPRESS_ATという閾値をまたいだ瞬間だけ、カードの状態という離散した値を1回だけ切り替えます。連続した数値の変化が、状態という不連続な値を動かすという関係です。
余白のサイズが背景のぼかしを動かす場所
04縦型ショート動画埋め込みでは、画面幅からフレーム幅を引いたleftoverという1つの数値が、BG_BLUR_PXを基準にしたblurというCSSのぼかし強度へ求め直されます。余白が大きいほどぼかしが強くなるという、片方向の対応関係です。
3つのバリエーションとreduced-motionの扱い
紙のポスターに9枚の見本を並べるのと違い、この9種は同じ見た目のまま定数を1つ変えるだけで体感の軽さが変わります。9種の埋め込みUIはすべてphy8esssで開くzipにまとめられており、vanillaフォルダには9項目それぞれのindex.htmlが、reactフォルダには同じ計算をコンポーネント化したtsxファイルが入っています。
| 名前 | 値 | 感じ方 |
|---|---|---|
| ライトファサードの切り替え遅延 | 02 FACADE_DELAY 1200ms → 3000ms |
遅延を1200msに短くすると軽量カードから実フレームのような見た目へ切り替わるのが速すぎて効果を体感しづらく、3000msに延ばすと偽のサムネイル状態が長く保たれ読み込みが遅く見えます |
| 同意オーバーレイの折りたたみ時間 | 09 CONSENT_FOLD_MS 200ms → 500ms |
200msに短くすると案内文が消えた瞬間にフレームが現れ2つの状態がぶつ切りに見え、500msに延ばすと折りたたむ過程自体が目に入り切り替えが滑らかに感じられます |
| 縦型フレーム背景のぼかし強度 | 04 BG_BLUR_PX 8px → 24px |
8pxと弱くすると左右余白の背景がくっきり見えて縦型カードが浮いて見え、24pxと強く上げると背景がほぼ潰れて縦型フレームに視線が自然と集まります |
prefers-reduced-motionをどう扱うか
9項目ともwindow.matchMedia('(prefers-reduced-motion: reduce)')の結果をreduceという変数に持ち、真であればapply(0)を1回呼ぶだけで自動再生のrAFループ自体は回しません。08・09は元々実際のリスナーで動く状態遷移型なので、reduceが真でも押された・同意した瞬間の計算そのものは消えず、繰り返しの脈動や周期的な再計算だけが止まります。
コピー&ペーストで動く理由
9項目とも定数・関数・クラス名を1つのindex.htmlの中だけで完結させ、外部のライブラリやCDNは一切呼び出しません。フォルダを開いてブラウザに読み込ませるだけで、追加のビルド手順なしに同じ計算がそのまま動き出します。自分の埋め込みUIを実装する際も、名前付き定数の値だけ差し替えれば同じ計算の層をそのまま流用できます。
埋め込みを組み込む前によくある質問
この9種は実際のYouTube・SNSと通信していますか
いいえ。9項目とも実際のiframe src・SNSのSDKスクリプト・外部URLの呼び出しはどこにもなく、動画・プロフィール画像の場所はキャンバスに描いた固定シードのサムネイルと仮のチャンネル名(Channel A)で代用しています。クライアント側の計算だけで画面が動きます。
「sns 埋め込み html」のような実装をこの9種の中でそのまま使えますか
この9種はSNSの実際の埋め込みhtml・SDKを再現したものではなく、幅・経過時間・明るさといった値がUIをどう動かすかを学ぶための計算シミュレーションです。html・css・jsの3つのタブに分けたコードをそのまま自分のページに貼り付けて、見た目の計算だけを流用する使い方に向いています。
08・09にリスナーがあるのに、なぜ01〜07には付いていませんか
01〜07は幅の変化や経過時間、明るさの周期といった「時間が経てば自動で変わる」性質を実演するための項目で、requestAnimationFrameの固定周期だけで動きを見せる設計です。08・09は逆に「実際に押された・同意した」という利用者の操作を起点に状態が変わる項目なので、その起点を再現するために実際のリスナーが必要でした。








