🗓️ Everything *[NYC] is back. A free gathering for AI builders. Sept 9

Gallery Block

Image galleries as a Portable Text block: grid or carousel, fed from the Sanity Media Library. Every image carries alternative text, caption and credit, and the caption is rendered underneath each slide. The carousel steps through one image at a time with arrows and dot indicators; clicking any image opens it full screen in a native <dialog> lightbox with keyboard navigation. The renderer ships structural CSS only — colours, fonts and spacing come from your design via CSS custom properties.

Install command

npm i sanity-plugin-sgntech-gallery

sanity-plugin-sgntech-gallery — image galleries as a Portable Text block

sanity-plugin-sgntech-gallery

Image galleries as a Portable Text block for Sanity Studio — grid or carousel, fed from the Sanity Media Library, with a design-agnostic React renderer.

npm version license


What you get

  • A gallery block for Portable Text — several images as one block, dropped wherever they belong in the article.
  • Images come from the Sanity Media Library. The block uses regular image fields, so every asset source your Studio has is available. Set disableNew: true to hide the upload UI and force editors to pick from the library.
  • Alternative text, caption and credit per image — the caption is rendered underneath each slide, with the credit next to it.
  • Two layouts: grid (1–6 columns) and carousel — one image at a time, stepped through with arrows and dot indicators underneath.
  • Lightbox: clicking an image opens it full screen, with arrows, a counter, keyboard navigation and Escape to close. Built on the native <dialog>, so focus handling comes from the browser.
  • sanity-plugin-sgntech-gallery/render — a React renderer and a @portabletext/react mapping. That entry point does not import sanity, so your app bundle stays clean.
  • Design-agnostic markup. The stylesheet only handles structure — no colours, no fonts, no shadows. Everything inherits from your page, and every value worth changing is a CSS custom property. The block looks like part of your design without a single override.

Installation

npm install sanity-plugin-sgntech-gallery

Requires Sanity Studio v5 or v6 and React 18 or 19. No runtime dependencies.

📦 On npm: sanity-plugin-sgntech-gallery

Usage in the Studio

// sanity.config.ts
import {defineArrayMember, defineConfig, defineField} from 'sanity'
import {gallery} from 'sanity-plugin-sgntech-gallery'

export default defineConfig({
  // ...
  plugins: [gallery()],

  schema: {
    types: [
      {
        type: 'document',
        name: 'article',
        fields: [
          defineField({
            type: 'array',
            name: 'body',
            of: [
              defineArrayMember({type: 'block'}),
              defineArrayMember({type: 'gallery'}), // ← the gallery block
            ],
          }),
        ],
      },
    ],
  },
})

Editors pick Gallery from the "Add item" menu, add images, and fill in alternative text, caption and credit per image.

Options

gallery({
  name: 'gallery', // schema type name
  title: 'Gallery', // label in the editor's insert menu
  disableNew: false, // true = no uploads, Media Library only
  mediaLibraryFilters: [], // GROQ filters offered in the asset dialog
  assetSources: undefined, // restrict which asset sources this field offers
  hotspot: true, // hotspot/crop editor
  requireAlt: true, // make alternative text mandatory
  layouts: ['grid', 'carousel'], // layouts editors may choose from
  defaultLayout: 'grid',
  defaultColumns: 3,
  maxImages: undefined, // upper limit for the number of images
})

Media Library

The block stores plain Sanity image references, so it works with whatever asset sources your Studio offers. To use Sanity Media Library assets, enable it in your Studio config:

export default defineConfig({
  // ...
  mediaLibrary: {enabled: true},
  auth: {loginMethod: 'token'}, // recommended by Sanity for private assets
})

Force editors to pick from the library instead of uploading ad hoc:

gallery({disableNew: true})

Offer filters inside the asset dialog — each is a GROQ filter that runs against the library:

gallery({
  mediaLibraryFilters: [
    {name: 'Press photos', query: 'defined(aspects.pressPhoto)'},
    {name: 'Live shots', query: 'aspects.category == "live"'},
  ],
})

If an asset carries alternative text in the library, the renderer falls back to it when the editor left the alt field empty.

Stored value

{
  "_type": "gallery",
  "_key": "a1b2c3",
  "layout": "carousel",
  "columns": 3,
  "caption": "Waldbühne 2026",
  "images": [
    {
      "_key": "d4e5f6",
      "_type": "galleryImage",
      "asset": {"_type": "reference", "_ref": "image-abc123-2000x1333-jpg"},
      "alt": "Crowd at the barrier",
      "caption": "Berlin, Waldbühne",
      "credit": "Photo: E. Hohmann"
    }
  ]
}

Rendering in your frontend

