04Vertical shorts embed — a frame that recomputes background blur from the leftover width
The leftover space is computed by subtracting the vertical frame's width from the screen width, and the wider that leftover gets, the stronger the side-background blur is recomputed to be.
Best forMobile short-clip listing pages, intro sections for shorts-style landing pages
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="Play">
<span class="playShape" aria-hidden="true"></span>
</div>
<div class="captionBar">
<span class="captionText">Vertical video 9:16</span>
</div>
</div>
</div>
<div class="readout" id="readout">Margin 148px · blur 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) — 프레임 폭이 숨쉬듯 늘고 줄면, 남는 스테이지 폭만큼
// 뒤 배경이 흐려진다(초점이 앞으로 온 느낌). 실키·실서버 요청 없음 — 자동 Play 시나리오.
(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 = 'Margin ' + Math.round(leftover) + 'px · blur ' + 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 Vertical shorts embed — a frame that recomputes background blur from the leftover width — Measured area 25.935% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 YouTube Embed Code UI Patterns (Copy-Paste)
The post this demo lives in
9 YouTube Embed Code UI Patterns (Copy-Paste)Nine embed UIs: a frame's height recomputes from its width, a lite facade times itself into a full frame after a delay, See all demos in this post
If you like this effect - similar demos
Embeds04 Social mention cards that roll in from the side9 Activity Feed UI Widgets — Sidebar-Ready Lists
Embeds02 Focal Zoom That Calculates Its Center From the Pointer's CoordinatesPDF Viewer Implementation: 9 UI Patterns
Embeds02 Tabs that swap the same example per language9 Code Block UI Patterns — No Highlighter Library