# Image thumbnails in a snapping strip

> Image thumbnails in a snapping strip

A shadcn/ui block from the [@reui](https://reui.io) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/reui/c-attachment-3).

- Page, with a live preview: https://shadcn.exchange/r/reui/c-attachment-3
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/reui/c-attachment-3-light-ea957b2a34b12fea.png
- Registry item: https://reui.io/r/new-york-v4/c-attachment-3.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://reui.io/r/new-york-v4/c-attachment-3.json
```

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

## Dependencies

- Registry items: `attachment`

## Files

### `c-attachment-3.tsx`

```tsx
"use client"

import { useState } from "react"

import {
  Attachment,
  AttachmentContent,
  AttachmentGroup,
  AttachmentMedia,
  AttachmentTitle,
  AttachmentTrigger,
} from "@/components/ui/attachment"

type Shot = {
  id: string
  name: string
  dimensions: string
  src: string
  alt: string
}

// Thumbnails are requested at the card width and twice the density, so the
// strip never pulls a full size screenshot down to paint a 120px square.
const SHOTS: Shot[] = [
  {
    id: "gradient",
    name: "brand-gradient.png",
    dimensions: "2400 x 1600",
    src: "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=120&h=120&dpr=2&q=80",
    alt: "Purple and orange gradient artwork",
  },
  {
    id: "mesh",
    name: "mesh-backdrop.png",
    dimensions: "1800 x 1200",
    src: "https://images.unsplash.com/photo-1620121692029-d088224ddc74?w=120&h=120&dpr=2&q=80",
    alt: "Blue and violet mesh render",
  },
  {
    id: "spectrum",
    name: "spectrum-wash.jpg",
    dimensions: "3000 x 2000",
    src: "https://images.unsplash.com/photo-1579546929518-9e396f3cc809?w=120&h=120&dpr=2&q=80",
    alt: "Soft rainbow spectrum wash",
  },
  {
    id: "waves",
    name: "wave-pattern.png",
    dimensions: "1200 x 1200",
    src: "https://images.unsplash.com/photo-1620641788421-7a1c342ea42e?w=120&h=120&dpr=2&q=80",
    alt: "Magenta and violet wave pattern",
  },
  {
    id: "ink",
    name: "ink-render.png",
    dimensions: "2000 x 1400",
    src: "https://images.unsplash.com/photo-1541701494587-cb58502866ab?w=120&h=120&dpr=2&q=80",
    alt: "Colourful ink dispersing in water",
  },
]

export function Pattern() {
  const [selectedId, setSelectedId] = useState<string | null>(null)

  // Boots with nothing chosen so the first click is the thing on show, and
  // the selected shot is looked up during render rather than duplicated.
  const selected = SHOTS.find((shot) => shot.id === selectedId) ?? null

  return (
    <div className="mx-auto flex w-full max-w-sm flex-col gap-2">
      <AttachmentGroup role="group" aria-label="Screenshots in this thread">
        {SHOTS.map((shot) => (
          /*
           * A vertical card takes its own fixed width, so a w-full here would
           * collapse the strip into one column and kill the snap scrolling.
           */
          <Attachment key={shot.id} orientation="vertical">
            <AttachmentMedia variant="image">
              {/*
               * No classes on the image: variant="image" already crops every
               * child img square and covers the frame. The alt describes the
               * picture, and the title below it names the file.
               */}
              <img
                src={shot.src}
                alt={shot.alt}
                width={120}
                height={120}
                loading="lazy"
                decoding="async"
              />
            </AttachmentMedia>
            <AttachmentContent>
              <AttachmentTitle>{shot.name}</AttachmentTitle>
            </AttachmentContent>
            {/*
             * The trigger reports aria-pressed because it selects rather than
             * navigates, and it renders below the actions slot in the stacking
             * order, so a card can carry a full card select and its own buttons.
             */}
            <AttachmentTrigger
              type="button"
              aria-label={`Preview ${shot.name}`}
              aria-pressed={selectedId === shot.id}
              onClick={() => setSelectedId(shot.id)}
            />
          </Attachment>
        ))}
      </AttachmentGroup>
      <p className="text-muted-foreground text-xs" aria-live="polite">
        {selected ? (
          <>
            {selected.name}
            <span
              aria-hidden="true"
              className="bg-muted-foreground/40 mx-1.5 inline-block size-1 rounded-full align-middle"
            />
            {selected.dimensions}
          </>
        ) : (
          "Select a shot to see its details."
        )}
      </p>
    </div>
  )
}
```
