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

Approach: Replace one part. Choosing where to start explains the approaches.

Starts from: 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:

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 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:

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>
    </>
  );
};

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:

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>
  );
};

The lightbox at the waterfall

The lightbox open at the waterfall, with its caption and a strip of the four photos

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:

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>
  );
};

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:

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:

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>
  );
};

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

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

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}
    />
  );

The lightbox at the video

The lightbox at the video, with the player controls and the caption from its title

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.