{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gallery-1",
  "title": "Gallery 1: filterable gallery with lightbox",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A photo gallery: filter chips with counts, a gap-free masonry of tiles that fade in, and a lightbox with caption, counter, thumbnail strip, arrow-key and swipe navigation, and a link to the original.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/cover-art.json",
    "https://ui.ballmac.com/r/dialog.json",
    "https://ui.ballmac.com/r/direction.json",
    "https://ui.ballmac.com/r/masonry-grid.json",
    "https://ui.ballmac.com/r/media.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/return-focus.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/gallery-1/gallery-1.tsx",
      "content": "// Ballmac UI: Gallery 1. https://ui.ballmac.com/blocks/gallery-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowUpRight, ChevronLeft, ChevronRight, Expand, ImageOff, X } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { CoverArt, type CoverArtVariant } from \"@/components/ballmac/cover-art\"\nimport { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from \"@/components/ballmac/dialog\"\nimport { MasonryGrid, MasonryItem } from \"@/components/ballmac/masonry-grid\"\nimport { Media, type MediaSource } from \"@/components/ballmac/media\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { useReturnFocus } from \"@/lib/ballmac/return-focus\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { useDirection } from \"@/lib/ballmac/direction\"\nimport { cn } from \"@/lib/utils\"\n\ntype Gallery1Ratio = \"square\" | \"landscape\" | \"portrait\" | \"tall\"\n\ntype Gallery1Item = {\n  id: string\n  title: string\n  /** A sentence under the picture in the lightbox. */\n  caption?: string\n  /** A short line next to the title (\"Lisbon, 2026\"). */\n  meta?: string\n  category: string\n  /** The picture. Leave out and art is drawn from the id, so the gallery looks finished before you have photos. */\n  src?: MediaSource\n  /** Describes the picture. Required when `src` is set; use \"\" for decoration. */\n  alt?: string\n  /** The shape of the tile. */\n  ratio?: Gallery1Ratio\n  /** Where the full-size original lives. Adds a link in the lightbox. */\n  href?: string\n}\n\ntype Gallery1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    items?: Gallery1Item[]\n    title?: string\n    description?: string\n    /** The category shown at first, or \"all\". */\n    defaultCategory?: string\n    /** Called when a picture is opened in the lightbox. */\n    onOpen?: (item: Gallery1Item) => void\n    /** The id of a picture that is open in the lightbox at first. */\n    defaultOpenId?: string\n  }\n\nconst ratios: Record<Gallery1Ratio, string> = {\n  square: \"aspect-square\",\n  landscape: \"aspect-[3/2]\",\n  portrait: \"aspect-[4/5]\",\n  tall: \"aspect-[3/4]\",\n}\nconst variants: CoverArtVariant[] = [\"mesh\", \"rings\", \"bars\", \"dots\", \"waves\"]\n\nconst sample: Gallery1Item[] = [\n  { id: \"g1\", title: \"The Lisbon studio\", caption: \"Our first office, above a bakery. The morning light on the long table is why we stayed.\", meta: \"Lisbon, 2024\", category: \"Offices\", ratio: \"landscape\" },\n  { id: \"g2\", title: \"Launch day\", caption: \"Everyone in one room when version 2 went live, with pizza that arrived late.\", meta: \"March 2026\", category: \"Events\", ratio: \"portrait\" },\n  { id: \"g3\", title: \"The first prototype\", caption: \"A paper model of the sidebar, taped to a window and argued over for a week.\", meta: \"2023\", category: \"Product\", ratio: \"square\" },\n  { id: \"g4\", title: \"Design offsite\", caption: \"Two days in the mountains, no laptops until the second afternoon.\", meta: \"Serra da Estrela\", category: \"Team\", ratio: \"tall\" },\n  { id: \"g5\", title: \"Standing desks\", caption: \"The new floor, with room for the support team to sit next to engineering.\", meta: \"Porto, 2026\", category: \"Offices\", ratio: \"square\" },\n  { id: \"g6\", title: \"Conference talk\", caption: \"Mara on stage explaining why empty states deserve a designer.\", meta: \"Berlin, 2025\", category: \"Events\", ratio: \"landscape\" },\n  { id: \"g7\", title: \"Sketching the dashboard\", caption: \"Four weeks of whiteboards before a single pixel was drawn.\", meta: \"2025\", category: \"Product\", ratio: \"portrait\" },\n  { id: \"g8\", title: \"Friday lunch\", caption: \"The one rule: nobody talks about work for the first twenty minutes.\", meta: \"Every week\", category: \"Team\", ratio: \"landscape\" },\n  { id: \"g9\", title: \"The wall of notes\", caption: \"Every customer letter we have kept, pinned where we pass it daily.\", meta: \"Lisbon\", category: \"Offices\", ratio: \"tall\" },\n  { id: \"g10\", title: \"Community meetup\", caption: \"Sixty people, a projector and a very patient host.\", meta: \"Lisbon, 2025\", category: \"Events\", ratio: \"square\" },\n  { id: \"g11\", title: \"Color study\", caption: \"Forty swatches narrowed to five over one long lunch.\", meta: \"2024\", category: \"Product\", ratio: \"landscape\" },\n  { id: \"g12\", title: \"The whole team\", caption: \"Twenty-three people on one staircase, and one blurry photographer.\", meta: \"Autumn 2026\", category: \"Team\", ratio: \"portrait\" },\n]\n\nfunction GallerySkeleton() {\n  const heights = [\"h-56\", \"h-72\", \"h-48\", \"h-64\", \"h-80\", \"h-52\"]\n  return (\n    <div className=\"grid gap-4 sm:grid-cols-2 lg:grid-cols-3\" aria-hidden=\"true\">\n      {heights.map((h, i) => (\n        <div key={i} className=\"grid gap-2\">\n          <Skeleton className={cn(\"rounded-2xl\", h)} />\n          <Skeleton className=\"h-3.5 w-1/2\" />\n        </div>\n      ))}\n    </div>\n  )\n}\n\nfunction Picture({ item, index, className, fit = \"cover\" }: { item: Gallery1Item; index: number; className?: string; fit?: \"cover\" | \"contain\" }) {\n  return (\n    <Media\n      media={item.src}\n      alt={item.alt ?? \"\"}\n      fit={fit}\n      fill\n      className={cn(\"absolute inset-0\", className)}\n      fallback={<CoverArt seed={item.id} variant={variants[index % variants.length]} grid className=\"absolute inset-0\" />}\n    />\n  )\n}\n\n/**\n * A photo gallery: filter chips with counts, a gap-free masonry of tiles that fade in, and a lightbox with the caption, a counter, a\n * thumbnail strip, arrow-key and swipe navigation (mirrored in right-to-left), and a link to the original.\n */\nfunction Gallery1({\n  items = sample,\n  title = \"Life at Acme\",\n  description = \"Offices, launches and the people behind the work.\",\n  defaultCategory = \"all\",\n  onOpen,\n  defaultOpenId,\n  state = \"ready\",\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: Gallery1Props) {\n  const id = React.useId()\n  const dir = useDirection()\n  const [category, setCategory] = React.useState(defaultCategory)\n  const [openIndex, setOpenIndex] = React.useState<number | null>(() => {\n    const at = defaultOpenId ? items.filter((i) => defaultCategory === \"all\" || i.category === defaultCategory).findIndex((i) => i.id === defaultOpenId) : -1\n    return at >= 0 ? at : null\n  })\n  const swipe = React.useRef<number | null>(null)\n  const ready = state === \"ready\" || state === \"empty\"\n\n  const categories = [...new Set(items.map((i) => i.category))]\n  const list = state === \"empty\" ? [] : items\n  const filtered = list.filter((i) => category === \"all\" || i.category === category)\n  const current = openIndex !== null ? filtered[openIndex] : undefined\n  const returnFocus = useReturnFocus(current !== undefined)\n  const indexInAll = (item: Gallery1Item) => items.findIndex((x) => x.id === item.id)\n\n  const go = (delta: number) => setOpenIndex((i) => (i === null || !filtered.length ? i : (i + delta + filtered.length) % filtered.length))\n  const open = (index: number) => {\n    setOpenIndex(index)\n    const item = filtered[index]\n    if (item) onOpen?.(item)\n  }\n\n  function onKeyDown(e: React.KeyboardEvent) {\n    const forward = dir === \"rtl\" ? \"ArrowLeft\" : \"ArrowRight\"\n    const back = dir === \"rtl\" ? \"ArrowRight\" : \"ArrowLeft\"\n    if (e.key === forward) {\n      e.preventDefault()\n      go(1)\n    } else if (e.key === back) {\n      e.preventDefault()\n      go(-1)\n    } else if (e.key === \"Home\") {\n      e.preventDefault()\n      setOpenIndex(0)\n    } else if (e.key === \"End\") {\n      e.preventDefault()\n      setOpenIndex(filtered.length - 1)\n    }\n  }\n\n  const content = (\n    <>\n      {categories.length > 1 ? (\n        <fieldset className=\"mb-8\" disabled={!ready}>\n          <legend className=\"sr-only\">Category</legend>\n          <div className=\"flex flex-wrap gap-2\">\n            {[{ key: \"all\", label: \"All\", n: list.length }, ...categories.map((c) => ({ key: c, label: c, n: list.filter((i) => i.category === c).length }))].map((c) => (\n              <label\n                key={c.key}\n                className=\"has-[:focus-visible]:ring-ring/50 hover:bg-accent/60 has-[:checked]:bg-foreground has-[:checked]:text-background has-[:checked]:border-foreground flex cursor-pointer items-center gap-2 rounded-full border px-4 py-1.5 text-sm transition-colors duration-150 has-[:focus-visible]:ring-[3px]\"\n              >\n                <input type=\"radio\" name={`${id}-cat`} value={c.key} checked={category === c.key} onChange={() => setCategory(c.key)} className=\"sr-only\" />\n                {c.label}{\" \"}\n                <span className=\"text-xs tabular-nums opacity-70\">{c.n}</span>\n              </label>\n            ))}\n          </div>\n        </fieldset>\n      ) : null}\n\n      {filtered.length ? (\n        <MasonryGrid key={category} columns={{ base: 1, sm: 2, lg: 3 }} gap=\"lg\" reveal>\n          {filtered.map((item, i) => (\n            <MasonryItem key={item.id}>\n              <button\n                type=\"button\"\n                aria-haspopup=\"dialog\"\n                onClick={() => open(i)}\n                className=\"group/tile focus-visible:ring-ring/50 block w-full rounded-2xl text-start outline-none focus-visible:ring-[3px]\"\n              >\n                <span className={cn(\"bg-muted relative block w-full overflow-hidden rounded-2xl border shadow-xs\", ratios[item.ratio ?? \"landscape\"])}>\n                  <Picture item={item} index={indexInAll(item)} className=\"transition-transform duration-700 ease-out group-hover/tile:scale-[1.05] motion-reduce:transition-none motion-reduce:group-hover/tile:scale-100\" />\n                  <span aria-hidden=\"true\" className=\"absolute inset-0 bg-gradient-to-t from-black/30 to-transparent opacity-0 transition-opacity duration-300 group-hover/tile:opacity-100 group-focus-visible/tile:opacity-100 motion-reduce:transition-none\" />\n                  <span aria-hidden=\"true\" className=\"bg-background/90 text-foreground absolute end-3 top-3 flex size-8 translate-y-1 items-center justify-center rounded-full opacity-0 shadow-sm backdrop-blur transition-all duration-300 group-hover/tile:translate-y-0 group-hover/tile:opacity-100 group-focus-visible/tile:translate-y-0 group-focus-visible/tile:opacity-100 motion-reduce:transition-none\">\n                    <Expand className=\"size-4\" />\n                  </span>\n                </span>\n                <span className=\"mt-3 flex items-baseline justify-between gap-3 px-1\">\n                  <span className=\"min-w-0 text-sm font-medium tracking-tight text-pretty\">{item.title}</span>\n                  <span className=\"text-muted-foreground shrink-0 text-xs\">{item.category}</span>\n                </span>\n                {item.meta ? <span className=\"text-muted-foreground mt-0.5 block px-1 text-xs\">{item.meta}</span> : null}\n              </button>\n            </MasonryItem>\n          ))}\n        </MasonryGrid>\n      ) : (\n        <div className=\"bg-card rounded-2xl border px-6 py-16 text-center\">\n          <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-12 items-center justify-center rounded-full\">\n            <ImageOff className=\"size-6\" />\n          </span>\n          <h2 className=\"mt-5 text-lg font-semibold tracking-tight\">{emptyState?.title ?? (list.length ? \"Nothing in this category yet\" : \"No pictures yet\")}</h2>\n          <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-sm text-sm text-pretty\">{emptyState?.description ?? (list.length ? \"Try another category to see more.\" : \"Pictures will appear here as they are added.\")}</p>\n          {list.length ? (\n            <Button variant=\"outline\" size=\"sm\" className=\"mt-5\" onClick={() => setCategory(\"all\")}>\n              Show everything\n            </Button>\n          ) : (\n            emptyState?.action\n          )}\n        </div>\n      )}\n    </>\n  )\n\n  return (\n    <section data-slot=\"gallery-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-6xl px-4 py-12 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-10 max-w-2xl\">\n        <h1 id={`${id}-title`} className=\"text-4xl font-semibold tracking-[-0.04em] text-balance sm:text-5xl\">\n          {title}\n        </h1>\n        <p className=\"text-muted-foreground mt-3 text-base text-pretty\">{description}</p>\n      </header>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading pictures\" loading={<GallerySkeleton />} error={{ title: errorState?.title ?? \"The pictures could not be loaded\", description: errorState?.description ?? \"Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n\n      <Dialog open={current !== undefined} onOpenChange={(o) => !o && setOpenIndex(null)}>\n        {current ? (\n          <DialogContent showCloseButton={false} onKeyDown={onKeyDown} {...returnFocus} className=\"max-w-5xl gap-0 overflow-hidden p-0 sm:p-0\">\n            <DialogTitle className=\"sr-only\">{current.title}</DialogTitle>\n            <DialogDescription className=\"sr-only\">\n              Picture {(openIndex ?? 0) + 1} of {filtered.length}. Use the left and right arrow keys to move between pictures.\n            </DialogDescription>\n            <div\n              className=\"bg-muted relative h-[min(60dvh,34rem)] touch-pan-y select-none\"\n              onPointerDown={(e) => (swipe.current = e.clientX)}\n              onPointerUp={(e) => {\n                if (swipe.current === null) return\n                const dx = e.clientX - swipe.current\n                swipe.current = null\n                if (Math.abs(dx) > 60) go((dx < 0 ? 1 : -1) * (dir === \"rtl\" ? -1 : 1))\n              }}\n            >\n              <Picture item={current} index={indexInAll(current)} fit=\"contain\" />\n              <DialogClose aria-label=\"Close\" className=\"bg-background/85 hover:bg-background focus-visible:ring-ring/50 absolute end-3 top-3 z-10 flex size-9 items-center justify-center rounded-full border shadow-sm backdrop-blur outline-none focus-visible:ring-[3px]\">\n                <X aria-hidden=\"true\" className=\"size-4\" />\n              </DialogClose>\n              {filtered.length > 1 ? (\n                <>\n                  <button type=\"button\" aria-label=\"Previous picture\" onClick={() => go(-1)} className=\"bg-background/85 hover:bg-background focus-visible:ring-ring/50 absolute start-3 top-1/2 flex size-10 -translate-y-1/2 items-center justify-center rounded-full border shadow-sm backdrop-blur outline-none focus-visible:ring-[3px]\">\n                    <ChevronLeft aria-hidden=\"true\" className=\"size-5 rtl:rotate-180\" />\n                  </button>\n                  <button type=\"button\" aria-label=\"Next picture\" onClick={() => go(1)} className=\"bg-background/85 hover:bg-background focus-visible:ring-ring/50 absolute end-3 top-1/2 flex size-10 -translate-y-1/2 items-center justify-center rounded-full border shadow-sm backdrop-blur outline-none focus-visible:ring-[3px]\">\n                    <ChevronRight aria-hidden=\"true\" className=\"size-5 rtl:rotate-180\" />\n                  </button>\n                </>\n              ) : null}\n            </div>\n            <div className=\"flex flex-wrap items-start gap-x-6 gap-y-3 border-t p-5\">\n              <div className=\"min-w-0 flex-1 basis-72\">\n                <h2 className=\"text-lg font-semibold tracking-tight text-pretty\">{current.title}</h2>\n                {current.caption ? <p className=\"text-muted-foreground mt-1 text-sm leading-relaxed text-pretty\">{current.caption}</p> : null}\n                <p className=\"text-muted-foreground mt-2 text-xs\">\n                  {current.category}\n                  {current.meta ? ` · ${current.meta}` : \"\"}\n                </p>\n              </div>\n              <div className=\"flex items-center gap-3\">\n                <p className=\"text-muted-foreground text-sm tabular-nums\" aria-hidden=\"true\">\n                  {(openIndex ?? 0) + 1} / {filtered.length}\n                </p>\n                {current.href ? (\n                  <a href={current.href} className=\"focus-visible:ring-ring/50 hover:bg-accent inline-flex h-8 items-center gap-1.5 rounded-md border px-3 text-[13px] font-medium outline-none focus-visible:ring-[3px]\">\n                    Open original\n                    <ArrowUpRight aria-hidden=\"true\" className=\"size-3.5 rtl:-scale-x-100\" />\n                  </a>\n                ) : null}\n              </div>\n            </div>\n            {filtered.length > 1 ? (\n              <div className=\"flex gap-2 overflow-x-auto border-t p-3\" role=\"group\" aria-label=\"All pictures\">\n                {filtered.map((item, i) => (\n                  <button\n                    key={item.id}\n                    type=\"button\"\n                    aria-label={`Picture ${i + 1}: ${item.title}`}\n                    aria-current={i === openIndex ? \"true\" : undefined}\n                    onClick={() => setOpenIndex(i)}\n                    className={cn(\"bg-muted focus-visible:ring-ring/50 relative size-14 shrink-0 overflow-hidden rounded-lg border outline-none transition-[opacity,box-shadow] duration-150 focus-visible:ring-[3px]\", i === openIndex ? \"ring-foreground ring-2 ring-offset-2 ring-offset-card\" : \"opacity-60 hover:opacity-100\")}\n                  >\n                    <Picture item={item} index={indexInAll(item)} />\n                  </button>\n                ))}\n              </div>\n            ) : null}\n          </DialogContent>\n        ) : null}\n      </Dialog>\n    </section>\n  )\n}\n\nexport { Gallery1, type Gallery1Props, type Gallery1Item, type Gallery1Ratio }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/gallery-1/gallery-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "gallery",
      "photos",
      "images",
      "masonry",
      "lightbox",
      "portfolio",
      "filter",
      "media",
      "grid",
      "carousel"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "A gallery page. <Gallery1 items={[{ id, title, caption, meta, category, src, alt, ratio: 'square' | 'landscape' | 'portrait' | 'tall', href }]} title description defaultCategory onOpen />. src takes a URL, an image with alt text or any element (a next/image); without it the tile shows art drawn from the id. Categories come from the items.",
      "whenToUse": [
        "A photo, event, studio or portfolio gallery",
        "Showing work or places with captions and the option to see each larger"
      ],
      "whenNotToUse": [
        "A product image carousel on a store page (use carousel)",
        "Logos (use logo-cloud-1)",
        "One hero picture (use media with a frame)"
      ],
      "composesWith": [
        "header-2",
        "footer-2",
        "cta-1",
        "media"
      ],
      "a11y": [
        {
          "keys": "Tab, then Enter on a picture",
          "action": "Opens the lightbox (a dialog with a hidden title and \"Picture 3 of 12\"); Escape closes it and focus returns to the tile"
        },
        {
          "keys": "Left / Right, Home / End in the lightbox",
          "action": "Move between pictures; the arrows swap in right-to-left"
        },
        {
          "keys": "Filters",
          "action": "Category chips are native radio buttons (arrow keys), with counts in their labels"
        }
      ],
      "customization": [
        "ratio sets the tile shape, which gives the masonry its rhythm; the lightbox shows the whole picture (contain)",
        "defaultOpenId opens a picture at first; defaultCategory starts on a category",
        "Set alt on every real picture; the caption and title are text next to it"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "gallery-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton tiles of different heights under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No pictures yet",
        "example": "gallery-1-empty",
        "trigger": "state=\"empty\" (or items={[]})",
        "note": "Says pictures will appear; a category with nothing in it offers Show everything. emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "gallery-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert with Try again (onRetry)."
      }
    ],
    "examples": [
      "gallery-1-demo",
      "gallery-1-lightbox",
      "gallery-1-team",
      "gallery-1-loading",
      "gallery-1-empty",
      "gallery-1-error"
    ],
    "url": "https://ui.ballmac.com/components/gallery-1"
  },
  "categories": [
    "blocks",
    "gallery"
  ],
  "type": "registry:block"
}