@cloudinary/product-gallery
v2.0.2
Published
Cloudinary Gallery Widget — an embeddable product media gallery with support for images, videos, and 3D models.
Readme
Cloudinary Gallery Widget — an embeddable product media gallery with support for images, videos, and 3D models.
Built by Cloudinary.
Documentation
Full documentation: cloudinary.com/documentation/product_gallery
Installation
npm install @cloudinary/product-galleryUsage — React / Next.js
// in layout.tsx
import '@cloudinary/product-gallery/index.css';
// page.tsx (Server Component)
import { resolveConfig } from '@cloudinary/product-gallery/server';
import { GalleryClient } from './GalleryClient';
export default async function Page() {
const resolvedConfig = await resolveConfig({
cloudName: 'your-cloud-name',
mediaAssets: [{ publicId: 'sample' }],
});
return <GalleryClient resolvedConfig={resolvedConfig} />;
}// GalleryClient.tsx (Client Component)
'use client';
import { Gallery } from '@cloudinary/product-gallery';
export function GalleryClient({ resolvedConfig }) {
return <Gallery resolvedConfig={resolvedConfig} />;
}Note — the
@cloudinary/product-gallery/index.cssimport above relies on a CSS bundler (as in Next.js). If you are not using one (e.g. manual SSR with Express), you can for example import the stylesheet as a string and inline it instead:import galleryStyles from '@cloudinary/product-gallery/index.css', then render<style dangerouslySetInnerHTML={{ __html: galleryStyles }} />in your document<head>.
Usage — Script embed (standalone build)
<script src="https://product-gallery-v2.cloudinary.com/latest/all.js"></script>
<div id="my-gallery"></div>
<script>
const widget = cloudinary.galleryWidget({
cloudName: 'your-cloud-name',
container: '#my-gallery',
mediaAssets: [{ publicId: 'sample' }]
});
widget.render();
</script>The returned instance has the methods render, hydrate, update, focus, hide, destroy and on, plus two properties kept from v1:
widget.options– the options object you passed togalleryWidget(), as the same reference.render(newOptions)andupdate()don't change it.widget.config– the widget's current config. Right aftergalleryWidget()it is a copy of the defaults (cloudinary.galleryWidgetInfo.DEFAULTS); once arender(),hydrate()orupdate()call has resolved, it is the full resolved config (defaults, skin and options merged, withviewportBreakpointsandpreloadtaken as given). It stays readable afterdestroy().
await widget.render();
widget.config.carouselLocation; // resolved value, default or yoursMoving from v1
Swap the script URL to https://product-gallery-v2.cloudinary.com/latest/all.js. Your existing galleryWidget(…) config keeps working, and v2 renders its own DOM, which carries no data-test hooks unless you ask for them.
If your page's CSS or JS targets the widget's internal DOM (v1 data-test hooks such as gallery-viewer-wrap or carousel-nav-next, or v1 class names), also add useLegacyV1Mode: true:
cloudinary.galleryWidget({ cloudName: 'your-cloud-name', container: '#my-gallery', mediaAssets, useLegacyV1Mode: true });Deprecated –
useLegacyV1Modeis a temporary migration aid for script embeds and will be removed in a future major version. Plan to move your page to v2's documented API (andtestAttributesfor test hooks) instead of the widget's internals. The npm package ignores it.
| Config | Script embed (all.js) | npm package |
|---|---|---|
| neither set (default) | no hooks | no hooks |
| testAttributes: true | v2 data-test names | v2 data-test names |
| useLegacyV1Mode: true (with any testAttributes) | v1 data-test names | ignored: follows testAttributes |
In legacy v1 mode the hooks keep v1's names. Only the thumbnail strip differs from v2, because v1 built it from a carousel: carousel (v2 thumbnails-wrapper), carousel-inner-wrapper (thumbnails-inner-wrapper), carousel-nav-prev / carousel-nav-next (thumbnails-nav-prev / thumbnails-nav-next), carousel-item-<n> (thumbnail-item-<n>) and the bare index <n> on the thumbnail button (thumbnail-asset-<n>). Icons also get v1's svg-image. hydrate() ignores the flag, to match the npm package's server markup.
Legacy v1 mode also keeps the v1 structure host CSS selects through, without changing how v2 behaves:
- v1's elements:
gallery-spacerbetween viewer and navigator (in place of the gap, same size), the thumbnail chaincarousel > carousel-wrapper > carousel-inner-wrapper > carousel-item-<n> > slide-item-wrap > thumbnails-wrap(with v1'sthumbnails-wrapclass; the button gets v1'sassetWrapperclass),selected-wrapover the selected thumbnail (an empty styling hook: v2 still draws the selected border, your CSS can restyle the hook), andcarousel > carousel-wrapperandslide-item-wrap/fade-item-wrapin the viewer. - v1's bare attributes:
modeongallery-wrapandgallery-thumbnail-wrap,selected="true"on the selected indicator,shapeanddirectionon the arrows.
Legacy v1 mode also brings back what made v1 pages look the way they did, while sizes and image URLs stay v2's (responsive layout, srcset):
- Layout: expanded-mode rows take the aspect ratio on their content box, so a host border adds to a row's height as it did on v1's content-sized rows.
- Specificity: the widget's styles carry v1's one-class specificity (the root gets
pgw-v1instead ofpgw), so host rules that overrode v1's styles, such as arrow colours and borders, still apply.
Not restored: v1's generated class names (css-…), img-zoom-wrap, v1's other bare attributes (color, size, radius, …), the zoom pop-up's v1 child order (selectors such as [data-test="popup"] div:nth-child(2) > button don't match v2's pop-up), and native disabled on arrows, which stay aria-disabled and focusable, so button[disabled] / :disabled selectors don't match.
The script build also serves an empty image.js next to all.js, so pages that still preload v1's image.js chunk don't get a 404.
Responsive sizing
The gallery viewer is fully responsive: it fills the width of its parent element and derives its height from the configured aspect ratio using native CSS (width: 100% + aspect-ratio). To size the gallery, constrain its parent container — don't pass pixel dimensions to the widget.
// The gallery will be 800px wide on desktop and shrink with the viewport.
<div style={{ maxWidth: 800, width: '100%', margin: '0 auto' }}>
<Gallery resolvedConfig={resolvedConfig} />
</div><!-- Same idea for the standalone embed -->
<div id="my-gallery" style="max-width: 800px; width: 100%; margin: 0 auto;"></div>viewerWidthHint (SSR, optional)
viewerWidthHint is an optional config setting. When rendering on the server, the widget cannot measure its container, so the server-generated markup (including responsive srcSet) may not match what the client computes on hydration. If you want server and client markup to line up exactly, set viewerWidthHint to the width your container will occupy at render time — typically the same value as its CSS max-width.
const MAX_WIDTH = 800;
const resolvedConfig = await resolveConfig({
cloudName: 'your-cloud-name',
mediaAssets: [{ publicId: 'sample' }],
viewerWidthHint: MAX_WIDTH,
});
return (
<div style={{ maxWidth: MAX_WIDTH, width: '100%', margin: '0 auto' }}>
<GalleryClient resolvedConfig={resolvedConfig} />
</div>
);Notes:
- Only needed for SSR. Omit it in purely client-side usage — the widget will measure the container directly.
- The hint drives
srcSetselection and internal dimension calculations so server and client agree on hydration. It does not force the viewer to render at that pixel width — the CSS still handles visual sizing. - Should match the container's effective layout width. A significant mismatch will cause React to log a hydration-dimension warning and may result in a suboptimal
srcSetpick.
