# Product Screenshot Showcase

> Product screenshot gallery section with a large browser-framed image, version badge and caption, plus a five-thumbnail strip that swaps the featured screen.

A shadcn/ui block from the [@7ovr](https://7ovr.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/7ovr/gallery-2).

- Page, with a live preview: https://shadcn.exchange/r/7ovr/gallery-2
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/7ovr/gallery-2-light-58cec633e6804d54.png
- Registry item: https://7ovr.com/r/gallery-2.json
- Categories: gallery
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://7ovr.com/r/gallery-2.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@base-ui/react`
- Registry items: `badge`, `button`, `dialog`

## Notes from the registry

Requires a `base-*` style in components.json (the `shadcn init` default). Legacy `new-york`/`radix-*` styles install Radix primitives, which reject the `render` prop this block uses.

## Files

### `registry/blocks/gallery/2/gallery-block.tsx`

```tsx
"use client"

import { useState } from "react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import { IconPlaceholder } from "@/components/icons/icon-placeholder"

const screens = [
  {
    id: 1,
    label: "Acme Dashboard: Overview",
    url: "app.acme.io/dashboard",
    version: "v2.4",
    description:
      "A unified command center for monitoring pipeline health, active deployments, and real-time system metrics across all environments.",
    src: "https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=1200&q=80",
    alt: "Acme dashboard overview showing pipeline metrics",
  },
  {
    id: 2,
    label: "Analytics",
    url: "app.acme.io/analytics",
    description:
      "Deep-dive into usage trends, funnel conversion, and retention cohorts.",
    src: "https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=800&q=75",
    alt: "Analytics charts and metrics",
  },
  {
    id: 3,
    label: "Deployments",
    url: "app.acme.io/deployments",
    description:
      "Track every release: status, rollback controls, and canary traffic splits.",
    src: "https://images.unsplash.com/photo-1667372393119-3d4c48d07fc9?w=800&q=75",
    alt: "Deployment pipeline and release status",
  },
  {
    id: 4,
    label: "Team Settings",
    url: "app.acme.io/team",
    description:
      "Manage members, roles, SSO configuration, and access policies.",
    src: "https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=800&q=75",
    alt: "Team settings and member management",
  },
  {
    id: 5,
    label: "Audit Logs",
    url: "app.acme.io/audit",
    description:
      "Immutable event history with filtering, export, and alert rules.",
    src: "https://images.unsplash.com/photo-1555949963-ff9fe0c870eb?w=800&q=75",
    alt: "Audit log event timeline",
  },
  {
    id: 6,
    label: "API Keys",
    url: "app.acme.io/api-keys",
    description:
      "Generate, rotate, and scope API credentials with fine-grained permissions.",
    src: "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=800&q=75",
    alt: "API key management interface",
  },
]

export default function GalleryBlock() {
  const [activeId, setActiveId] = useState(screens[0].id)
  const active = screens.find((screen) => screen.id === activeId) ?? screens[0]
  const thumbnails = screens.slice(1)

  return (
    <section className="flex w-full items-center justify-center bg-background px-6 py-20 text-foreground">
      <div className="mx-auto flex w-full max-w-5xl flex-col gap-8">
        <div className="flex flex-col gap-1.5">
          <span className="text-[10px] font-semibold tracking-[0.18em] text-muted-foreground uppercase">
            Product Screenshots
          </span>
          <h2 className="font-heading text-2xl font-bold tracking-tight sm:text-3xl">
            See Acme in action
          </h2>
        </div>

        <div className="rounded-xl border border-border bg-card shadow-sm">
          <div className="flex items-center gap-2.5 rounded-t-[inherit] border-b border-border bg-muted px-4 py-2.5">
            <div className="flex items-center gap-1.5">
              <span className="size-2.5 border border-border bg-background" />
              <span className="size-2.5 border border-border bg-background" />
              <span className="size-2.5 border border-border bg-background" />
            </div>
            <div className="mx-2 flex flex-1 items-center gap-2 rounded-md border border-border bg-background px-3 py-1">
              <span className="size-1.5 bg-muted-foreground/30" />
              <span className="flex-1 truncate text-xs text-muted-foreground">
                {active.url}
              </span>
            </div>
            <IconPlaceholder
              lucide="ZoomIn"
              tabler="IconZoomIn"
              hugeicons="SearchAddIcon"
              phosphor="MagnifyingGlassPlus"
              remixicon="RiZoomInLine"
              className="size-4 shrink-0 text-muted-foreground/40"
              aria-hidden="true"
            />
          </div>

          <Dialog>
            <DialogTrigger
              render={
                <button
                  type="button"
                  aria-label={`Enlarge ${active.label}`}
                  className="group relative block aspect-video w-full cursor-zoom-in overflow-hidden rounded-b-[inherit] bg-muted text-left focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
                />
              }
            >
              <img
                key={active.id}
                src={active.src}
                alt={active.alt}
                className="size-full object-cover transition-transform duration-300 group-hover:scale-[1.02]"
              />
              <span className="pointer-events-none absolute right-3 bottom-3 flex items-center gap-1.5 rounded-4xl bg-foreground/70 px-2 py-1 text-[10px] font-medium tracking-wide text-background opacity-0 backdrop-blur-sm transition-opacity duration-200 group-hover:opacity-100">
                <IconPlaceholder
                  lucide="ZoomIn"
                  tabler="IconZoomIn"
                  hugeicons="SearchAddIcon"
                  phosphor="MagnifyingGlassPlus"
                  remixicon="RiZoomInLine"
                  className="size-3.5"
                  aria-hidden="true"
                />
                Enlarge
              </span>
            </DialogTrigger>

            <DialogContent showCloseButton className="gap-0 p-0 sm:max-w-3xl">
              <DialogTitle className="sr-only">{active.label}</DialogTitle>
              <DialogDescription className="sr-only">
                {active.description}
              </DialogDescription>
              <div className="aspect-video w-full overflow-hidden rounded-t-[inherit] bg-muted">
                <img
                  src={active.src}
                  alt={active.alt}
                  className="size-full object-contain"
                />
              </div>
              <div className="flex items-center justify-between gap-3 border-t border-border px-4 py-3">
                <span className="text-sm font-semibold tracking-tight text-foreground">
                  {active.label}
                </span>
                <span className="truncate text-xs text-muted-foreground">
                  {active.url}
                </span>
              </div>
            </DialogContent>
          </Dialog>
        </div>

        <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
          <div className="flex flex-col gap-1.5">
            <div className="flex items-center gap-2">
              <span className="text-base font-semibold tracking-tight">
                {active.label}
              </span>
              {active.version && (
                <Badge
                  variant="secondary"
                  className="text-[10px] font-medium tracking-wide"
                >
                  {active.version}
                </Badge>
              )}
            </div>
            <p className="max-w-lg text-sm leading-relaxed text-muted-foreground">
              {active.description}
            </p>
          </div>
          <Button
            render={<a href="#" />}
            nativeButton={false}
            variant="outline"
            className="shrink-0 gap-2 text-xs font-medium"
          >
            <IconPlaceholder
              lucide="ExternalLink"
              tabler="IconExternalLink"
              hugeicons="ExternalLinkIcon"
              phosphor="ArrowSquareOut"
              remixicon="RiExternalLinkLine"
              className="size-3.5"
              aria-hidden="true"
            />
            View Live Demo
          </Button>
        </div>

        <div className="border-t border-border" />

        <div className="flex flex-col gap-3">
          <span className="text-[10px] font-semibold tracking-[0.18em] text-muted-foreground uppercase">
            More Screens
          </span>
          <div className="grid grid-cols-3 gap-3 sm:grid-cols-5">
            {thumbnails.map((thumb) => {
              const isActive = thumb.id === activeId
              return (
                <button
                  key={thumb.id}
                  type="button"
                  onClick={() => setActiveId(thumb.id)}
                  className="group flex cursor-pointer flex-col gap-1.5 text-left focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
                >
                  <div
                    className={[
                      "relative aspect-video w-full overflow-hidden rounded-md border transition-all duration-200",
                      isActive
                        ? "border-foreground/60 ring-1 ring-foreground/20"
                        : "border-border group-hover:border-foreground/30",
                    ].join(" ")}
                  >
                    <img
                      src={thumb.src}
                      alt={thumb.alt}
                      className="size-full object-cover transition-transform duration-300 group-hover:scale-[1.04]"
                    />
                    {!isActive && (
                      <div className="absolute inset-0 bg-foreground/0 transition-colors duration-200 group-hover:bg-foreground/5" />
                    )}
                  </div>
                  <span
                    className={[
                      "truncate text-xs font-medium transition-colors",
                      isActive
                        ? "text-foreground"
                        : "text-muted-foreground group-hover:text-foreground/70",
                    ].join(" ")}
                  >
                    {thumb.label}
                  </span>
                </button>
              )
            })}
          </div>
        </div>
      </div>
    </section>
  )
}
```
