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.
- A
galleryblock 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
imagefields, so every asset source your Studio has is available. SetdisableNew: trueto 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/reactmapping. That entry point does not importsanity, 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.
npm install sanity-plugin-sgntech-galleryRequires Sanity Studio v5 or v6 and React 18 or 19. No runtime dependencies.
📦 On npm: sanity-plugin-sgntech-gallery
// 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.
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
})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.
{
"_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"
}
]
}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}}
}
}
}
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,
},
}import {Gallery} from 'sanity-plugin-sgntech-gallery/render'
;<Gallery value={gallery} className="my-gallery" />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} />
)}
/>| Prop | Default | Purpose |
|---|---|---|
value |
— | The gallery value from Sanity |
layout / columns |
from the value | Override what the editor picked |
showCaptions |
true |
Render captions underneath each image |
controls |
true |
Previous/next buttons in the carousel |
indicators |
true |
Dot indicators underneath the carousel |
lightbox |
true |
Open images full screen on click |
loop |
false |
Wrap around at the first and last image |
resolveImageUrl |
— | Build image URLs yourself |
resolveLightboxUrl |
resolveImageUrl |
Larger URL for the lightbox |
renderImage |
— | Replace the <img> element |
sizes, loading |
lazy |
Passed through to the image |
injectStyles |
true |
Add the structural stylesheet to the document |
className, label |
— | Extra class, accessible name |
labels |
English | Button texts — previous, next, close, goTo, open |
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.
- Alternative text is required by default (
requireAlt). An emptyaltmarks an image as decorative, which is the correct choice for purely ornamental pictures — the plugin never falls back to the caption foralt, 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-motiondisables the smooth scrolling.- Captions use
figure/figcaption, so the association is explicit.
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 testingBuilt with @sanity/plugin-kit.
MIT © SGNTech
