06Lightbox that grows out of its thumbnail
Tapping a thumbnail does not pop a big panel in the middle of the screen; the panel grows out of the thumbnail you pressed. Both positions are measured with getBoundingClientRect, the difference is applied backwards, and one requestAnimationFrame later it is released.
Best forPhoto lists where one shot is opened large while browsing
getBoundingClientRectrequestAnimationFramekeydown

<main class="stage">
<div class="lb is-demo" id="lb" data-i="2">
<div class="lb__grid">
<button class="lb__t lb__t--1" type="button" data-open="1" aria-label="Open the first photo"></button>
<button class="lb__t lb__t--2" type="button" data-open="2" aria-label="Open the second photo"></button>
<button class="lb__t lb__t--3" type="button" data-open="3" aria-label="Open the third photo"></button>
</div>
<div class="lb__sheet" role="dialog" aria-modal="true" aria-label="Photo viewer">
<div class="lb__scrim" id="lb-scrim"></div>
<div class="lb__panel" id="lb-panel">
<button class="lb__x" id="lb-x" type="button" aria-label="Close"><svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z"/></g></svg></button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
// 가짜 사진 세 장 — 파일 없이 그라디언트로만 그렸다
$p1: radial-gradient(circle at 30% 28%, $stage-yellow 0 16%, transparent 17%), linear-gradient(150deg, $stage-orange, $subject-pink 62%, $subject-lilac);
$p2: radial-gradient(circle at 72% 70%, $subject-cream 0 12%, transparent 13%), linear-gradient(28deg, $subject-blue, $subject-mint 70%, $stage-yellow);
$p3: repeating-linear-gradient(120deg, rgba(255, 247, 230, .3) 0 6%, transparent 6% 14%), linear-gradient(200deg, $stage-ink, $subject-sky);
.lb {
position: relative;
box-sizing: border-box;
width: 288px;
height: 168px;
}
.lb__grid {
display: flex;
justify-content: center;
gap: $sp-2;
padding-top: $sp-4;
}
.lb__t {
width: 84px;
height: 64px;
padding: 0;
border: 0;
border-radius: $r-sm;
box-shadow: $shadow-press;
cursor: zoom-in;
}
.lb__t:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
.lb__t--1 { background: $p1; }
.lb__t--2 { background: $p2; }
.lb__t--3 { background: $p3; }
// ── 열린 판 ───────────────────────────────────────────
.lb__sheet {
position: absolute;
inset: 0;
display: none;
place-items: center;
}
// 루프 중에도 판은 보이지만 클릭은 통과시킨다 — 안 그러면 스크림이 썸네일을 덮어
// 사람이 열 수가 없다 (Playwright 실측: 클릭이 .lb__scrim 에 가로막혔다)
.lb.is-demo .lb__sheet { display: grid; pointer-events: none; }
.lb.is-open .lb__sheet { display: grid; pointer-events: auto; }
.lb__scrim {
position: absolute;
inset: 0;
border-radius: $r-card;
background: rgba(23, 20, 26, .72);
}
.lb__panel {
position: relative;
box-sizing: border-box;
width: 240px;
height: 120px;
border-radius: $r-card;
box-shadow: $shadow-raised;
transform-origin: center center;
transition: transform $dur-enter $easing;
}
.lb[data-i="1"] .lb__panel { background: $p1; }
.lb[data-i="2"] .lb__panel { background: $p2; }
.lb[data-i="3"] .lb__panel { background: $p3; }
.lb__x {
position: absolute;
right: $sp-2;
top: $sp-2;
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(23, 20, 26, .72);
color: $subject-cream;
cursor: pointer;
}
.lb__x:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
// ── 자동 루프 (2s) ────────────────────────────────────
// 두 번째 썸네일 자리(0, -36px · 0.35×0.53배)에서 자라나 제자리로 돌아간다.
// 나타나고 사라지는 순간은 steps 없이도 1% 구간이라 반투명 유령 프레임이 남지 않는다
.lb.is-demo .lb__panel {
transition: none;
animation-name: lb-panel;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.lb.is-demo .lb__scrim {
animation-name: lb-scrim;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes lb-panel {
0% { opacity: 0; transform: translate(0, -36px) scale(.35, .533); }
9% { opacity: 0; transform: translate(0, -36px) scale(.35, .533); }
10% { opacity: 1; transform: translate(0, -36px) scale(.35, .533); }
40% { opacity: 1; transform: translate(0, 0) scale(1, 1); }
70% { opacity: 1; transform: translate(0, 0) scale(1, 1); }
86% { opacity: 1; transform: translate(0, -36px) scale(.35, .533); }
87% { opacity: 0; transform: translate(0, -36px) scale(.35, .533); }
100% { opacity: 0; transform: translate(0, -36px) scale(.35, .533); }
}
@keyframes lb-scrim {
0% { opacity: 0; }
10% { opacity: 0; }
22% { opacity: 1; }
78% { opacity: 1; }
87% { opacity: 0; }
100% { opacity: 0; }
}
@media (max-width: 360px) {
.lb { height: 160px; }
.lb__grid { padding-top: $sp-2; }
.lb__panel { width: 224px; height: 112px; }
}
@media (prefers-reduced-motion: reduce) {
.lb.is-demo .lb__panel,
.lb.is-demo .lb__scrim { animation: none !important; }
.lb.is-demo .lb__panel { opacity: 1; transform: translate(0, 0) scale(1, 1); }
.lb.is-demo .lb__scrim { opacity: 1; }
.lb__panel { transition: none; }
}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);
}
.lb {
position: relative;
box-sizing: border-box;
width: 288px;
height: 168px;
}
.lb__grid {
display: flex;
justify-content: center;
gap: 8px;
padding-top: 16px;
}
.lb__t {
width: 84px;
height: 64px;
padding: 0;
border: 0;
border-radius: 8px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: zoom-in;
}
.lb__t:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.lb__t--1 {
background: radial-gradient(circle at 30% 28%, #ffd23f 0 16%, transparent 17%), linear-gradient(150deg, #ff4d1f, #ff9ebb 62%, #b9a0ff);
}
.lb__t--2 {
background: radial-gradient(circle at 72% 70%, #fff7e6 0 12%, transparent 13%), linear-gradient(28deg, #2f6df6, #4cd4a6 70%, #ffd23f);
}
.lb__t--3 {
background: repeating-linear-gradient(120deg, rgba(255, 247, 230, 0.3) 0 6%, transparent 6% 14%), linear-gradient(200deg, #17141a, #6ec8ff);
}
.lb__sheet {
position: absolute;
inset: 0;
display: none;
place-items: center;
}
.lb.is-demo .lb__sheet {
display: grid;
pointer-events: none;
}
.lb.is-open .lb__sheet {
display: grid;
pointer-events: auto;
}
.lb__scrim {
position: absolute;
inset: 0;
border-radius: 16px;
background: rgba(23, 20, 26, 0.72);
}
.lb__panel {
position: relative;
box-sizing: border-box;
width: 240px;
height: 120px;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: center center;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.lb[data-i="1"] .lb__panel {
background: radial-gradient(circle at 30% 28%, #ffd23f 0 16%, transparent 17%), linear-gradient(150deg, #ff4d1f, #ff9ebb 62%, #b9a0ff);
}
.lb[data-i="2"] .lb__panel {
background: radial-gradient(circle at 72% 70%, #fff7e6 0 12%, transparent 13%), linear-gradient(28deg, #2f6df6, #4cd4a6 70%, #ffd23f);
}
.lb[data-i="3"] .lb__panel {
background: repeating-linear-gradient(120deg, rgba(255, 247, 230, 0.3) 0 6%, transparent 6% 14%), linear-gradient(200deg, #17141a, #6ec8ff);
}
.lb__x {
position: absolute;
right: 8px;
top: 8px;
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(23, 20, 26, 0.72);
color: #fff7e6;
cursor: pointer;
}
.lb__x:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.lb.is-demo .lb__panel {
transition: none;
animation-name: lb-panel;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.lb.is-demo .lb__scrim {
animation-name: lb-scrim;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes lb-panel {
0% {
opacity: 0;
transform: translate(0, -36px) scale(0.35, 0.533);
}
9% {
opacity: 0;
transform: translate(0, -36px) scale(0.35, 0.533);
}
10% {
opacity: 1;
transform: translate(0, -36px) scale(0.35, 0.533);
}
40% {
opacity: 1;
transform: translate(0, 0) scale(1, 1);
}
70% {
opacity: 1;
transform: translate(0, 0) scale(1, 1);
}
86% {
opacity: 1;
transform: translate(0, -36px) scale(0.35, 0.533);
}
87% {
opacity: 0;
transform: translate(0, -36px) scale(0.35, 0.533);
}
100% {
opacity: 0;
transform: translate(0, -36px) scale(0.35, 0.533);
}
}
@keyframes lb-scrim {
0% {
opacity: 0;
}
10% {
opacity: 0;
}
22% {
opacity: 1;
}
78% {
opacity: 1;
}
87% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 360px) {
.lb {
height: 160px;
}
.lb__grid {
padding-top: 8px;
}
.lb__panel {
width: 224px;
height: 112px;
}
}
@media (prefers-reduced-motion: reduce) {
.lb.is-demo .lb__panel,
.lb.is-demo .lb__scrim {
animation: none !important;
}
.lb.is-demo .lb__panel {
opacity: 1;
transform: translate(0, 0) scale(1, 1);
}
.lb.is-demo .lb__scrim {
opacity: 1;
}
.lb__panel {
transition: none;
}
}// 실제 동작: 눌린 썸네일 자리에서 큰 판이 자라난다 (FLIP — 재고, 되돌리고, 한 프레임 뒤 푼다).
var lb = document.getElementById('lb');
var lbPanel = document.getElementById('lb-panel');
var lbThumbs = [].slice.call(lb.querySelectorAll('.lb__t'));
function lbOpen(i) {
lb.classList.remove('is-demo'); // 사람이 누르면 자동 루프를 멈춘다
lb.setAttribute('data-i', String(i));
lb.classList.add('is-open');
var from = lbThumbs[i - 1].getBoundingClientRect();
var to = lbPanel.getBoundingClientRect();
lbPanel.style.transition = 'none';
lbPanel.style.transform = 'translate(' + (from.left - to.left + (from.width - to.width) / 2) + 'px,'
+ (from.top - to.top + (from.height - to.height) / 2) + 'px) scale('
+ (from.width / to.width) + ',' + (from.height / to.height) + ')';
requestAnimationFrame(function () {
lbPanel.style.transition = '';
lbPanel.style.transform = '';
document.getElementById('lb-x').focus({ preventScroll: true });
});
}
function lbClose() {
lb.classList.remove('is-open');
lbThumbs[Number(lb.getAttribute('data-i')) - 1].focus({ preventScroll: true });
}
lbThumbs.forEach(function (t) { t.addEventListener('click', function () { lbOpen(Number(t.getAttribute('data-open'))); }); });
document.getElementById('lb-x').addEventListener('click', lbClose);
document.getElementById('lb-scrim').addEventListener('click', lbClose);
document.addEventListener('keydown', function (e) {
if (!lb.classList.contains('is-open')) return;
if (e.key === 'Escape') { e.preventDefault(); lbClose(); }
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
var n = Number(lb.getAttribute('data-i')) + (e.key === 'ArrowRight' ? 1 : -1);
lb.setAttribute('data-i', String(n < 1 ? 3 : n > 3 ? 1 : n));
}
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LightboxFlip.module.scss";
export type LightboxFlipProps = {
thumbLabels?: string[];
dialogLabel?: string;
closeLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
const Close = () => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true">
<g fill="currentColor">
<path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2" />
<path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z" />
</g>
</svg>
);
export default function LightboxFlip({
thumbLabels = ["Open the first photo", "Open the second photo", "Open the third photo"],
dialogLabel = "Photo viewer",
closeLabel = "Close",
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: LightboxFlipProps) {
const thumbsRef = useRef<HTMLButtonElement[]>([]);
const thumbs: HTMLButtonElement[] = thumbsRef.current || (thumbsRef.current = []);
const panel = useRef<HTMLDivElement>(null);
const closeBtn = useRef<HTMLButtonElement>(null);
const [live, setLive] = useState(demo);
const [open, setOpen] = useState(false);
const [index, setIndex] = useState(2);
// FLIP — 두 자리를 재고, 차이만큼 되돌려 놓고, 한 프레임 뒤에 푼다
function flipFrom(i: number) {
const from = thumbs[i - 1] ? thumbs[i - 1].getBoundingClientRect() : null;
const to = panel.current ? panel.current.getBoundingClientRect() : null;
if (!from || !to || !panel.current) return;
panel.current.style.transition = "none";
panel.current.style.transform =
`translate(${from.left - to.left + (from.width - to.width) / 2}px,` +
`${from.top - to.top + (from.height - to.height) / 2}px) ` +
`scale(${from.width / to.width},${from.height / to.height})`;
requestAnimationFrame(() => {
if (!panel.current) return;
panel.current.style.transition = "";
panel.current.style.transform = "";
if (closeBtn.current) closeBtn.current.focus({ preventScroll: true });
});
}
function openAt(i: number) {
setLive(false);
setIndex(i);
setOpen(true);
flipFrom(i);
}
function close() {
setOpen(false);
const t = thumbs[index - 1];
if (t) t.focus({ preventScroll: true });
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [styles.root, live ? styles.isDemo : "", open ? styles.isOpen : ""].join(" ");
return (
<div
className={cls}
style={vars}
data-i={index}
onKeyDown={(e: KeyEvt) => {
if (!open) return;
if (e.key === "Escape") { e.preventDefault(); close(); }
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
const n = index + (e.key === "ArrowRight" ? 1 : -1);
setIndex(n < 1 ? 3 : n > 3 ? 1 : n);
}
}}
>
<div className={styles.grid}>
{[1, 2, 3].map((i) => (
<button
key={i}
className={[styles.t, i === 1 ? styles.t1 : i === 2 ? styles.t2 : styles.t3].join(" ")}
type="button"
aria-label={thumbLabels[i - 1]}
ref={(el: HTMLButtonElement) => { thumbs[i - 1] = el; }}
onClick={() => openAt(i)}
/>
))}
</div>
<div className={styles.sheet} role="dialog" aria-modal="true" aria-label={dialogLabel}>
<div className={styles.scrim} onClick={close} />
<div className={styles.panel} ref={panel}>
<button className={styles.x} type="button" aria-label={closeLabel} ref={closeBtn} onClick={close}>
<Close />
</button>
</div>
</div>
</div>
);
}06 Lightbox that grows out of its thumbnail — Measured area 33.357% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · 9 Image Zoom Viewer Interactions — No Photo Files
The post this demo lives in
9 Image Zoom Viewer Interactions — No Photo FilesAn image zoom viewer is the layer between a picture and the person studying it: it magnifies, turns, or lights up one pa See all demos in this post


