06썸네일 자리에서 자라나는 라이트박스
썸네일을 누르면 큰 판이 화면 한가운데에서 나타나는 대신, 눌린 썸네일이 있던 자리에서 자라납니다. 두 자리를 getBoundingClientRect 로 재서 차이만큼 되돌려 놓은 다음 requestAnimationFrame 한 번 뒤에 풀어 주는 방식입니다.
쓰임여러 장을 훑다가 한 장을 크게 보는 사진 목록
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="첫 번째 사진 크게 보기"></button>
<button class="lb__t lb__t--2" type="button" data-open="2" aria-label="두 번째 사진 크게 보기"></button>
<button class="lb__t lb__t--3" type="button" data-open="3" aria-label="세 번째 사진 크게 보기"></button>
</div>
<div class="lb__sheet" role="dialog" aria-modal="true" aria-label="사진 크게 보기">
<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="닫기"><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 썸네일 자리에서 자라나는 라이트박스 — 측정 누적면적 33.357% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 이미지 확대 보기 UI 9종 — 사진 파일 없이 만드는 뷰어
이 데모가 속한 글
이미지 확대 보기 UI 9종 — 사진 파일 없이 만드는 뷰어이미지 확대 보기는 한 장의 그림을 크게 키우거나, 돌리거나, 한 부분만 짚어 보게 해 주는 화면 장치입니다. 아홉 개 모두 실제로 움직이며, 사진 파일은 한 장도 쓰지 않고 그라디언트로만 그렸습니다. 이 글의 데모 전부 보기


