04세로형 쇼츠 임베드 — 남는 폭만큼 배경 흐림을 다시 구하는 프레임
화면 폭에서 세로 프레임이 차지하는 폭을 뺀 나머지 여백 크기를 계산해, 그 여백이 클수록 양옆 배경의 흐림 강도를 더 진하게 다시 구하는 임베드입니다.
쓰임모바일 클립 모음 페이지, 쇼츠 소개 랜딩 섹션
backdrop-filteraspect-ratiowill-change

<main class="stage">
<canvas class="bg" id="bg" width="480" height="300" aria-hidden="true"></canvas>
<section class="panel">
<div class="frameWrap">
<div class="frame" id="frame">
<canvas class="thumb" id="thumb" width="120" height="213"></canvas>
<div class="playBtn" role="button" aria-label="재생">
<span class="playShape" aria-hidden="true"></span>
</div>
<div class="captionBar">
<span class="captionText">세로 영상 9:16</span>
</div>
</div>
</div>
<div class="readout" id="readout">여백 148px · 흐림 17px</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 세로형 임베드 프레임 — 프레임 폭 애니메이션은 JS 계산 겹(leftover → blur)의 재료라 rule1 예외.
// CSS keyframes 는 두지 않는다 — 전부 rAF 실시간 루프.
@include stage($stage-paper);
.stage {
position: relative;
overflow: hidden;
}
.bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 0;
will-change: filter;
}
.panel {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.frameWrap {
display: grid;
place-items: center;
--base-fw: 118;
--amp-fw: 16;
}
.frame {
position: relative;
aspect-ratio: 9 / 16;
border-radius: $r-card;
overflow: hidden;
background: $stage-ink;
box-shadow: $shadow-raised;
}
.thumb {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.playBtn {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.playShape {
width: 0;
height: 0;
border-style: solid;
border-width: 11px 0 11px 17px;
border-color: transparent transparent transparent $subject-cream;
filter: drop-shadow(0 1px 2px rgba(23, 20, 26, .4));
opacity: .92;
}
.captionBar {
position: absolute;
inset-inline: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
padding: $sp-2 $sp-1;
background: rgba(23, 20, 26, .66);
backdrop-filter: blur(4px);
}
.captionText {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 11px;
line-height: 1.3;
color: $subject-cream;
opacity: .9;
}
.readout {
display: inline-flex;
align-items: center;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-blue-deep;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.3;
color: $subject-cream;
}
@media (max-width: 340px) {
.frameWrap { --base-fw: 62; --amp-fw: 10; }
.panel { gap: $sp-1; }
.captionText { font-size: 10px; }
.readout { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bg { filter: blur(8px); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
overflow: hidden;
}
.bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 0;
will-change: filter;
}
.panel {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.frameWrap {
display: grid;
place-items: center;
--base-fw: 118;
--amp-fw: 16;
}
.frame {
position: relative;
aspect-ratio: 9/16;
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.thumb {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.playBtn {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.playShape {
width: 0;
height: 0;
border-style: solid;
border-width: 11px 0 11px 17px;
border-color: transparent transparent transparent #fff7e6;
filter: drop-shadow(0 1px 2px rgba(23, 20, 26, 0.4));
opacity: 0.92;
}
.captionBar {
position: absolute;
inset-inline: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 8px 4px;
background: rgba(23, 20, 26, 0.66);
backdrop-filter: blur(4px);
}
.captionText {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 11px;
line-height: 1.3;
color: #fff7e6;
opacity: 0.9;
}
.readout {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 999px;
background: #0a4fe8;
font-family: "Space Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.3;
color: #fff7e6;
}
@media (max-width: 340px) {
.frameWrap {
--base-fw: 62;
--amp-fw: 10;
}
.panel {
gap: 4px;
}
.captionText {
font-size: 10px;
}
.readout {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bg {
filter: blur(8px);
}
}// 세로형 임베드 프레임(04) — 프레임 폭이 숨쉬듯 늘고 줄면, 남는 스테이지 폭만큼
// 뒤 배경이 흐려진다(초점이 앞으로 온 느낌). 실키·실서버 요청 없음 — 자동 재생 시나리오.
(function () {
function mulberry32(seed) {
var a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function drawSeedThumb(canvas, seed) {
var ctx = canvas.getContext('2d');
if (!ctx) return;
var rnd = mulberry32(seed);
var w = canvas.width, h = canvas.height;
var hues = [216, 32, 268, 168];
ctx.fillStyle = 'hsl(' + hues[Math.floor(rnd() * hues.length)] + ', 38%, 22%)';
ctx.fillRect(0, 0, w, h);
for (var i = 0; i < 6; i++) {
var cx = rnd() * w, cy = rnd() * h, r = (0.18 + rnd() * 0.3) * Math.min(w, h);
ctx.fillStyle = 'hsla(' + hues[Math.floor(rnd() * hues.length)] + ', ' + (44 + rnd() * 22).toFixed(1) + '%, ' + (38 + rnd() * 26).toFixed(1) + '%, .5)';
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill();
}
ctx.fillStyle = 'rgba(255, 247, 230, .06)';
for (var j = 0; j < h; j += 4) ctx.fillRect(0, j, w, 1);
}
var stageEl = document.querySelector('.stage');
var bgEl = document.getElementById('bg');
var thumbEl = document.getElementById('thumb');
var frameEl = document.getElementById('frame');
var readoutEl = document.getElementById('readout');
if (!stageEl || !bgEl || !thumbEl || !frameEl || !readoutEl) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
drawSeedThumb(bgEl, 104);
drawSeedThumb(thumbEl, 140);
var CYCLE_MS = 2000;
var BG_BLUR_PX = 8;
// 폭 밑값·진폭은 scss 커스텀 프로퍼티에서 프레임마다 다시 읽는다 — 320px 미디어쿼리가 값을 낮추는데
// 시작 폭에서 한 번만 캡처하면 뷰포트가 줄어도 큰 값이 남아 BD9 가 깨진다(04 실측 2026-09-19: 134×238px → 289px).
var frameWrapEl = frameEl.parentElement;
function apply(t) {
var frameWrapStyle = getComputedStyle(frameWrapEl);
var BASE_FW = parseFloat(frameWrapStyle.getPropertyValue('--base-fw')) || 118;
var AMP_FW = parseFloat(frameWrapStyle.getPropertyValue('--amp-fw')) || 16;
var fw = BASE_FW + AMP_FW * Math.sin(2 * Math.PI * t / CYCLE_MS);
frameEl.style.width = fw.toFixed(1) + 'px';
// 계산 겹: 남는 스테이지 폭 → 배경 흐림 강도. 프레임이 커질수록 뒤 배경은 덜 흐리다.
var leftover = stageEl.clientWidth - frameEl.getBoundingClientRect().width;
var blur = BG_BLUR_PX + leftover / 14;
bgEl.style.filter = 'blur(' + blur.toFixed(1) + 'px)';
readoutEl.textContent = '여백 ' + Math.round(leftover) + 'px · 흐림 ' + blur.toFixed(1) + 'px';
}
if (reduce) {
apply(0);
return;
}
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./EmbedShortsVertical.module.scss";
// 세로형 임베드 프레임 — 프레임 폭이 숨쉬듯 늘고 줄면, 남는 스테이지 폭만큼
// 뒤 배경이 흐려진다(초점이 앞으로 온 느낌). 실키·실서버 요청 없음.
const CYCLE_MS = 2000;
const BG_BLUR_PX = 8;
function mulberry32(seed: number) {
let a = seed >>> 0;
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function drawSeedThumb(canvas: HTMLCanvasElement, seed: number) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const rnd = mulberry32(seed);
const w = canvas.width;
const h = canvas.height;
const hues = [216, 32, 268, 168];
ctx.fillStyle = "hsl(" + hues[Math.floor(rnd() * hues.length)] + ", 38%, 22%)";
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < 6; i++) {
const cx = rnd() * w;
const cy = rnd() * h;
const r = (0.18 + rnd() * 0.3) * Math.min(w, h);
ctx.fillStyle =
"hsla(" + hues[Math.floor(rnd() * hues.length)] + ", " + (44 + rnd() * 22).toFixed(1) + "%, " + (38 + rnd() * 26).toFixed(1) + "%, .5)";
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = "rgba(255, 247, 230, .06)";
for (let j = 0; j < h; j += 4) ctx.fillRect(0, j, w, 1);
}
export default function EmbedShortsVertical() {
const stageRef = useRef<HTMLDivElement>(null);
const bgRef = useRef<HTMLCanvasElement>(null);
const thumbRef = useRef<HTMLCanvasElement>(null);
const frameWrapRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<HTMLDivElement>(null);
const readoutRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const stageEl = stageRef.current;
const bgEl = bgRef.current;
const thumbEl = thumbRef.current;
const frameWrapEl = frameWrapRef.current;
const frameEl = frameRef.current;
const readoutEl = readoutRef.current;
if (!stageEl || !bgEl || !thumbEl || !frameWrapEl || !frameEl || !readoutEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
drawSeedThumb(bgEl, 104);
drawSeedThumb(thumbEl, 140);
function apply(t: number) {
// 폭 밑값·진폭은 매 프레임 다시 읽는다 — 320px 미디어쿼리가 낮춘 값을 시작 폭 캡처로 놓치면 BD9 가 깨진다.
const frameWrapStyle = getComputedStyle(frameWrapEl!);
const baseFw = parseFloat(frameWrapStyle.getPropertyValue("--base-fw")) || 118;
const ampFw = parseFloat(frameWrapStyle.getPropertyValue("--amp-fw")) || 16;
const fw = baseFw + ampFw * Math.sin((2 * Math.PI * t) / CYCLE_MS);
frameEl!.style.width = fw.toFixed(1) + "px";
// 계산 겹: 남는 스테이지 폭 → 배경 흐림 강도.
const leftover = stageEl!.clientWidth - frameEl!.getBoundingClientRect().width;
const blur = BG_BLUR_PX + leftover / 14;
bgEl!.style.filter = "blur(" + blur.toFixed(1) + "px)";
readoutEl!.textContent = "여백 " + Math.round(leftover) + "px · 흐림 " + blur.toFixed(1) + "px";
}
if (reduce) {
apply(0);
return;
}
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage} ref={stageRef}>
<canvas className={styles.bg} ref={bgRef} width={480} height={300} aria-hidden="true" />
<section className={styles.panel}>
<div className={styles.frameWrap} ref={frameWrapRef}>
<div className={styles.frame} ref={frameRef}>
<canvas className={styles.thumb} ref={thumbRef} width={120} height={213} />
<div className={styles.playBtn} role="button" aria-label="재생">
<span className={styles.playShape} aria-hidden="true" />
</div>
<div className={styles.captionBar}>
<span className={styles.captionText}>세로 영상 9:16</span>
</div>
</div>
</div>
<div className={styles.readout} ref={readoutRef}>
여백 148px · 흐림 17px
</div>
</section>
</div>
);
}04 세로형 쇼츠 임베드 — 남는 폭만큼 배경 흐림을 다시 구하는 프레임 — 측정 누적면적 25.935% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 유튜브 임베드 코드 9종 — SNS 카드도 담은 플레이스홀더 UI
이 데모가 속한 글
유튜브 임베드 코드 9종 — SNS 카드도 담은 플레이스홀더 UI프레임 폭에 맞춰 세로 비율이 다시 계산되고, 화면에 머무른 시간이 일정 값을 넘으면 가벼운 카드가 스스로 완전한 프레임으로 바뀌고, 최근 소셜 임베드는 등장 뒤 지난 시간만큼 배지 숫자가 조금씩 올라가는 임베드 U 이 글의 데모 전부 보기


