Photo Grid

Photos pop in, shrink away and bounce into their new spots.

Photos · 4

  • Dune

    Soft light

  • Drift

    Blurred

  • Ridge

    High contrast

  • Haze

    Faded

"use client"

import { startTransition, useLayoutEffect, useRef, ViewTransition } from "react"

const icon = {
  width: 16,
  height: 16,
  viewBox: "0 0 16 16",
  fill: "none",
  stroke: "currentColor",
  strokeWidth: 1.5,
  strokeLinecap: "round",
  strokeLinejoin: "round",
  "aria-hidden": true,
} as const
const Trash2 = ({ className }: { className?: string }) => (
  <svg {...icon} className={className}>
    <path d="M2.75 4.5h10.5" />
    <path d="M6.25 4.5V3.25h3.5V4.5" />
    <path d="M4.25 4.5l.6 8.1a1 1 0 0 0 1 .9h4.3a1 1 0 0 0 1-.9l.6-8.1" />
    <path d="M6.75 7v4" />
    <path d="M9.25 7v4" />
  </svg>
)

export type Photo = {
  id: string | number
  name: string
  detail?: string
  image: string
  filter?: string
}
const grid = "grid grid-cols-3 auto-rows-[9rem] gap-3 @min-[360px]:grid-cols-5"

export function PhotoGrid({
  photos,
  onRemove,
  height = "22rem",
}: {
  photos: Photo[]
  onRemove?: (id: Photo["id"]) => void
  height?: string
}) {
  const root = useRef<HTMLDivElement>(null)
  const removed = useRef<number | null>(null)
  useLayoutEffect(() => {
    const el = root.current
    const transition = document.activeViewTransition
    if (!el || !transition) return
    el.dataset.animating = ""
    transition.finished.finally(() => delete el.dataset.animating)
  }, [photos])

  useLayoutEffect(() => {
    const i = removed.current
    const el = root.current
    if (i === null || !el) return
    removed.current = null
    const buttons = el.querySelectorAll<HTMLElement>("[data-remove]")
    const next = buttons[Math.min(i, buttons.length - 1)] ?? el.querySelector("[role=status]")
    ;(next as HTMLElement | null)?.focus({ preventScroll: true })
  }, [photos])

  return (
    <div
      ref={root}
      className="group/grid @container relative w-full max-w-md overflow-y-auto text-foreground"
      style={{ height }}
    >
      <ul className={grid} aria-label="Photos">
        {photos.map((photo) => (
          <ViewTransition key={photo.id} default="vt-pop">
            <li className="relative min-w-0">
              <div className="relative h-24 overflow-hidden rounded-xl bg-muted">
                <div
                  className={`absolute inset-0 ${photo.filter ? "scale-110" : ""}`}
                  style={{ background: photo.image, filter: photo.filter }}
                />
              </div>
              {onRemove && (
                <span
                  aria-hidden="true"
                  className="absolute top-16 right-1 inline-flex size-7 items-center justify-center rounded-full border border-border bg-background text-foreground"
                >
                  <Trash2 className="size-3" />
                </span>
              )}
              <p className="mt-1.5 truncate text-xs font-medium" title={photo.name}>
                {photo.name}
              </p>
              {photo.detail && (
                <p className="truncate text-[11px] text-muted-foreground">{photo.detail}</p>
              )}
            </li>
          </ViewTransition>
        ))}
      </ul>
      {photos.length === 0 && (
        <p role="status" tabIndex={-1} className="text-sm text-muted-foreground outline-none">
          No photos yet.
        </p>
      )}
      {onRemove && (
        <div
          className={`${grid} pointer-events-none absolute inset-x-0 top-0 group-has-[[style*=view-transition-name]]/grid:opacity-0 group-data-[animating]/grid:opacity-0`}
        >
          {photos.map((photo, i) => (
            <div key={photo.id} className="flex h-36 items-start justify-end pt-16 pr-1">
              <button
                type="button"
                data-remove
                aria-label={`Remove ${photo.name}`}
                onClick={() => {
                  removed.current = i
                  document.activeViewTransition?.skipTransition()
                  startTransition(() => onRemove(photo.id))
                }}
                className="pointer-events-auto inline-flex size-7 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
              >
                <Trash2 className="size-3" />
              </button>
            </div>
          ))}
        </div>
      )}
    </div>
  )
}

Installation

  1. 1Copy the Component tab into components/photo-grid.tsx.
  2. 2Paste the CSS tab into globals.css, after Tailwind.

How it works

Stable photo keys preserve identity across shuffles. Added and removed photos scale and fade, while the rest move to their new spots with the same bounce curve.

Uses
keyed boundaries, vt-pop
Theme
--background, --foreground, --muted, --muted-foreground, --border, --ring