Fetch the gallery with the asset dereferenced — the renderer uses url, the dimensions and the library's altText when they are there:

body[]{
  ...,
  _type == "gallery" => {
    ...,
    images[]{
      ...,
      asset->{url, altText, metadata{lqip, dimensions}}
    }
  }
}

With @portabletext/react

import {PortableText} from '@portabletext/react'
import {galleryPortableTextComponents} from 'sanity-plugin-sgntech-gallery/render'

;<PortableText value={article.body} components={galleryPortableTextComponents} />

Merge it with your own block types:

const components = {
  types: {
    ...galleryPortableTextComponents.types,
    image: MyImageBlock,
  },
}

Standalone component

import {Gallery} from 'sanity-plugin-sgntech-gallery/render'

;<Gallery value={gallery} className="my-gallery" />

With @sanity/image-url or next/image

import imageUrlBuilder from '@sanity/image-url'
import Image from 'next/image'
import {Gallery} from 'sanity-plugin-sgntech-gallery/render'

const builder = imageUrlBuilder(client)

;<Gallery
  value={gallery}
  resolveImageUrl={(image) => builder.image(image).width(1200).auto('format').url()}
  renderImage={(image, {src, alt}) => (
    <Image className="sgn-gallery__image" src={src!} alt={alt} width={1200} height={800} />
  )}
/>

Props

PropDefaultPurpose
valueThe gallery value from Sanity
layout / columnsfrom the valueOverride what the editor picked
showCaptionstrueRender captions underneath each image
controlstruePrevious/next buttons in the carousel
indicatorstrueDot indicators underneath the carousel
lightboxtrueOpen images full screen on click
loopfalseWrap around at the first and last image
resolveImageUrlBuild image URLs yourself
resolveLightboxUrlresolveImageUrlLarger URL for the lightbox
renderImageReplace the <img> element
sizes, loadinglazyPassed through to the image
injectStylestrueAdd the structural stylesheet to the document
className, labelExtra class, accessible name
labelsEnglishButton texts — previous, next, close, goTo, open

Styling

The renderer ships structural CSS only — grid, scroll-snap, aspect ratio. Colours, fonts and spacing come from your page. Every selector is a single class, so your own rules win without !important.

.sgn-gallery {
  --sgn-gallery-gap: 1.5rem;
  --sgn-gallery-columns: 3; /* set by the block, override if you like */
  --sgn-gallery-radius: 12px;
  --sgn-gallery-aspect: 4 / 3; /* auto = keep the original ratio */
  --sgn-gallery-object-fit: cover;
  --sgn-gallery-caption-gap: 0.5rem;
  --sgn-gallery-caption-size: 0.875em;
  --sgn-gallery-caption-opacity: 0.75;
  --sgn-gallery-slide-width: 100%; /* e.g. min(100%, 34rem) to peek at the next slide */
  --sgn-gallery-control-size: 2.25rem;
  --sgn-gallery-control-radius: 999px;
  --sgn-gallery-dot-size: 0.5rem;
  --sgn-gallery-dot-gap: 0.5rem;
  --sgn-gallery-lightbox-backdrop: rgba(0, 0, 0, 0.82);
  --sgn-gallery-lightbox-bg: transparent;
  --sgn-gallery-lightbox-color: #fff;
  --sgn-gallery-lightbox-padding: 2rem;
}

Class names: sgn-gallery, __list (--grid / --carousel), __item, __figure, __image, __trigger, __caption, __credit, __footer, __controls, __control, __dots, __dot, __lightbox, __lightbox-inner, __lightbox-image, __lightbox-caption, __lightbox-nav, __lightbox-counter, __lightbox-close.

The active dot carries aria-current="true", so styling the current slide indicator needs no extra class.

Prefer to own the CSS completely? Pass injectStyles={false} and import galleryStyles as a starting point, or write your own from scratch.

Accessibility

  • Alternative text is required by default (requireAlt). An empty alt marks an image as decorative, which is the correct choice for purely ornamental pictures — the plugin never falls back to the caption for alt, since that would make screen readers repeat the visible text.
  • The carousel is a keyboard-focusable scroll region, so it can be scrolled without a mouse. Arrows and dots are real buttons with labels; the dots expose aria-current.
  • The lightbox is a native modal <dialog>: the browser traps focus, closes it on Escape and returns focus to the image that opened it. Left/right arrow keys step through the images, and a click on the backdrop closes it.
  • prefers-reduced-motion disables the smooth scrolling.
  • Captions use figure/figcaption, so the association is explicit.

Develop

npm install
npm test            # unit tests for the value helpers
npm run lint
npm run build
npm run link-watch  # build + publish to a local yalc repo for Studio testing

Built with @sanity/plugin-kit.

License

MIT © SGNTech

Related contributions