08앨범 격자에서 열리는 라이트박스 슬라이더
격자에서 고른 사진이 showModal 로 열린 dialog 안에서 다시 슬라이더가 되어, 창을 닫지 않고도 좌우로 다음 장을 볼 수 있다.
쓰임행사 사진첩, 작업물 모음 페이지
showModaldialoggrid-template-columns

<main class="stage">
<section class="ab is-demo" id="ab">
<header class="ab__bar">
<span class="ab__title">앨범 격자</span>
<span class="ab__hint">창을 닫지 않고 넘깁니다</span>
</header>
<ul class="ab__grid" id="grid">
<li class="ab__cell"><button class="ab__btn" type="button" aria-label="새벽 부두의 컨테이너 크게 보기"><span class="ab__ph ab__ph--a" role="img" aria-label="새벽 부두의 컨테이너"></span></button></li>
<li class="ab__cell"><button class="ab__btn" type="button" aria-label="노을 진 사막 능선 크게 보기"><span class="ab__ph ab__ph--b" role="img" aria-label="노을 진 사막 능선"></span></button></li>
<li class="ab__cell"><button class="ab__btn" type="button" aria-label="비 온 뒤 도심 골목 크게 보기"><span class="ab__ph ab__ph--c" role="img" aria-label="비 온 뒤 도심 골목"></span></button></li>
<li class="ab__cell"><button class="ab__btn" type="button" aria-label="눈 덮인 자작나무 숲 크게 보기"><span class="ab__ph ab__ph--d" role="img" aria-label="눈 덮인 자작나무 숲"></span></button></li>
<li class="ab__cell"><button class="ab__btn" type="button" aria-label="한낮의 수영장 물결 크게 보기"><span class="ab__ph ab__ph--e" role="img" aria-label="한낮의 수영장 물결"></span></button></li>
<li class="ab__cell"><button class="ab__btn" type="button" aria-label="밤 열차의 창가 불빛 크게 보기"><span class="ab__ph ab__ph--f" role="img" aria-label="밤 열차의 창가 불빛"></span></button></li>
</ul>
</section>
<dialog class="lb" id="lb" aria-label="사진 크게 보기">
<div class="lb__box" role="region" aria-roledescription="carousel" aria-label="라이트박스 사진 슬라이더">
<div class="lb__frame">
<div class="lb__track" id="lbtrack">
<span class="lb__slide" role="group" aria-roledescription="slide" aria-label="6장 중 1장"><span class="ab__ph ab__ph--a" role="img" aria-label="새벽 부두의 컨테이너"></span></span>
<span class="lb__slide" role="group" aria-roledescription="slide" aria-label="6장 중 2장"><span class="ab__ph ab__ph--b" role="img" aria-label="노을 진 사막 능선"></span></span>
<span class="lb__slide" role="group" aria-roledescription="slide" aria-label="6장 중 3장"><span class="ab__ph ab__ph--c" role="img" aria-label="비 온 뒤 도심 골목"></span></span>
<span class="lb__slide" role="group" aria-roledescription="slide" aria-label="6장 중 4장"><span class="ab__ph ab__ph--d" role="img" aria-label="눈 덮인 자작나무 숲"></span></span>
<span class="lb__slide" role="group" aria-roledescription="slide" aria-label="6장 중 5장"><span class="ab__ph ab__ph--e" role="img" aria-label="한낮의 수영장 물결"></span></span>
<span class="lb__slide" role="group" aria-roledescription="slide" aria-label="6장 중 6장"><span class="ab__ph ab__ph--f" role="img" aria-label="밤 열차의 창가 불빛"></span></span>
</div>
</div>
<div class="lb__bar">
<button class="lb__nav" id="prev" type="button" aria-label="이전 사진"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M160 48v160l-80-80Z" opacity=".2"/><path d="M163.06 40.61a8 8 0 0 0-8.72 1.73l-80 80a8 8 0 0 0 0 11.32l80 80A8 8 0 0 0 168 208V48a8 8 0 0 0-4.94-7.39M152 188.69L91.31 128L152 67.31Z"/></g></svg></button>
<span class="lb__count" id="count">1 / 6</span>
<button class="lb__nav" id="next" type="button" aria-label="다음 사진"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg></button>
<button class="lb__close" id="close" type="button" aria-label="닫기"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256" 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>
</dialog>
</main>@use "../../../../site/static/demo-tokens" as *;
// 앨범 격자에서 열리는 라이트박스 슬라이더.
// 위는 grid-template-columns 로 짠 앨범 격자, 칸은 진짜 <button> 이다.
// 누르면 dialog.showModal() 로 창이 뜨고 **그 안이 다시 인덱스 슬라이더**라 창을 닫지 않고 좌우로 넘긴다.
// 창은 position: fixed 인 top layer 라 무대의 스크롤 높이에 더해지지 않는다 — 그래도 폭·높이를 직접 묶어 둔다.
// 사진 자리는 FLUX 이미지가 들어올 자리다 (run/082/_이미지후보.07.json) — 지금은 토큰 그라디언트 합성.
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue-deep;
@include stage($stage-paper);
@mixin scene($top, $mid, $low, $angle, $sunx, $suny) {
background:
radial-gradient(40% 32% at $sunx $suny, rgba(255, 255, 255, .68), rgba(255, 255, 255, 0) 72%),
repeating-linear-gradient($angle, rgba(255, 255, 255, .07) 0 5px, rgba(255, 255, 255, 0) 5px 13px),
linear-gradient(180deg, rgba(255, 255, 255, 0) 44%, rgba($stage-ink, .46) 100%),
linear-gradient($angle, $top 0%, $mid 54%, $low 100%);
}
.ab {
box-sizing: border-box;
display: flex; flex-direction: column; gap: $sp-2;
width: min(100%, 360px);
}
.ab__bar { display: flex; align-items: baseline; gap: $sp-2; }
.ab__title { flex: 0 0 auto; color: $subject-ink; font-weight: 700; font-size: 13px; line-height: 1.3; }
.ab__hint { flex: 1 1 auto; color: rgba($subject-ink, .72); font-weight: 500; font-size: 11px; line-height: 1.3; }
// 앨범 격자 — 세 칸 고정, 칸 비율은 4:3
.ab__grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: $sp-2;
margin: 0; padding: 0; list-style: none;
}
.ab__cell { display: block; }
.ab__btn {
display: block; box-sizing: border-box; width: 100%; aspect-ratio: 4 / 3;
margin: 0; padding: 0; border: 0; border-radius: $r-sm; overflow: hidden;
background: none; cursor: pointer;
transform-origin: 50% 50%;
transition: transform $duration $easing, box-shadow $duration $easing;
}
.ab__btn:hover, .ab__btn:focus-visible { transform: scale(1.06); box-shadow: $shadow-press; }
.ab__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ab__ph { display: block; width: 100%; height: 100%; }
.ab__ph--a { @include scene($subject-blue-deep, $subject-sky, $stage-ink, 196deg, 74%, 22%); }
.ab__ph--b { @include scene($stage-orange, $stage-yellow, $subject-orange-deep, 172deg, 30%, 26%); }
.ab__ph--c { @include scene($stage-ink, $subject-blue-light, $tick-panel, 208deg, 58%, 18%); }
.ab__ph--d { @include scene($subject-sky, $subject-cream, $chart-3, 184deg, 44%, 30%); }
.ab__ph--e { @include scene($subject-mint, $subject-sky, $chart-1, 160deg, 66%, 34%); }
.ab__ph--f { @include scene($tick-panel, $subject-lilac, $stage-ink, 214deg, 36%, 20%); }
// 라이트박스 — dialog 기본 여백·테두리를 걷어내고 크기를 직접 묶는다
.lb {
box-sizing: border-box; width: min(88%, 300px); max-width: 88%;
margin: auto; padding: $sp-2; border: 0; border-radius: $r-card;
background: $stage-ink; color: $subject-cream;
box-shadow: $shadow-raised;
}
.lb::backdrop { background: rgba($stage-ink, .72); }
.lb__box { display: flex; flex-direction: column; gap: $sp-2; }
.lb__frame { overflow: hidden; border-radius: $r-sm; }
.lb__track {
display: flex;
transform: translateX(0); // JS 가 처음 놓는 값과 같은 항등 변환 — 첫 칠에 헛 transition 이 안 생긴다
transition: transform $duration $easing;
will-change: transform;
}
.lb__slide { display: block; flex: 0 0 100%; aspect-ratio: 16 / 9; }
.lb__bar { display: flex; align-items: center; gap: $sp-2; }
.lb__nav, .lb__close {
display: grid; place-items: center;
box-sizing: border-box; width: 24px; height: 24px;
margin: 0; padding: 0; border: 0; border-radius: $r-sm;
background: rgba(255, 255, 255, .16); color: $subject-cream;
font-size: 15px; cursor: pointer;
transition: transform $dur-quick $easing, background $dur-quick $easing;
transform-origin: 50% 50%;
}
.lb__nav:hover, .lb__close:hover { background: $color; transform: scale(1.1); }
.lb__nav:focus-visible, .lb__close:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.lb__close { margin-left: auto; }
.lb__count {
flex: 0 0 auto; color: $subject-cream;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-weight: 700; font-size: 12px; line-height: 1.3;
}
// 좁은 화면 — 격자와 창을 함께 줄여 320×200 에서 넘침 0
@media (max-width: 340px) {
.ab { width: min(100%, 240px); gap: $sp-1; }
.ab__grid { gap: $sp-1; }
.ab__title { font-size: 11px; }
.ab__hint { font-size: 10px; }
.lb { width: min(88%, 220px); padding: $sp-1; }
.lb__box { gap: $sp-1; }
.lb__nav, .lb__close { width: 20px; height: 20px; font-size: 13px; }
.lb__count { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.lb__track, .ab__btn, .lb__nav, .lb__close { transition: none !important; }
}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);
}
.ab {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
width: min(100%, 360px);
}
.ab__bar {
display: flex;
align-items: baseline;
gap: 8px;
}
.ab__title {
flex: 0 0 auto;
color: #17141a;
font-weight: 700;
font-size: 13px;
line-height: 1.3;
}
.ab__hint {
flex: 1 1 auto;
color: rgba(23, 20, 26, 0.72);
font-weight: 500;
font-size: 11px;
line-height: 1.3;
}
.ab__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.ab__cell {
display: block;
}
.ab__btn {
display: block;
box-sizing: border-box;
width: 100%;
aspect-ratio: 4/3;
margin: 0;
padding: 0;
border: 0;
border-radius: 8px;
overflow: hidden;
background: none;
cursor: pointer;
transform-origin: 50% 50%;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ab__btn:hover, .ab__btn:focus-visible {
transform: scale(1.06);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ab__btn:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.ab__ph {
display: block;
width: 100%;
height: 100%;
}
.ab__ph--a {
background: radial-gradient(40% 32% at 74% 22%, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0) 72%), repeating-linear-gradient(196deg, rgba(255, 255, 255, 0.07) 0 5px, rgba(255, 255, 255, 0) 5px 13px), linear-gradient(180deg, rgba(255, 255, 255, 0) 44%, rgba(23, 20, 26, 0.46) 100%), linear-gradient(196deg, #0a4fe8 0%, #6ec8ff 54%, #17141a 100%);
}
.ab__ph--b {
background: radial-gradient(40% 32% at 30% 26%, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0) 72%), repeating-linear-gradient(172deg, rgba(255, 255, 255, 0.07) 0 5px, rgba(255, 255, 255, 0) 5px 13px), linear-gradient(180deg, rgba(255, 255, 255, 0) 44%, rgba(23, 20, 26, 0.46) 100%), linear-gradient(172deg, #ff4d1f 0%, #ffd23f 54%, #c93000 100%);
}
.ab__ph--c {
background: radial-gradient(40% 32% at 58% 18%, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0) 72%), repeating-linear-gradient(208deg, rgba(255, 255, 255, 0.07) 0 5px, rgba(255, 255, 255, 0) 5px 13px), linear-gradient(180deg, rgba(255, 255, 255, 0) 44%, rgba(23, 20, 26, 0.46) 100%), linear-gradient(208deg, #17141a 0%, #5b8cf8 54%, #231f28 100%);
}
.ab__ph--d {
background: radial-gradient(40% 32% at 44% 30%, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0) 72%), repeating-linear-gradient(184deg, rgba(255, 255, 255, 0.07) 0 5px, rgba(255, 255, 255, 0) 5px 13px), linear-gradient(180deg, rgba(255, 255, 255, 0) 44%, rgba(23, 20, 26, 0.46) 100%), linear-gradient(184deg, #6ec8ff 0%, #fff7e6 54%, #2b9d74 100%);
}
.ab__ph--e {
background: radial-gradient(40% 32% at 66% 34%, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0) 72%), repeating-linear-gradient(160deg, rgba(255, 255, 255, 0.07) 0 5px, rgba(255, 255, 255, 0) 5px 13px), linear-gradient(180deg, rgba(255, 255, 255, 0) 44%, rgba(23, 20, 26, 0.46) 100%), linear-gradient(160deg, #4cd4a6 0%, #6ec8ff 54%, #2f6df6 100%);
}
.ab__ph--f {
background: radial-gradient(40% 32% at 36% 20%, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0) 72%), repeating-linear-gradient(214deg, rgba(255, 255, 255, 0.07) 0 5px, rgba(255, 255, 255, 0) 5px 13px), linear-gradient(180deg, rgba(255, 255, 255, 0) 44%, rgba(23, 20, 26, 0.46) 100%), linear-gradient(214deg, #231f28 0%, #b9a0ff 54%, #17141a 100%);
}
.lb {
box-sizing: border-box;
width: min(88%, 300px);
max-width: 88%;
margin: auto;
padding: 8px;
border: 0;
border-radius: 16px;
background: #17141a;
color: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.lb::backdrop {
background: rgba(23, 20, 26, 0.72);
}
.lb__box {
display: flex;
flex-direction: column;
gap: 8px;
}
.lb__frame {
overflow: hidden;
border-radius: 8px;
}
.lb__track {
display: flex;
transform: translateX(0);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
will-change: transform;
}
.lb__slide {
display: block;
flex: 0 0 100%;
aspect-ratio: 16/9;
}
.lb__bar {
display: flex;
align-items: center;
gap: 8px;
}
.lb__nav, .lb__close {
display: grid;
place-items: center;
box-sizing: border-box;
width: 24px;
height: 24px;
margin: 0;
padding: 0;
border: 0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.16);
color: #fff7e6;
font-size: 15px;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
transform-origin: 50% 50%;
}
.lb__nav:hover, .lb__close:hover {
background: #0a4fe8;
transform: scale(1.1);
}
.lb__nav:focus-visible, .lb__close:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.lb__close {
margin-left: auto;
}
.lb__count {
flex: 0 0 auto;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.3;
}
@media (max-width: 340px) {
.ab {
width: min(100%, 240px);
gap: 4px;
}
.ab__grid {
gap: 4px;
}
.ab__title {
font-size: 11px;
}
.ab__hint {
font-size: 10px;
}
.lb {
width: min(88%, 220px);
padding: 4px;
}
.lb__box {
gap: 4px;
}
.lb__nav, .lb__close {
width: 20px;
height: 20px;
font-size: 13px;
}
.lb__count {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.lb__track, .ab__btn, .lb__nav, .lb__close {
transition: none !important;
}
}var ab = document.getElementById('ab');
var lb = document.getElementById('lb');
var lbtrack = document.getElementById('lbtrack');
var count = document.getElementById('count');
var cells = [].slice.call(document.querySelectorAll('.ab__btn'));
var total = lbtrack.children.length;
var index = 0, opener = null, timer = 0;
function show(next) {
index = (next + total) % total;
lbtrack.style.transform = 'translateX(' + (-index * 100) + '%)';
count.textContent = (index + 1) + ' / ' + total;
[].slice.call(lbtrack.children).forEach(function (slide, i) {
slide.setAttribute('aria-hidden', i === index ? 'false' : 'true');
});
}
// 격자 칸이 dialog 를 연다. 열린 뒤에도 창은 그대로 두고 트랙만 움직이는 것이 이 항목의 축이다
function open(at, from) {
opener = from || cells[at];
show(at);
if (!lb.open) { lb.showModal(); }
}
function stopDemo() { ab.classList.remove('is-demo'); clearInterval(timer); }
cells.forEach(function (cell, i) {
cell.addEventListener('click', function () { stopDemo(); open(i, cell); });
});
document.getElementById('prev').addEventListener('click', function () { stopDemo(); show(index - 1); });
document.getElementById('next').addEventListener('click', function () { stopDemo(); show(index + 1); });
document.getElementById('close').addEventListener('click', function () { stopDemo(); lb.close(); });
lb.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') { return; }
e.preventDefault(); stopDemo(); show(index + (e.key === 'ArrowRight' ? 1 : -1));
});
// Esc 로 닫아도 close 이벤트는 온다 — 초점은 항상 눌렀던 격자 칸으로 돌아간다
lb.addEventListener('close', function () {
if (opener) { opener.focus({ preventScroll: true }); }
});
show(0);
if (ab.classList.contains('is-demo')) {
timer = setInterval(function () {
if (!lb.open) { open(0, cells[0]); }
else if (index < total - 1) { show(index + 1); }
else { lb.close(); }
}, 460);
}// AlbumGridLightbox — vanilla 판(run/082/demo/08-slider-album-grid-lightbox/index.html) 의 React 포트.
// grid-template-columns 로 짠 앨범 격자의 칸은 진짜 button 이고, 누르면 dialog.showModal() 로 창이 열린다.
// 창 안은 다시 인덱스 슬라이더라 닫지 않고 좌우로 다음 장을 본다. Esc 로 닫히고, 닫히면 눌렀던 칸으로 초점이 돌아간다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./AlbumGridLightbox.module.scss";
export type AlbumGridLightboxProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef<T> 는 T | null 을 돌려준다 — ambient 타입에 맞춰 상자 타입을 따로 둔다
type Box<T> = { current: T | null };
const TITLE = "앨범 격자";
const HINT = "창을 닫지 않고 넘깁니다";
const DIALOG_LABEL = "사진 크게 보기";
const CAROUSEL_LABEL = "라이트박스 사진 슬라이더";
const PREV_LABEL = "이전 사진";
const NEXT_LABEL = "다음 사진";
const CLOSE_LABEL = "닫기";
const PHOTOS = [
{ tone: "a", alt: "새벽 부두의 컨테이너", open: "새벽 부두의 컨테이너 크게 보기", slide: "6장 중 1장" },
{ tone: "b", alt: "노을 진 사막 능선", open: "노을 진 사막 능선 크게 보기", slide: "6장 중 2장" },
{ tone: "c", alt: "비 온 뒤 도심 골목", open: "비 온 뒤 도심 골목 크게 보기", slide: "6장 중 3장" },
{ tone: "d", alt: "눈 덮인 자작나무 숲", open: "눈 덮인 자작나무 숲 크게 보기", slide: "6장 중 4장" },
{ tone: "e", alt: "한낮의 수영장 물결", open: "한낮의 수영장 물결 크게 보기", slide: "6장 중 5장" },
{ tone: "f", alt: "밤 열차의 창가 불빛", open: "밤 열차의 창가 불빛 크게 보기", slide: "6장 중 6장" },
];
// Phosphor Duotone (MIT) — 인라인이라 런타임 외부 요청이 없다
const CaretLeft = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d="M160 48v160l-80-80Z" opacity=".2" />
<path d="M163.06 40.61a8 8 0 0 0-8.72 1.73l-80 80a8 8 0 0 0 0 11.32l80 80A8 8 0 0 0 168 208V48a8 8 0 0 0-4.94-7.39M152 188.69L91.31 128L152 67.31Z" />
</g>
</svg>
);
const CaretRight = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d="m176 128l-80 80V48Z" opacity=".2" />
<path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z" />
</g>
</svg>
);
const CloseMark = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256" 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 AlbumGridLightbox(props: AlbumGridLightboxProps) {
const duration = props.duration ?? 300;
const easing = props.easing ?? "cubic-bezier(.2, .8, .2, 1)";
const color = props.color ?? "#0a4fe8";
const [index, setIndex] = useState<number>(0);
const [preview, setPreview] = useState<boolean>(true);
const dialogRef = useRef<HTMLDialogElement>(null) as Box<HTMLDialogElement>;
const openerRef = useRef<HTMLButtonElement>(null) as Box<HTMLButtonElement>;
const indexRef = useRef<number>(0) as Box<number>;
useEffect(() => {
indexRef.current = index;
}, [index]);
const show = (next: number) => {
setIndex((next + PHOTOS.length) % PHOTOS.length);
};
const open = (at: number, from: HTMLButtonElement | null) => {
openerRef.current = from;
setIndex(at);
const dialog = dialogRef.current;
if (dialog && !dialog.open) {
dialog.showModal();
}
};
const close = () => {
const dialog = dialogRef.current;
if (dialog && dialog.open) {
dialog.close();
}
};
// Esc 로 닫아도 close 이벤트는 온다 — 초점은 항상 눌렀던 격자 칸으로 돌아간다
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) {
return;
}
const onClose = () => {
const opener = openerRef.current;
if (opener) {
opener.focus({ preventScroll: true });
}
};
dialog.addEventListener("close", onClose);
return () => {
dialog.removeEventListener("close", onClose);
};
}, []);
// 미리보기 — 창을 열고, 열린 채로 끝까지 넘기고, 닫는다
useEffect(() => {
if (!preview) {
return;
}
const id = window.setInterval(() => {
const dialog = dialogRef.current;
const at = indexRef.current ?? 0;
if (!dialog) {
return;
}
if (!dialog.open) {
setIndex(0);
dialog.showModal();
} else if (at < PHOTOS.length - 1) {
setIndex(at + 1);
} else {
dialog.close();
}
}, 460);
return () => {
window.clearInterval(id);
};
}, [preview]);
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
return (
<main className={styles.stage} style={vars}>
<section className={styles.root}>
<header className={styles.bar}>
<span className={styles.title}>{TITLE}</span>
<span className={styles.hint}>{HINT}</span>
</header>
<ul className={styles.grid}>
{PHOTOS.map((photo, i) => (
<li className={styles.cell} key={photo.tone}>
<button
className={styles.btn}
type="button"
aria-label={photo.open}
onClick={(e: { currentTarget: HTMLButtonElement }) => {
setPreview(false);
open(i, e.currentTarget);
}}
>
<span className={styles.ph + " " + styles["ph--" + photo.tone]} role="img" aria-label={photo.alt} />
</button>
</li>
))}
</ul>
</section>
<dialog
className={styles.lb}
ref={dialogRef}
aria-label={DIALOG_LABEL}
onKeyDown={(e: { key: string; preventDefault: () => void }) => {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") {
return;
}
e.preventDefault();
setPreview(false);
show(index + (e.key === "ArrowRight" ? 1 : -1));
}}
>
<div className={styles.box} role="region" aria-roledescription="carousel" aria-label={CAROUSEL_LABEL}>
<div className={styles.frame}>
<div className={styles.track} style={{ transform: "translateX(" + -index * 100 + "%)" }}>
{PHOTOS.map((photo, i) => (
<span
className={styles.slide}
key={photo.tone}
role="group"
aria-roledescription="slide"
aria-label={photo.slide}
aria-hidden={i === index ? "false" : "true"}
>
<span className={styles.ph + " " + styles["ph--" + photo.tone]} role="img" aria-label={photo.alt} />
</span>
))}
</div>
</div>
<div className={styles.lbbar}>
<button
className={styles.nav}
type="button"
aria-label={PREV_LABEL}
onClick={() => {
setPreview(false);
show(index - 1);
}}
>
<CaretLeft />
</button>
<span className={styles.count}>{index + 1} / {PHOTOS.length}</span>
<button
className={styles.nav}
type="button"
aria-label={NEXT_LABEL}
onClick={() => {
setPreview(false);
show(index + 1);
}}
>
<CaretRight />
</button>
<button
className={styles.close}
type="button"
aria-label={CLOSE_LABEL}
onClick={() => {
setPreview(false);
close();
}}
>
<CloseMark />
</button>
</div>
</div>
</dialog>
</main>
);
}08 앨범 격자에서 열리는 라이트박스 슬라이더 — 측정 누적면적 99.915% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 이미지 슬라이드 css 9종 — GSAP 없이 반응형까지
이 데모가 속한 글
이미지 슬라이드 css 9종 — GSAP 없이 반응형까지이미지 슬라이드 css 는 사진 자리 여러 장을 한 줄로 이어 붙여 화살표·점·손가락으로 한 장씩 보여 주는 화면 부품입니다. GSAP 없이 아홉 개를 직접 짰는데 자바스크립트는 인덱스 숫자 하나만 바꾸고 거리·색· 이 글의 데모 전부 보기


