07Iframe Where a Click Value Computes the Load Moment via Lazy Loading
A click value is computed so that a static notice sits in a map or social-embed slot before any click, and only on click does that slot become the real iframe.
Best forStore-location maps and social-post embeds
RIPPLE_DURATION_MSLOAD_DELAY_MSmaxDistanceToCorner

<main class="stage">
<div class="lic is-demo" id="lic">
<div class="lic__frame" id="licFrame" tabindex="0" role="button" aria-label="Load map embed">
<div class="lic__notice" id="licNotice">
<span class="lic__pin">📍</span>
<p class="lic__text">Click to load the map</p>
</div>
<svg class="lic__ripple" id="licRipple" aria-hidden="true">
<circle id="licRippleCircle" cx="0" cy="0" r="0"></circle>
</svg>
<div class="lic__embed" id="licEmbed">
<svg viewBox="0 0 220 140" width="100%" height="100%" preserveAspectRatio="none">
<rect width="220" height="140" fill="#d8ead8"></rect>
<path d="M0 90 L60 70 L120 96 L160 60 L220 84 L220 140 L0 140 Z" fill="#bfe0c4"></path>
<path d="M0 40 L220 55" stroke="#f4d878" stroke-width="6"></path>
<path d="M40 0 L70 140" stroke="#f4d878" stroke-width="5"></path>
<circle cx="118" cy="66" r="7" fill="#e6564a"></circle>
<circle cx="118" cy="66" r="3" fill="#fff"></circle>
</svg>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 클릭 지점에서 프레임 모서리까지의 최대 거리 값을 리플 반지름 목표로 계산한다.
// 클릭 전에는 정적 안내만, 클릭한 순간에만 실제(가짜) 임베드로 전환한다.
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-mint;
@include stage($stage-ink);
.lic {
display: flex;
align-items: center;
justify-content: center;
}
.lic__frame {
position: relative;
box-sizing: border-box;
width: 220px;
height: 140px;
border-radius: $r-card;
overflow: hidden;
background: $stage-paper;
cursor: pointer;
outline: none;
box-shadow: $shadow-raised;
}
.lic__frame:focus-visible {
box-shadow: 0 0 0 3px rgba(76, 212, 166, .5);
}
.lic__notice {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-2;
background: $stage-paper;
transition: opacity $dur-quick $ease-out;
}
.lic__pin { font-size: 22px; }
.lic__text {
margin: 0;
font-size: 11px;
color: $stage-ink;
opacity: .7;
}
.lic__ripple {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
pointer-events: none;
}
.lic__ripple--active { opacity: 1; }
.lic__ripple circle {
fill: rgba(76, 212, 166, .28);
}
.lic__embed {
position: absolute;
inset: 0;
opacity: 0;
}
.lic__frame--loading .lic__notice { opacity: .35; }
.lic__frame--loaded .lic__notice { opacity: 0; pointer-events: none; }
.lic__frame--loaded .lic__embed {
opacity: 1;
transition: opacity $duration $easing;
}
@media (max-width: 340px) {
.lic__frame { width: 170px; height: 110px; }
}
@media (prefers-reduced-motion: reduce) {
.lic__notice, .lic__embed { transition: none !important; }
.lic__ripple { display: 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);
}
.lic {
display: flex;
align-items: center;
justify-content: center;
}
.lic__frame {
position: relative;
box-sizing: border-box;
width: 220px;
height: 140px;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
cursor: pointer;
outline: none;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.lic__frame:focus-visible {
box-shadow: 0 0 0 3px rgba(76, 212, 166, 0.5);
}
.lic__notice {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
background: #ffffff;
transition: opacity 220ms ease-out;
}
.lic__pin {
font-size: 22px;
}
.lic__text {
margin: 0;
font-size: 11px;
color: #17141a;
opacity: 0.7;
}
.lic__ripple {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
pointer-events: none;
}
.lic__ripple--active {
opacity: 1;
}
.lic__ripple circle {
fill: rgba(76, 212, 166, 0.28);
}
.lic__embed {
position: absolute;
inset: 0;
opacity: 0;
}
.lic__frame--loading .lic__notice {
opacity: 0.35;
}
.lic__frame--loaded .lic__notice {
opacity: 0;
pointer-events: none;
}
.lic__frame--loaded .lic__embed {
opacity: 1;
transition: opacity 600ms ease-out;
}
@media (max-width: 340px) {
.lic__frame {
width: 170px;
height: 110px;
}
}
@media (prefers-reduced-motion: reduce) {
.lic__notice, .lic__embed {
transition: none !important;
}
.lic__ripple {
display: none;
}
}(function () {
var root = document.getElementById('lic');
var frame = document.getElementById('licFrame');
var ripple = document.getElementById('licRipple');
var rippleCircle = document.getElementById('licRippleCircle');
var RIPPLE_DURATION_MS = 520;
var LOAD_DELAY_MS = 340;
var CYCLE_GAP_MS = 2400;
var loaded = false;
// 계산 겹: 클릭 지점에서 프레임 네 모서리까지의 최대 거리(피타고라스) 를
// 리플 반지름 목표값으로 계산해, 실제 지점에서 시작하는 원이 프레임을 덮는다.
function maxDistanceToCorner(x, y, w, h) {
var dTL = Math.sqrt(x * x + y * y);
var dTR = Math.sqrt((w - x) * (w - x) + y * y);
var dBL = Math.sqrt(x * x + (h - y) * (h - y));
var dBR = Math.sqrt((w - x) * (w - x) + (h - y) * (h - y));
return Math.max(dTL, dTR, dBL, dBR);
}
function runRipple(x, y, onDone) {
var rect = frame.getBoundingClientRect();
var targetR = maxDistanceToCorner(x, y, rect.width, rect.height);
rippleCircle.setAttribute('cx', String(x));
rippleCircle.setAttribute('cy', String(y));
rippleCircle.setAttribute('r', '0');
ripple.classList.add('lic__ripple--active');
var start = performance.now();
function step(now) {
var progress = Math.min((now - start) / RIPPLE_DURATION_MS, 1);
rippleCircle.setAttribute('r', String(targetR * progress));
if (progress < 1) {
requestAnimationFrame(step);
} else {
ripple.classList.remove('lic__ripple--active');
onDone();
}
}
requestAnimationFrame(step);
}
function loadEmbed(x, y) {
if (loaded) { return; }
loaded = true;
frame.classList.add('lic__frame--loading');
runRipple(x, y, function () {
setTimeout(function () {
frame.classList.remove('lic__frame--loading');
frame.classList.add('lic__frame--loaded');
frame.setAttribute('aria-label', 'Map embed loaded');
}, LOAD_DELAY_MS);
});
}
function resetEmbed() {
loaded = false;
frame.classList.remove('lic__frame--loading', 'lic__frame--loaded');
frame.setAttribute('aria-label', 'Load map embed');
}
frame.addEventListener('click', function (e) {
root.classList.remove('is-demo');
if (loaded) { return; }
var rect = frame.getBoundingClientRect();
loadEmbed(e.clientX - rect.left, e.clientY - rect.top);
});
frame.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
root.classList.remove('is-demo');
if (loaded) { return; }
var rect = frame.getBoundingClientRect();
loadEmbed(rect.width / 2, rect.height / 2);
}
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.remove('is-demo');
loaded = true;
frame.classList.add('lic__frame--loaded');
frame.setAttribute('aria-label', 'Map embed loaded');
} else {
(function loop() {
resetEmbed();
setTimeout(function () {
if (!root.classList.contains('is-demo')) { return; }
loadEmbed(110, 70);
setTimeout(function () {
if (root.classList.contains('is-demo')) { loop(); }
}, CYCLE_GAP_MS);
}, 700);
})();
}
})();import { useEffect, useRef, useCallback, useState } from "react";
import styles from "./IframeClickToLoad.module.scss";
// 클릭 지점에서 프레임 네 모서리까지의 최대 거리(피타고라스) 를 리플 반지름
// 목표값으로 계산해, 실제 클릭 지점에서 시작하는 원이 프레임을 덮는다.
const RIPPLE_DURATION_MS = 520;
const LOAD_DELAY_MS = 340;
const CYCLE_GAP_MS = 2400;
type Box<T> = { current: T };
function useBox<T>(initial: T): Box<T> {
return useRef(initial) as unknown as Box<T>;
}
function maxDistanceToCorner(x: number, y: number, w: number, h: number): number {
const dTL = Math.sqrt(x * x + y * y);
const dTR = Math.sqrt((w - x) * (w - x) + y * y);
const dBL = Math.sqrt(x * x + (h - y) * (h - y));
const dBR = Math.sqrt((w - x) * (w - x) + (h - y) * (h - y));
return Math.max(dTL, dTR, dBL, dBR);
}
export default function IframeClickToLoad() {
const rootRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<HTMLDivElement>(null);
const [demo, setDemo] = useState(true);
const [rippleActive, setRippleActive] = useState(false);
const [circle, setCircle] = useState({ cx: 0, cy: 0, r: 0 });
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const loadedRef = useBox<boolean>(false);
const runRipple = useCallback((x: number, y: number, onDone: () => void) => {
const rect = frameRef.current?.getBoundingClientRect();
const w = rect ? rect.width : 220;
const h = rect ? rect.height : 140;
const targetR = maxDistanceToCorner(x, y, w, h);
setCircle({ cx: x, cy: y, r: 0 });
setRippleActive(true);
const start = performance.now();
function step(now: number) {
const progress = Math.min((now - start) / RIPPLE_DURATION_MS, 1);
setCircle({ cx: x, cy: y, r: targetR * progress });
if (progress < 1) {
requestAnimationFrame(step);
} else {
setRippleActive(false);
onDone();
}
}
requestAnimationFrame(step);
}, []);
const loadEmbed = useCallback((x: number, y: number) => {
if (loadedRef.current) { return; }
loadedRef.current = true;
setLoaded(false);
setLoading(true);
runRipple(x, y, () => {
window.setTimeout(() => {
setLoading(false);
setLoaded(true);
}, LOAD_DELAY_MS);
});
}, [loadedRef, runRipple]);
const resetEmbed = useCallback(() => {
loadedRef.current = false;
setLoading(false);
setLoaded(false);
}, [loadedRef]);
const onClick = useCallback((e: { clientX: number; clientY: number }) => {
setDemo(false);
if (loadedRef.current) { return; }
const rect = frameRef.current?.getBoundingClientRect();
const x = rect ? e.clientX - rect.left : 110;
const y = rect ? e.clientY - rect.top : 70;
loadEmbed(x, y);
}, [loadedRef, loadEmbed]);
useEffect(() => {
const frame = frameRef.current;
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setDemo(false);
if (loadedRef.current) { return; }
const rect = frame?.getBoundingClientRect();
loadEmbed(rect ? rect.width / 2 : 110, rect ? rect.height / 2 : 70);
}
}
frame?.addEventListener("keydown", onKeyDown);
return () => frame?.removeEventListener("keydown", onKeyDown);
}, [loadedRef, loadEmbed]);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
setDemo(false);
loadedRef.current = true;
setLoaded(true);
return;
}
let cancelled = false;
let t1: number | null = null;
let t2: number | null = null;
function loop() {
if (cancelled) { return; }
resetEmbed();
t1 = window.setTimeout(() => {
if (cancelled || !rootRef.current?.classList.contains(styles["is-demo"])) { return; }
loadEmbed(110, 70);
t2 = window.setTimeout(() => {
if (!cancelled && rootRef.current?.classList.contains(styles["is-demo"])) { loop(); }
}, CYCLE_GAP_MS);
}, 700);
}
loop();
return () => {
cancelled = true;
if (t1 !== null) { window.clearTimeout(t1); }
if (t2 !== null) { window.clearTimeout(t2); }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const frameClass = [
styles.lic__frame,
loading ? styles["lic__frame--loading"] : "",
loaded ? styles["lic__frame--loaded"] : "",
].join(" ").trim();
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.lic} ${demo ? styles["is-demo"] : ""}`}>
<div
ref={frameRef}
className={frameClass}
tabIndex={0}
role="button"
aria-label={loaded ? "지도 임베드 로드됨" : "지도 임베드 불러오기"}
onClick={onClick}
>
<div className={styles["lic__notice"]}>
<span className={styles["lic__pin"]}>{"📍"}</span>
<p className={styles["lic__text"]}>클릭하면 지도를 불러옵니다</p>
</div>
<svg className={`${styles["lic__ripple"]} ${rippleActive ? styles["lic__ripple--active"] : ""}`} aria-hidden="true">
<circle cx={circle.cx} cy={circle.cy} r={circle.r} />
</svg>
<div className={styles["lic__embed"]}>
<svg viewBox="0 0 220 140" width="100%" height="100%" preserveAspectRatio="none">
<rect width={220} height={140} fill="#d8ead8" />
<path d="M0 90 L60 70 L120 96 L160 60 L220 84 L220 140 L0 140 Z" fill="#bfe0c4" />
<path d="M0 40 L220 55" stroke="#f4d878" strokeWidth={6} />
<path d="M40 0 L70 140" stroke="#f4d878" strokeWidth={5} />
<circle cx={118} cy={66} r={7} fill="#e6564a" />
<circle cx={118} cy={66} r={3} fill="#fff" />
</svg>
</div>
</div>
</div>
</main>
);
}07 Iframe Where a Click Value Computes the Load Moment via Lazy Loading — Measured area 21.261% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · Lazy Loading Images: 9 CSS Placeholder Patterns
The post this demo lives in
Lazy Loading Images: 9 CSS Placeholder PatternsAmong nine lazy loading images patterns, one computes a ratio to fade an image in, one computes timing to swap an iframe See all demos in this post
If you like this effect - similar demos
Loaders03 Load trigger threshold — a list recomputing how early it starts loading from the scroll speed valueInfinite Scroll Loading: 9 Essential UI Patterns
Loaders08 Row stagger reveal — an effect where each list row appears following an order value once the refresh finishesPull to Refresh CSS: 9 Loading Patterns
Loaders03 Particle Swirl That Gathers Into the Core as an Orbit-radius Value ShrinksAI Loading Animation: 9 Patterns by One Value