# 이미지 갤러리

여러 장의 그림을 한 묶음으로 보여 주고, 눌러서 크게 넘겨 보는 갤러리예요.

- Type: component
- Theme: base
- Tags: arc, component, gallery, image, lightbox, media
- English title: Image gallery
- URL: https://dsui.vercel.app/items/image-gallery

## Controls

| Key | Type | Label | Default |
| --- | --- | --- | --- |
| layout | select | 배치 | fan |

## Source

### image-gallery.module.css

```css
.root { display: grid; width: 100%; min-width: 0; gap: var(--space-3); grid-template-columns: repeat(2, minmax(0, 1fr)); }
.root[data-layout="grid"][data-count="1"] { grid-template-columns: minmax(0, 1fr); }
/* Three pictures: the first one spans both columns, the other two share the row under it. */
.root[data-layout="grid"][data-count="3"] .tile:first-child { grid-column: 1 / -1; aspect-ratio: 16 / 9; }
.root[data-layout="strip"] { display: flex; overflow-x: auto; padding-bottom: var(--space-2); scroll-snap-type: x proximity; }
.root[data-layout="strip"] .tile { flex: 0 0 min(78%, 360px); scroll-snap-align: start; }

.tile { position: relative; display: block; overflow: hidden; width: 100%; aspect-ratio: 4 / 3; padding: 0; border: 1px solid var(--border); border-radius: var(--radius-surface); background: var(--surface-muted); color: inherit; font: inherit; line-height: 0; cursor: zoom-in; -webkit-tap-highlight-color: transparent; transition: transform var(--duration-spring) var(--ease-spring), box-shadow var(--duration-standard) var(--ease-standard); }
.thumb { display: block; width: 100%; height: 100%; object-fit: cover; }
.tile:focus-visible { --focus-outline-offset: 3px; }
.tile:active { transform: scale(.97); transition: transform var(--duration-instant) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) {
  .tile:hover { transform: scale(1.02); box-shadow: var(--shadow-raised); }
  .tile:hover .badge, .tile:focus-visible .badge { opacity: 1; transform: none; }
}

.badge { position: absolute; right: var(--space-3); bottom: var(--space-3); display: grid; width: 36px; height: 36px; place-items: center; border-radius: 50%; background: var(--surface-raised); color: var(--foreground); box-shadow: 0 0 0 1px var(--surface-raised), var(--shadow-floating); opacity: 0; transform: translateY(4px) scale(.9); transition: opacity var(--duration-standard) var(--ease-standard), transform var(--duration-standard) var(--ease-spring); pointer-events: none; }
/* The last visible tile counts the pictures behind it. */
.more { position: absolute; inset: 0; display: grid; place-items: center; background: oklch(10% 0 0 / .52); color: oklch(98% 0 0); font-size: 1.75rem; font-weight: 500; line-height: 1; }

/* The fan: overlapping tilted cards like the blog fan. Hovering a card lifts it and pushes its neighbours away. */
.fan { position: relative; width: 100%; height: var(--fh, 320px); margin-block: var(--space-4); }
.slot { position: absolute; top: 0; left: 0; z-index: var(--z, 1); width: var(--cw); height: var(--ch); transform: translate(calc(var(--x) + var(--sx, 0px)), var(--y)) rotate(var(--rot)) scale(var(--ss, 1)); transition: transform .85s cubic-bezier(.26, 1.4, .42, 1) calc(var(--i) * 60ms), opacity .5s calc(var(--i) * 60ms); }
.fan:not([data-in="true"]) .slot { opacity: 0; transform: translate(var(--cx), calc(var(--y) + 40px)) rotate(0deg) scale(.7); }
.slot:hover, .slot:focus-within { z-index: 30; }
.fcard { position: relative; display: block; overflow: hidden; width: 100%; height: 100%; padding: 0; border: 0; border-radius: calc(var(--cw) * .07); background: var(--surface-muted); box-shadow: 0 1.2em 2.4em rgba(26, 26, 26, .18), 0 .15em .4em rgba(26, 26, 26, .1); color: inherit; font: inherit; line-height: 0; cursor: zoom-in; -webkit-tap-highlight-color: transparent; transition: transform var(--duration-spring) var(--ease-spring), box-shadow var(--duration-standard) var(--ease-standard); }
.fcard:focus-visible { --focus-outline-offset: 4px; }
.fcard:active { transform: scale(.96); transition: transform var(--duration-instant) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .fcard:hover { transform: translateY(-.9em) scale(1.1) rotate(calc(var(--rot) * -1)); box-shadow: 0 2.2em 3.6em rgba(26, 26, 26, .3); } }
@media (hover: none) { .fcard:hover { transform: none; } }
@media (prefers-reduced-motion: reduce) { .slot, .fcard { transition: none !important; } .fan:not([data-in="true"]) .slot { opacity: 1; transform: translate(var(--x), var(--y)) rotate(var(--rot)); } }
:root[data-motion="reduce"] .slot, :root[data-motion="reduce"] .fcard { transition: none; }
:root[data-motion="reduce"] .fan:not([data-in="true"]) .slot { opacity: 1; transform: translate(var(--x), var(--y)) rotate(var(--rot)); }

.layer { position: fixed; inset: 0; z-index: 90; display: grid; grid-template-rows: minmax(0, 1fr) auto; justify-items: center; gap: var(--space-3); padding: var(--space-6) var(--space-4) var(--space-4); background: oklch(10% 0 0 / .66); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
.stage { display: grid; width: 100%; min-height: 0; place-items: center; }
.figure { display: grid; justify-items: center; gap: var(--space-3); max-width: min(94vw, 1200px); margin: 0; touch-action: pan-y; }
.big { display: block; max-width: 100%; max-height: 66vh; width: auto; height: auto; border-radius: var(--radius-surface); background: var(--surface); box-shadow: var(--shadow-floating); -webkit-user-drag: none; user-select: none; }
.caption { display: grid; gap: var(--space-1); max-width: min(94vw, 720px); margin: 0; color: oklch(96% 0 0); font-size: var(--text-sm); line-height: var(--leading-body); text-align: center; }
.source { color: oklch(80% 0 0); font-size: var(--text-xs); }
.source a { color: inherit; text-underline-offset: 3px; }

.bar { display: flex; align-items: center; gap: var(--space-3); }
.counter { min-width: 7ch; color: oklch(96% 0 0); font-size: var(--text-sm); font-variant-numeric: tabular-nums; text-align: center; }
.control { display: grid; width: 44px; height: 44px; place-items: center; padding: 0; border: 0; border-radius: 50%; background: var(--surface-raised); color: var(--foreground); box-shadow: 0 0 0 1px var(--surface-raised), var(--shadow-floating); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: transform var(--duration-spring) var(--ease-spring), background-color var(--duration-fast) var(--ease-standard); }
.control:focus-visible { --focus-outline-offset: 3px; }
.control:active { transform: scale(.9); background: var(--surface-muted); }
@media (hover: hover) and (pointer: fine) { .control:hover { transform: scale(1.06); background: var(--surface-muted); } }
.close { position: absolute; top: var(--space-4); right: var(--space-4); }
/* A mouse over the dimmed area gets the round close button as its cursor (the native one is hidden there); touch and keyboard use the close button. */
.cursor { position: fixed; top: 0; left: 0; z-index: 2; display: none; place-items: center; width: 56px; height: 56px; margin: -28px 0 0 -28px; border-radius: 50%; background: var(--surface-raised); color: var(--foreground); box-shadow: 0 0 0 1px var(--surface-raised), var(--shadow-floating); opacity: 0; pointer-events: none; }
@media (hover: hover) and (pointer: fine) {
  .layer { cursor: none; }
  .cursor { display: grid; }
  .figure, .big, .caption, .counter { cursor: default; }
  .control, .source a { cursor: pointer; }
}

@media (hover: none), (prefers-reduced-motion: reduce) { .tile:hover, .control:hover { transform: none; } }
@media (prefers-reduced-motion: reduce) { .tile, .badge, .control { transition: none; } }
:root[data-motion="reduce"] .tile, :root[data-motion="reduce"] .badge, :root[data-motion="reduce"] .control { transition: none; }
@media print { .badge, .close, .bar { display: none; } }

```

