# Image gallery

A message with several images shows them as a grid of thumbnails, and a click opens them in a gallery. This recipe replaces both.

>
> **Note:** **What you'll build**
>
> - A grid of up to four tiles with a "+N" count
> - A lightbox with Previous and Next
> - A thumbnail strip for jumping between items
> - Keyboard and swipe navigation
> - Captions, and videos in the same gallery
>

---

**Preview**

![A message with four trail photos in a two-by-two grid](https://getstream.io/docs-assets/images/23011560e7fb.png)

---

**Approach:** Replace one part. [Choosing where to start](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/overview/#choosing-where-to-start) explains the approaches.

**Starts from:** [the base app](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/overview/#the-base-app). The page shows only what the recipe adds.

## Best practices

- Replace the grid through `Attachment`'s `ModalGallery` prop and register the result as the `Attachment` key. Register the grid as the `ModalGallery` key too: a message with one image renders `Image`, which reads the key, not the prop.
- Keep the SDK's `Gallery` for the lightbox. It holds the current index and the navigation, so your UI only reads `useGalleryContext()`.
- Pass your lightbox to `Gallery` as its `GalleryUI` prop. The default `ModalGallery` always renders the default `GalleryUI`, so the `GalleryUI` key doesn't reach it.
- Give the lightbox the same ways to move as the SDK's gallery: Previous and Next buttons, the arrow keys and a swipe.
- Leave the arrow keys to a video while it has the focus. Its own player seeks with them.

## Register the grid

`Attachment` takes a prop for each part it renders. Pass the grid to it, and register the result:

```tsx title="src/WithImageGallery.tsx"
import type { PropsWithChildren } from "react";
import type { AttachmentProps } from "stream-chat-react";
import { Attachment, WithComponents } from "stream-chat-react";
import { ImageGrid } from "./ImageGrid";

// A message with several images or videos renders `ModalGallery` from this prop.
const AttachmentWithImageGrid = (props: AttachmentProps) => (
  <Attachment {...props} ModalGallery={ImageGrid} />
);

// Declared once, so WithComponents gets the same object on every render. A message with one image
// renders `Image`, which takes `ModalGallery` from here, not from the prop.
const overrides = {
  Attachment: AttachmentWithImageGrid,
  ModalGallery: ImageGrid,
};

export const WithImageGallery = ({ children }: PropsWithChildren) => (
  <WithComponents overrides={overrides}>{children}</WithComponents>
);
```

Render your `Channel` inside `WithImageGallery`. The base app passes it as the recipe's wrapper, around the whole chat view.

## The image grid

`ModalGallery` receives the message's images and videos as gallery `items`. Each image carries its `imageUrl`. Each video carries its `videoUrl`, and a `videoThumbnailUrl` when it has a thumbnail. [Videos in a gallery](https://getstream.io/chat/docs/sdk/react/component-reference/message/attachments/#videos-in-a-gallery) shows how the default components render a video without one. `ImageGrid` shows up to four of them as tiles, with "+N" on the fourth when there are more. A tile opens the SDK's `Gallery` in a `GlobalModal`, at that item, and renders it with `Lightbox`:

```tsx label="React" title="src/ImageGrid.tsx"
import { useContext, useMemo, useState } from "react";
import { convertTimestampToDate } from "stream-chat";
import type { ModalGalleryProps } from "stream-chat-react";
import {
  Gallery,
  GlobalModal,
  MessageContext,
  useComponentContextIcons,
} from "stream-chat-react";
import { Lightbox } from "./Lightbox";
import "./ImageGrid.css";

const MAX_TILES = 4;

/**
 * A message's images and videos as a grid of up to four tiles; a click opens the lightbox at that
 * item. A video tile shows the video's thumbnail with a play badge.
 */
export const ImageGrid = ({ items }: ModalGalleryProps) => {
  const { IconPlayFill } = useComponentContextIcons();
  // The SDK also renders the `ModalGallery` component outside a message, where there is no
  // `MessageContext`, so the message may be missing.
  const { message } = useContext(MessageContext) ?? {};
  const [openAt, setOpenAt] = useState<number>();

  // The lightbox renders in a modal, outside MessageContext, so each item carries its message,
  // its sender and when it was shared.
  const galleryItems = useMemo(
    () =>
      items.map((item) => ({
        ...item,
        createdAt:
          item.createdAt ?? convertTimestampToDate(message?.created_at),
        message: item.message ?? message,
        user: item.user ?? message?.user ?? undefined,
      })),
    [items, message],
  );
  const tiles = galleryItems.slice(0, MAX_TILES);
  const hiddenCount = galleryItems.length - tiles.length;
  const close = () => setOpenAt(undefined);

  return (
    <>
      <div className="cookbook-image-grid" data-count={tiles.length}>
        {tiles.map((item, index) => (
          <button
            aria-label={`Open ${item.videoUrl ? "video" : "image"} ${index + 1} of ${galleryItems.length}`}
            className="cookbook-image-grid__tile"
            key={item.imageUrl ?? item.videoUrl ?? index}
            onClick={() => setOpenAt(index)}
            type="button"
          >
            <img
              alt=""
              className="cookbook-image-grid__image"
              src={item.imageUrl ?? item.videoThumbnailUrl}
            />
            {item.videoUrl && (
              <span className="cookbook-image-grid__play">
                <IconPlayFill />
              </span>
            )}
            {index === MAX_TILES - 1 && hiddenCount > 0 && (
              <span className="cookbook-image-grid__more">+{hiddenCount}</span>
            )}
          </button>
        ))}
      </div>
      <GlobalModal
        aria-label="Image gallery"
        className="cookbook-lightbox-modal"
        onClose={close}
        open={openAt !== undefined}
      >
        {openAt !== undefined && (
          <Gallery
            GalleryUI={Lightbox}
            initialIndex={openAt}
            items={galleryItems}
            onRequestClose={close}
          />
        )}
      </GlobalModal>
    </>
  );
};
```

```css label="CSS" title="src/ImageGrid.css"
@layer stream-overrides {
  .cookbook-image-grid {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: var(--str-chat__spacing-xxs);
    width: 280px;
    overflow: hidden;
    border-radius: var(--str-chat__radius-lg);
  }

  /* One image fills the attachment's width, as the SDK sizes it. */
  .cookbook-image-grid[data-count="1"] {
    grid-template-columns: 1fr;
    width: 100%;
  }

  /* With three items, the first one spans the top row. */
  .cookbook-image-grid[data-count="3"] .cookbook-image-grid__tile:first-child {
    grid-column: span 2;
  }

  .cookbook-image-grid__tile {
    position: relative;
    aspect-ratio: 4 / 3;
    padding: 0;
    border: 0;
    background: var(--str-chat__background-core-surface-subtle);
    cursor: zoom-in;
  }

  .cookbook-image-grid__tile:focus-visible {
    outline: 2px solid var(--str-chat__border-utility-focused);
    outline-offset: -2px;
  }

  .cookbook-image-grid__image {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  .cookbook-image-grid__play {
    position: absolute;
    inset: 50% auto auto 50%;
    display: grid;
    place-items: center;
    width: 40px;
    height: 40px;
    border-radius: var(--str-chat__radius-full);
    background: var(--str-chat__background-core-scrim);
    color: var(--str-chat__text-on-accent);
    transform: translate(-50%, -50%);
  }

  .cookbook-image-grid__more {
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    background: var(--str-chat__background-core-scrim);
    color: var(--str-chat__text-on-accent);
    font: var(--str-chat__font-heading-md);
  }
}
```

The lightbox renders in a modal, outside the message's `MessageContext`, so `ImageGrid` copies the message, its sender and its `message.created_at` onto each item, as the default `ModalGallery` does. Like `ModalGallery`, it reads `MessageContext` with `useContext()` rather than `useMessageContext()`, because the SDK also renders the `ModalGallery` key outside a message, where there is no message to copy.

## The lightbox

`Lightbox` is the `GalleryUI` the grid passes to `Gallery`. It reads the current item and the navigation from `useGalleryContext()`, and lays out the sender, the position, a Close button, the item with Previous and Next, and the thumbnail strip:

```tsx label="React" title="src/Lightbox.tsx"
import { useEffect, useRef } from "react";
import { useComponentContextIcons, useGalleryContext } from "stream-chat-react";
import { LightboxCaption } from "./LightboxCaption";
import { LightboxMedia } from "./LightboxMedia";
import { ThumbnailStrip } from "./ThumbnailStrip";
import { useLightboxNavigation } from "./useLightboxNavigation";
import "./Lightbox.css";

/** The open gallery: who shared it, the current item with Previous and Next, its caption, and a strip of every item. */
export const Lightbox = () => {
  const { IconChevronLeft, IconChevronRight, IconXmark } =
    useComponentContextIcons();
  const {
    currentIndex,
    currentItem,
    goToNext,
    goToPrevious,
    hasNext,
    hasPrevious,
    itemCount,
    onRequestClose,
  } = useGalleryContext();
  const swipeHandlers = useLightboxNavigation();
  const closeButton = useRef<HTMLButtonElement>(null);

  // The lightbox opens with the focus on Close, so Enter or Escape closes it at once.
  useEffect(() => closeButton.current?.focus(), []);

  return (
    <div className="cookbook-lightbox">
      <div className="cookbook-lightbox__header">
        <span className="cookbook-lightbox__sender">
          {currentItem.user?.name ?? currentItem.user?.id}
        </span>
        <span className="cookbook-lightbox__position">
          {currentIndex + 1} / {itemCount}
        </span>
        <button
          aria-label="Close"
          className="cookbook-lightbox__button"
          onClick={onRequestClose}
          ref={closeButton}
          type="button"
        >
          <IconXmark />
        </button>
      </div>
      <div className="cookbook-lightbox__stage">
        <button
          aria-label="Previous"
          className="cookbook-lightbox__button"
          disabled={!hasPrevious}
          onClick={goToPrevious}
          type="button"
        >
          <IconChevronLeft />
        </button>
        <figure className="cookbook-lightbox__figure" {...swipeHandlers}>
          <LightboxMedia item={currentItem} />
          <LightboxCaption item={currentItem} />
        </figure>
        <button
          aria-label="Next"
          className="cookbook-lightbox__button"
          disabled={!hasNext}
          onClick={goToNext}
          type="button"
        >
          <IconChevronRight />
        </button>
      </div>
      <ThumbnailStrip />
    </div>
  );
};
```

```css label="CSS" title="src/Lightbox.css"
@layer stream-overrides {
  .cookbook-lightbox {
    display: flex;
    flex-direction: column;
    gap: var(--str-chat__spacing-md);
    width: min(880px, 90vw);
    padding: var(--str-chat__spacing-lg);
    border-radius: var(--str-chat__radius-xl);
    background: var(--str-chat__background-core-elevation-1);
    color: var(--str-chat__text-primary);
  }

  .cookbook-lightbox__header {
    display: flex;
    align-items: center;
    gap: var(--str-chat__spacing-sm);
  }

  .cookbook-lightbox__sender {
    flex: 1;
    font: var(--str-chat__font-heading-xs);
  }

  .cookbook-lightbox__position {
    color: var(--str-chat__text-secondary);
    font: var(--str-chat__font-caption-default);
  }

  .cookbook-lightbox__stage {
    display: flex;
    align-items: center;
    gap: var(--str-chat__spacing-sm);
  }

  /* A horizontal swipe on the figure is the lightbox's; a vertical one still scrolls the page. */
  .cookbook-lightbox__figure {
    display: flex;
    flex: 1;
    flex-direction: column;
    align-items: center;
    gap: var(--str-chat__spacing-sm);
    min-width: 0;
    margin: 0;
    touch-action: pan-y;
    user-select: none;
  }

  .cookbook-lightbox__button {
    display: grid;
    flex-shrink: 0;
    place-items: center;
    width: 40px;
    height: 40px;
    padding: 0;
    border: 1px solid var(--str-chat__border-core-default);
    border-radius: var(--str-chat__radius-full);
    background: var(--str-chat__background-core-elevation-0);
    color: var(--str-chat__text-primary);
    cursor: pointer;
  }

  .cookbook-lightbox__button:hover:not(:disabled) {
    background: var(--str-chat__background-utility-hover);
  }

  .cookbook-lightbox__button:disabled {
    color: var(--str-chat__text-disabled);
    cursor: default;
  }
}
```

---

**The lightbox at the waterfall**

![The lightbox open at the waterfall, with its caption and a strip of the four photos](https://getstream.io/docs-assets/images/753792f82be4.png)

---

## A thumbnail strip

`ThumbnailStrip` shows every item of the gallery. The current one is marked with `aria-current`, and a click calls `goToIndex()`. When the strip is wider than the lightbox, it scrolls, and the current thumbnail scrolls into view:

```tsx label="React" title="src/ThumbnailStrip.tsx"
import { useEffect, useRef } from "react";
import { useComponentContextIcons, useGalleryContext } from "stream-chat-react";
import "./ThumbnailStrip.css";

/** A thumbnail of every item in the gallery; the current one is marked, and a click shows another. */
export const ThumbnailStrip = () => {
  const { IconPlayFill } = useComponentContextIcons();
  const { currentIndex, goToIndex, itemCount, items } = useGalleryContext();
  const currentThumbnail = useRef<HTMLButtonElement>(null);

  // Keeps the current thumbnail in view when the strip is wider than the lightbox.
  useEffect(() => {
    currentThumbnail.current?.scrollIntoView({
      block: "nearest",
      inline: "nearest",
    });
  }, [currentIndex]);

  if (itemCount < 2) return null;

  return (
    <div
      aria-label="All items"
      className="cookbook-thumbnail-strip"
      role="group"
    >
      {items.map((item, index) => (
        <button
          aria-current={index === currentIndex}
          aria-label={`Show ${item.videoUrl ? "video" : "image"} ${index + 1}`}
          className="cookbook-thumbnail-strip__thumbnail"
          key={item.imageUrl ?? item.videoUrl ?? index}
          onClick={() => goToIndex(index)}
          ref={index === currentIndex ? currentThumbnail : undefined}
          type="button"
        >
          <img alt="" src={item.imageUrl ?? item.videoThumbnailUrl} />
          {item.videoUrl && (
            <span className="cookbook-thumbnail-strip__play">
              <IconPlayFill />
            </span>
          )}
        </button>
      ))}
    </div>
  );
};
```

```css label="CSS" title="src/ThumbnailStrip.css"
@layer stream-overrides {
  .cookbook-thumbnail-strip {
    display: flex;
    justify-content: safe center;
    gap: var(--str-chat__spacing-xs);
    padding: var(--str-chat__spacing-xxs);
    overflow-x: auto;
  }

  .cookbook-thumbnail-strip__thumbnail {
    position: relative;
    flex-shrink: 0;
    width: 64px;
    height: 48px;
    padding: 0;
    overflow: hidden;
    border: 2px solid transparent;
    border-radius: var(--str-chat__radius-sm);
    background: none;
    opacity: 0.6;
    cursor: pointer;
  }

  .cookbook-thumbnail-strip__thumbnail[aria-current="true"] {
    border-color: var(--str-chat__accent-primary);
    opacity: 1;
  }

  .cookbook-thumbnail-strip__thumbnail:focus-visible {
    outline: 2px solid var(--str-chat__border-utility-focused);
    outline-offset: 2px;
  }

  .cookbook-thumbnail-strip__thumbnail img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  .cookbook-thumbnail-strip__play {
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    color: var(--str-chat__text-on-accent);
  }
}
```

## Keyboard and swipe navigation

`useLightboxNavigation` listens for the arrow keys, Home and End while the lightbox is open. It returns pointer handlers for the figure, so a drag to the side steps to the next or the previous item with a finger, a pen or the mouse:

```ts title="src/useLightboxNavigation.ts"
import type { PointerEvent } from "react";
import { useEffect, useRef } from "react";
import { useGalleryContext } from "stream-chat-react";

/** How far, in pixels, a pointer has to travel sideways to count as a swipe. */
const SWIPE_DISTANCE = 50;

/** Whether a key press belongs to a video's own controls, where the arrows seek. */
const isInVideo = (target: EventTarget | null) =>
  target instanceof Element && !!target.closest("video");

/**
 * Keyboard and swipe navigation for the lightbox. The arrow keys step to the previous or next
 * item, Home and End jump to the first and last. Returns pointer handlers for the element a swipe
 * starts on: a drag of at least {@link SWIPE_DISTANCE} pixels, more sideways than up or down,
 * steps in the direction the content moves, with a finger, a pen or the mouse.
 */
export const useLightboxNavigation = () => {
  const { goToIndex, goToNext, goToPrevious, itemCount } = useGalleryContext();
  const swipeStart = useRef<{ x: number; y: number }>(undefined);

  useEffect(() => {
    const onKeyDown = (event: KeyboardEvent) => {
      if (isInVideo(event.target)) return;
      const step = {
        ArrowLeft: goToPrevious,
        ArrowRight: goToNext,
        End: () => goToIndex(itemCount - 1),
        Home: () => goToIndex(0),
      }[event.key];
      if (!step) return;
      event.preventDefault();
      step();
    };
    document.addEventListener("keydown", onKeyDown);
    return () => document.removeEventListener("keydown", onKeyDown);
  }, [goToIndex, goToNext, goToPrevious, itemCount]);

  return {
    onPointerCancel: () => {
      swipeStart.current = undefined;
    },
    onPointerDown: (event: PointerEvent) => {
      // A drag on a video's timeline seeks; it isn't a swipe.
      if (isInVideo(event.target)) return;
      swipeStart.current = { x: event.clientX, y: event.clientY };
    },
    onPointerUp: (event: PointerEvent) => {
      const start = swipeStart.current;
      swipeStart.current = undefined;
      if (!start) return;
      const deltaX = event.clientX - start.x;
      const deltaY = event.clientY - start.y;
      if (
        Math.abs(deltaX) < SWIPE_DISTANCE ||
        Math.abs(deltaX) <= Math.abs(deltaY)
      )
        return;
      if (deltaX < 0) goToNext();
      else goToPrevious();
    },
  };
};
```

The figure sets `touch-action: pan-y` in `Lightbox.css`, so the browser leaves a sideways swipe to the handlers and still scrolls on a vertical one. `goToNext()` and `goToPrevious()` do nothing at the last and the first item.

## Captions

A Stream image attachment can carry a `title`, which the SDK copies to the gallery item as `title`. Its `fallback`, usually the file name, isn't copied. `LightboxCaption` shows the `title`, or the text of the message the item was shared in when it has none:

```tsx label="React" title="src/LightboxCaption.tsx"
import type { GalleryItem } from "stream-chat-react";
import "./LightboxCaption.css";

/**
 * The caption under the current item: the attachment's own `title`, or the text of the message it
 * was shared in when the attachment has none.
 */
export const LightboxCaption = ({ item }: { item: GalleryItem }) => {
  const caption = item.title || item.message?.text;
  if (!caption) return null;

  return (
    <figcaption className="cookbook-lightbox-caption">{caption}</figcaption>
  );
};
```

```css label="CSS" title="src/LightboxCaption.css"
@layer stream-overrides {
  .cookbook-lightbox-caption {
    max-width: 100%;
    color: var(--str-chat__text-secondary);
    font: var(--str-chat__font-body-default);
    text-align: center;
  }
}
```

## Videos next to images

A message with images and a video attachment puts them all in one gallery. The video attachment's `asset_url` becomes the item's `videoUrl`, and its `thumb_url` the `videoThumbnailUrl`. The grid shows the thumbnail with a play badge:

---

**Photos and a video in one grid**

![A message with two flower photos and a video, the video tile marked with a play badge](https://getstream.io/docs-assets/images/6ea464817a17.png)

---

In the lightbox, `LightboxMedia` plays the video in the browser's own player, with the thumbnail as its poster:

```tsx label="React" title="src/LightboxMedia.tsx"
import type { GalleryItem } from "stream-chat-react";
import "./LightboxMedia.css";

/**
 * The lightbox's current item: a video plays in the browser's own player, with its thumbnail as
 * the poster; an image shows at full size.
 */
export const LightboxMedia = ({ item }: { item: GalleryItem }) =>
  item.videoUrl ? (
    <video
      className="cookbook-lightbox-media"
      controls
      // A new key per video, so moving to another item stops the one that was playing.
      key={item.videoUrl}
      poster={item.videoThumbnailUrl}
      preload="metadata"
      src={item.videoUrl}
    />
  ) : (
    <img
      alt={item.title ?? ""}
      className="cookbook-lightbox-media"
      draggable={false}
      src={item.imageUrl}
    />
  );
```

```css label="CSS" title="src/LightboxMedia.css"
@layer stream-overrides {
  .cookbook-lightbox-media {
    max-width: 100%;
    max-height: 56vh;
    border-radius: var(--str-chat__radius-lg);
  }
}
```

---

**The lightbox at the video**

![The lightbox at the video, with the player controls and the caption from its title](https://getstream.io/docs-assets/images/6661dfa2d163.png)

---

The SDK's `VideoPlayer` isn't exported, so a lightbox of your own can't render it. A `VideoPlayer` override in `ComponentContext` changes the player of the default gallery and of a single video, not this one.

## How it works

>
> **Info:** **Example**
>
> Cara posts four photos from a hike in the Trail photos channel. The user sees them as a grid, clicks the second one, and the lightbox opens at the waterfall with its caption. They step through the photos with the arrow keys, or swipe on a phone, jump back with a thumbnail, and close the lightbox with Escape.
>

1. `MessageUI` renders the `Attachment` key for a message with attachments. `Attachment` puts its images and videos in one group: several go to its `ModalGallery` prop with one item each, one image goes to `Image`, and one video to `Media`.
2. `Image` renders the `ModalGallery` key with one item. Registering `ImageGrid` under the key gives that image the same lightbox.
3. `GlobalModal` renders the lightbox above the app, traps the focus in it, and closes on Escape or a click on the backdrop, calling `onClose`. `Gallery` is mounted only while the modal is open, so it starts at the clicked item every time.
4. `Lightbox` moves the focus to Close itself when it mounts. `GlobalModal`'s `getInitialFocusElement` runs before the modal's children mount, so it can't find the button.
5. `Gallery` keeps `currentIndex` and gives the UI `goToNext()`, `goToPrevious()` and `goToIndex()`. Every part of the lightbox reads them from `useGalleryContext()`, so the buttons, the keys, a swipe and the strip stay in step.

## Related

- [Attachments and uploads](https://getstream.io/chat/docs/sdk/react/features/rich-content/attachments-and-uploads/) lists which component renders each kind of attachment.
- [Attachments](https://getstream.io/chat/docs/sdk/react/component-reference/message/attachments/) lists the `Attachment` props, including `ModalGallery`.
- [Modal](https://getstream.io/chat/docs/sdk/react/component-reference/utilities/modal/) describes `GlobalModal` and its props.
- [Overriding components](https://getstream.io/chat/docs/sdk/react/ui/components/overriding-components/) explains how `WithComponents` keys apply.
- [Giphy preview](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/message-list/giphy-preview/) replaces the buttons under a `/giphy` preview.
- [GIF picker](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/composer/gif-picker/) adds a GIF from the composer, which the message list renders with `Giphy`, not the image grid.
- [Custom message](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/message-list/custom-message/) replaces the message UI that renders `Attachment`.

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react/ui-cookbooks/message-list/image-gallery/](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/message-list/image-gallery/).