### image-gallery.tsx

```tsx
"use client";

import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import type { CSSProperties } from "react";
import { createPortal } from "react-dom";
import type { PointerEvent } from "react";
import { AnimatePresence, motion, useMotionValue } from "motion/react";
import { cardCount, fanLayout, neighbourShift } from "../blocks/blog-fan/fan";
import { motionTokens } from "../lib/motion-tokens";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { playUiSound } from "../sound/play";
import { UiIcon } from "../theme/ui-icon";
import styles from "./image-gallery.module.css";

export interface ImageGalleryItem {
  src: string;
  /** What the picture shows. It is also the lightbox caption when `caption` is not given. */
  alt: string;
  caption?: string;
  /** Where the picture comes from, shown under it in the lightbox. */
  source?: { label: string; href?: string };
  width?: number;
  height?: number;
}

/** Use an image gallery when several pictures belong together and a reader should see them as one bundle, then page through them larger. */
export interface ImageGalleryProps {
  images: ImageGalleryItem[];
  /** Names the bundle for assistive technology, such as "화면 모음". */
  label?: string;
  /**
   * `grid` tiles the first four pictures (the rest sit behind a "+N" tile); `strip` lays them in a row that scrolls sideways; `fan` spreads
   * them as overlapping tilted cards like the blog fan (hovering one pushes its neighbours away; the cards beyond what fits sit behind "+N").
   */
  layout?: "grid" | "strip" | "fan";
  locale?: "ko" | "en";
  className?: string;
}

const TEXT = {
  ko: { open: "크게 보기", close: "닫기", prev: "이전 그림", next: "다음 그림", of: (i: number, n: number) => `${n}장 중 ${i}번째` },
  en: { open: "View larger", close: "Close", prev: "Previous picture", next: "Next picture", of: (i: number, n: number) => `${i} of ${n}` },
} as const;

const subscribeNothing = () => () => {};
const GRID_LIMIT = 4;
const SWIPE = 56;

/**
 * A bundle of pictures. Up to four show as tiles (a fifth and beyond are counted on the last tile); a click opens a lightbox that pages through all of
 * them with the arrow buttons, the arrow keys, or a swipe, with the caption and source of the picture on show. Esc, the close button or a click
 * outside closes it and focus goes back to the tile. Over the dimmed area around the picture a mouse cursor turns into a round close button that follows
 * it (a fine pointer only). With reduced motion the slide and the fades are instant.
 */
export function ImageGallery({ images, label, layout = "grid", locale = "ko", className }: ImageGalleryProps) {
  const t = TEXT[locale];
  const reduce = useReducedMotion();
  const mounted = useSyncExternalStore(subscribeNothing, () => true, () => false);
  const [open, setOpen] = useState<number | null>(null);
  const [dir, setDir] = useState(1);
  const opener = useRef<HTMLButtonElement | null>(null);
  /** True while a mouse is over the dimmed area (not the picture, the caption or a button): there the cursor is the close button. */
  const [overBackdrop, setOverBackdrop] = useState(false);
  const cursorX = useMotionValue(0);
  const cursorY = useMotionValue(0);
  const count = images.length;
  const fanRef = useRef<HTMLDivElement>(null);
  const [fanWidth, setFanWidth] = useState(900);
  const [seen, setSeen] = useState(false);
  const [fanFocus, setFanFocus] = useState<number | null>(null);

  // The fan follows the width it has, and plays its entrance once, when it comes into view.
  useEffect(() => {
    if (layout !== "fan") return;
    const el = fanRef.current;
    if (!el) return;
    setFanWidth(el.clientWidth || 900);
    let timer = 0;
    const ro = new ResizeObserver(() => {
      window.clearTimeout(timer);
      timer = window.setTimeout(() => setFanWidth(el.clientWidth || 900), 120);
    });
    ro.observe(el);
    const io = new IntersectionObserver((entries) => {
      if (!entries[0]?.isIntersecting) return;
      setSeen(true);
      io.disconnect();
    }, { threshold: 0.2 });
    io.observe(el);
    return () => {
      ro.disconnect();
      io.disconnect();
      window.clearTimeout(timer);
    };
  }, [layout]);

  const show = (index: number, el: HTMLButtonElement) => {
    playUiSound("open");
    opener.current = el;
    setDir(1);
    setOpen(index);
  };
  const hide = useCallback(() => {
    playUiSound("close");
    setOpen(null);
  }, []);
  const go = useCallback(
    (step: number) => {
      setDir(step);
      setOpen((i) => (i === null ? i : (i + step + count) % count));
      playUiSound("tick");
    },
    [count],
  );

  useEffect(() => {
    if (open === null) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") hide();
      else if (e.key === "ArrowRight") go(1);
      else if (e.key === "ArrowLeft") go(-1);
    };
    const overflow = document.documentElement.style.overflow;
    document.documentElement.style.overflow = "hidden";
    window.addEventListener("keydown", onKey);
    return () => {
      window.removeEventListener("keydown", onKey);
      document.documentElement.style.overflow = overflow;
    };
  }, [open, hide, go]);

  const follow = (e: PointerEvent<HTMLDivElement>) => {
    if (e.pointerType !== "mouse") return;
    cursorX.set(e.clientX);
    cursorY.set(e.clientY);
    setOverBackdrop((e.target as HTMLElement).dataset.backdrop === "true");
  };

  if (count === 0) return null;
  const tiles = layout === "grid" ? images.slice(0, GRID_LIMIT) : images;
  const hidden = layout === "grid" ? count - tiles.length : 0;
  const current = open === null ? null : images[open];
  const slide = reduce ? { duration: 0 } : motionTokens.spring.smooth;
  const fade = reduce ? { duration: 0 } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.standard] as [number, number, number, number] };

  const fanCount = Math.min(count, cardCount(fanWidth));
  const fanHidden = count - fanCount;
  const fanFirst = fanLayout(fanWidth, fanCount, 0);

  return (
    <>
      {layout === "fan" ? (
        <div
          ref={fanRef}
          className={[styles.fan, className].filter(Boolean).join(" ")}
          role="group"
          aria-label={label}
          data-in={reduce || seen}
          style={{ "--fh": `${(fanFirst.ch + fanFirst.drop + 36).toFixed(0)}px` } as CSSProperties}
          onPointerLeave={() => setFanFocus(null)}
        >
          {images.slice(0, fanCount).map((image, i) => {
            const L = fanLayout(fanWidth, fanCount, i);
            const sh = !reduce && fanFocus !== null ? neighbourShift(fanWidth, L.cw, L.x, i, fanFocus) : { sx: 0, ss: 1 };
            const slot = { "--cw": `${L.cw.toFixed(1)}px`, "--ch": `${L.ch.toFixed(1)}px`, "--x": `${L.x.toFixed(1)}px`, "--y": `${L.y.toFixed(1)}px`, "--cx": `${L.cx.toFixed(1)}px`, "--rot": `${L.rot.toFixed(1)}deg`, "--z": L.z, "--i": L.dist, "--sx": `${sh.sx.toFixed(1)}px`, "--ss": sh.ss.toFixed(3) } as CSSProperties;
            const last = i === fanCount - 1 && fanHidden > 0;
            return (
              <div key={`${image.src}-${i}`} className={styles.slot} style={slot} onPointerEnter={() => setFanFocus(i)} onFocus={() => setFanFocus(i)}>
                <button type="button" className={styles.fcard} onClick={(e) => show(i, e.currentTarget)} aria-haspopup="dialog" aria-label={`${image.alt}, ${t.open}`}>
                  <img className={styles.thumb} src={image.src} alt="" width={image.width} height={image.height} loading="lazy" decoding="async" draggable={false} />
                  {last ? <span className={styles.more}>+{fanHidden}</span> : null}
                </button>
              </div>
            );
          })}
        </div>
      ) : (
      <div className={[styles.root, className].filter(Boolean).join(" ")} data-layout={layout} data-count={Math.min(count, GRID_LIMIT)} role="group" aria-label={label}>
        {tiles.map((image, i) => {
          const last = i === tiles.length - 1 && hidden > 0;
          return (
            <button key={`${image.src}-${i}`} type="button" className={styles.tile} onClick={(e) => show(i, e.currentTarget)} aria-haspopup="dialog" aria-label={`${image.alt}, ${t.open}`}>
              <img className={styles.thumb} src={image.src} alt="" width={image.width} height={image.height} loading="lazy" decoding="async" />
              {last ? <span className={styles.more}>+{hidden}</span> : null}
              <span className={styles.badge} aria-hidden="true">
                <UiIcon iconRole="search" size={18} strokeWidth={1.75} />
              </span>
            </button>
          );
        })}
      </div>
      )}
      {mounted
        ? createPortal(
            <AnimatePresence onExitComplete={() => opener.current?.focus({ preventScroll: true })}>
              {current && open !== null ? (
                <motion.div className={styles.layer} role="dialog" aria-modal="true" aria-label={label ?? t.open} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={fade} onClick={hide} onPointerMove={follow} onPointerLeave={() => setOverBackdrop(false)} data-backdrop="true">
                  <div className={styles.stage} data-backdrop="true" onClick={(e) => e.target === e.currentTarget && hide()}>
                    <AnimatePresence mode="popLayout" custom={dir} initial={false}>
                      <motion.figure
                        key={open}
                        className={styles.figure}
                        onClick={(e) => e.stopPropagation()}
                        custom={dir}
                        variants={{ enter: (d: number) => ({ opacity: 0, x: reduce ? 0 : d * 48 }), center: { opacity: 1, x: 0 }, leave: (d: number) => ({ opacity: 0, x: reduce ? 0 : d * -48 }) }}
                        initial="enter"
                        animate="center"
                        exit="leave"
                        transition={slide}
                        drag={reduce || count < 2 ? false : "x"}
                        dragConstraints={{ left: 0, right: 0 }}
                        dragElastic={0.3}
                        onDragEnd={(_, info) => {
                          if (info.offset.x <= -SWIPE) go(1);
                          else if (info.offset.x >= SWIPE) go(-1);
                        }}
                      >
                        <img className={styles.big} src={current.src} alt={current.alt} draggable={false} />
                        <figcaption className={styles.caption}>
                          {current.caption ?? current.alt}
                          {current.source ? (
                            <span className={styles.source}>
                              {current.source.href ? (
                                <a href={current.source.href} target="_blank" rel="noopener noreferrer">
                                  {current.source.label}
                                </a>
                              ) : (
                                current.source.label
                              )}
                            </span>
                          ) : null}
                        </figcaption>
                      </motion.figure>
                    </AnimatePresence>
                  </div>
                  {count > 1 ? (
                    <div className={styles.bar} onClick={(e) => e.stopPropagation()}>
                      <button type="button" className={styles.control} onClick={() => go(-1)} aria-label={t.prev}>
                        <UiIcon iconRole="chevron-left" size={20} strokeWidth={1.75} aria-hidden="true" />
                      </button>
                      <span className={styles.counter} aria-live="polite">
                        {t.of(open + 1, count)}
                      </span>
                      <button type="button" className={styles.control} onClick={() => go(1)} aria-label={t.next}>
                        <UiIcon iconRole="chevron-right" size={20} strokeWidth={1.75} aria-hidden="true" />
                      </button>
                    </div>
                  ) : null}
                  <motion.span className={styles.cursor} style={{ x: cursorX, y: cursorY }} initial={false} animate={{ opacity: overBackdrop ? 1 : 0, scale: overBackdrop ? 1 : 0.5 }} transition={reduce ? { duration: 0 } : motionTokens.spring.snappy} aria-hidden="true">
                    <UiIcon iconRole="close" size={22} strokeWidth={1.75} />
                  </motion.span>
                  <button
                    type="button"
                    className={`${styles.control} ${styles.close}`}
                    onClick={(e) => {
                      e.stopPropagation();
                      hide();
                    }}
                    aria-label={t.close}
                    autoFocus
                  >
                    <UiIcon iconRole="close" size={20} strokeWidth={1.75} aria-hidden="true" />
                  </button>
                </motion.div>
              ) : null}
            </AnimatePresence>,
            document.body,
          )
        : null}
    </>
  );
}

export default ImageGallery;

```
