Gallery
This is an experimental block
This block has been built on top of experimental base component. The API and structure of it might change based on user feedback.
The Gallery presents a visually appealing and user-friendly collection of images that can be conveniently viewed and navigated. Within the example block, navigation options include "previous/next" arrows and photo thumbnails, providing users with multiple ways to interact with the gallery. These navigation features can be activated through both clicks and hover actions.
The Gallery is primarily intended for use on product pages, where it serves as an effective tool for showcasing product images. The choice between the vertical and horizontal variants should depend on the layout of the product page, ensuring optimal visual presentation. Additionally, the variant with bullets should be considered, particularly for mobile devices, as it offers a compact and easily accessible navigation format.
Accessibility notes
The Gallery supports the use of the keyboard (Tab/shift+Tab) to navigate through images.
Product Gallery with vertical thumbnails
Changing an image is provided by hover on the thumbnail or dragging the main image. There are buttons to scroll thumbnails up and down.
import { useRef, useState } from 'react';
import { useIntersection } from 'react-use';
import {
SfScrollable,
SfButton,
SfIconChevronLeft,
SfIconChevronRight,
type SfScrollableOnDragEndData,
} from '@storefront-ui/react';
import classNames from 'classnames';
const withBase = (filepath: string) => `https://storage.googleapis.com/sfui_docs_artifacts_bucket_public/production/gallery/${filepath}`;
const images = [
{ imageSrc: withBase('gallery_1.png'), imageThumbSrc: withBase('gallery_1_thumb.png'), alt: 'backpack1' },
{ imageSrc: withBase('gallery_2.png'), imageThumbSrc: withBase('gallery_2_thumb.png'), alt: 'backpack2' },
{ imageSrc: withBase('gallery_3.png'), imageThumbSrc: withBase('gallery_3_thumb.png'), alt: 'backpack3' },
{ imageSrc: withBase('gallery_4.png'), imageThumbSrc: withBase('gallery_4_thumb.png'), alt: 'backpack4' },
{ imageSrc: withBase('gallery_5.png'), imageThumbSrc: withBase('gallery_5_thumb.png'), alt: 'backpack5' },
{ imageSrc: withBase('gallery_6.png'), imageThumbSrc: withBase('gallery_6_thumb.png'), alt: 'backpack6' },
{ imageSrc: withBase('gallery_7.png'), imageThumbSrc: withBase('gallery_7_thumb.png'), alt: 'backpack7' },
{ imageSrc: withBase('gallery_8.png'), imageThumbSrc: withBase('gallery_8_thumb.png'), alt: 'backpack8' },
{ imageSrc: withBase('gallery_9.png'), imageThumbSrc: withBase('gallery_9_thumb.png'), alt: 'backpack9' },
{ imageSrc: withBase('gallery_10.png'), imageThumbSrc: withBase('gallery_10_thumb.png'), alt: 'backpack10' },
{ imageSrc: withBase('gallery_11.png'), imageThumbSrc: withBase('gallery_11_thumb.png'), alt: 'backpack11' },
];
export default function GalleryVertical() {
const lastThumbRef = useRef<HTMLButtonElement>(null);
const thumbsRef = useRef<HTMLDivElement>(null);
const firstThumbRef = useRef<HTMLButtonElement>(null);
const [activeIndex, setActiveIndex] = useState(0);
const firstThumbVisible = useIntersection(firstThumbRef, {
root: thumbsRef.current,
rootMargin: '0px',
threshold: 1,
});
const lastThumbVisible = useIntersection(lastThumbRef, {
root: thumbsRef.current,
rootMargin: '0px',
threshold: 1,
});
const onDragged = (event: SfScrollableOnDragEndData) => {
if (event.swipeRight && activeIndex > 0) {
setActiveIndex((currentActiveIndex) => currentActiveIndex - 1);
} else if (event.swipeLeft && activeIndex < images.length - 1) {
setActiveIndex((currentActiveIndex) => currentActiveIndex + 1);
}
};
return (
<div className="relative flex w-full max-h-[600px] aspect-[4/3]">
<SfScrollable
ref={thumbsRef}
className="items-center w-full [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
direction="vertical"
activeIndex={activeIndex}
prevDisabled={activeIndex === 0}
nextDisabled={activeIndex === images.length - 1}
slotPreviousButton={
<SfButton
className={classNames('absolute !rounded-full z-10 top-4 rotate-90 bg-white', {
hidden: firstThumbVisible?.isIntersecting,
})}
variant="secondary"
size="sm"
square
slotPrefix={<SfIconChevronLeft size="sm" />}
/>
}
slotNextButton={
<SfButton
className={classNames('absolute !rounded-full z-10 bottom-4 rotate-90 bg-white', {
hidden: lastThumbVisible?.isIntersecting,
})}
variant="secondary"
size="sm"
square
slotPrefix={<SfIconChevronRight size="sm" />}
/>
}
>
{images.map(({ imageThumbSrc, alt }, index, thumbsArray) => (
<button
// eslint-disable-next-line no-nested-ternary
ref={index === thumbsArray.length - 1 ? lastThumbRef : index === 0 ? firstThumbRef : null}
type="button"
aria-label={alt}
aria-current={activeIndex === index}
key={`${alt}-${index}-thumbnail`}
className={classNames(
'md:w-[78px] md:h-auto relative shrink-0 pb-1 mx-4 -mb-2 border-b-4 snap-center cursor-pointer focus-visible:outline focus-visible:outline-offset transition-colors flex-grow md:flex-grow-0',
{
'border-primary-700': activeIndex === index,
'border-transparent': activeIndex !== index,
},
)}
onMouseOver={() => setActiveIndex(index)}
onFocus={() => setActiveIndex(index)}
>
<img alt={alt} className="border border-neutral-200" width="78" height="78" src={imageThumbSrc} />
</button>
))}
</SfScrollable>
<SfScrollable
className="w-full h-full snap-y snap-mandatory [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
activeIndex={activeIndex}
direction="vertical"
wrapperClassName="h-full m-auto"
buttonsPlacement="none"
isActiveIndexCentered
drag={{ containerWidth: true }}
onDragEnd={onDragged}
>
{images.map(({ imageSrc, alt }, index) => (
<div
key={`${alt}-${index}`}
className="flex justify-center h-full basis-full shrink-0 grow snap-center snap-always"
>
<img
aria-label={alt}
aria-hidden={activeIndex !== index}
className="object-contain w-auto h-full"
alt={alt}
src={imageSrc}
/>
</div>
))}
</SfScrollable>
</div>
);
}
Product Gallery with horizontal thumbnails
Changing an image is provided by click on the thumbnail or dragging the main image. You can scroll thumbnails by click on the button.
import { useState } from 'react';
import {
SfScrollable,
SfButton,
SfIconChevronLeft,
SfIconChevronRight,
type SfScrollableOnDragEndData,
} from '@storefront-ui/react';
import classNames from 'classnames';
const withBase = (filepath: string) => `https://storage.googleapis.com/sfui_docs_artifacts_bucket_public/production/gallery/${filepath}`;
const images = [
{ imageSrc: withBase('gallery_1.png'), imageThumbSrc: withBase('gallery_1_thumb.png'), alt: 'backpack1' },
{ imageSrc: withBase('gallery_2.png'), imageThumbSrc: withBase('gallery_2_thumb.png'), alt: 'backpack2' },
{ imageSrc: withBase('gallery_3.png'), imageThumbSrc: withBase('gallery_3_thumb.png'), alt: 'backpack3' },
{ imageSrc: withBase('gallery_4.png'), imageThumbSrc: withBase('gallery_4_thumb.png'), alt: 'backpack4' },
{ imageSrc: withBase('gallery_5.png'), imageThumbSrc: withBase('gallery_5_thumb.png'), alt: 'backpack5' },
{ imageSrc: withBase('gallery_6.png'), imageThumbSrc: withBase('gallery_6_thumb.png'), alt: 'backpack6' },
{ imageSrc: withBase('gallery_7.png'), imageThumbSrc: withBase('gallery_7_thumb.png'), alt: 'backpack7' },
{ imageSrc: withBase('gallery_8.png'), imageThumbSrc: withBase('gallery_8_thumb.png'), alt: 'backpack8' },
{ imageSrc: withBase('gallery_9.png'), imageThumbSrc: withBase('gallery_9_thumb.png'), alt: 'backpack9' },
{ imageSrc: withBase('gallery_10.png'), imageThumbSrc: withBase('gallery_10_thumb.png'), alt: 'backpack10' },
{ imageSrc: withBase('gallery_11.png'), imageThumbSrc: withBase('gallery_11_thumb.png'), alt: 'backpack11' },
];
export default function GalleryHorizontal() {
const [activeIndex, setActiveIndex] = useState(0);
const onDragged = (event: SfScrollableOnDragEndData) => {
if (event.swipeRight && activeIndex > 0) {
setActiveIndex((currentActiveIndex) => currentActiveIndex - 1);
} else if (event.swipeLeft && activeIndex < images.length - 1) {
setActiveIndex((currentActiveIndex) => currentActiveIndex + 1);
}
};
return (
<div className="relative flex flex-col w-full max-h-[600px] aspect-[4/3]">
<SfScrollable
className="w-full h-full snap-x snap-mandatory [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
activeIndex={activeIndex}
wrapperClassName="h-full min-h-0"
buttonsPlacement="none"
isActiveIndexCentered
drag={{ containerWidth: true }}
onDragEnd={onDragged}
>
{images.map(({ imageSrc, alt }, index) => (
<div
key={`${alt}-${index}`}
className="flex justify-center h-full basis-full shrink-0 grow snap-center snap-always"
>
<img
aria-label={alt}
aria-hidden={activeIndex !== index}
className="w-auto h-full"
alt={alt}
src={imageSrc}
/>
</div>
))}
</SfScrollable>
<SfScrollable
className="items-center w-full [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']"
activeIndex={activeIndex}
buttonsPlacement="floating"
slotPreviousButton={
<SfButton
className="absolute disabled:hidden !rounded-full z-10 left-4 bg-white"
variant="secondary"
size="sm"
square
slotPrefix={<SfIconChevronLeft size="sm" />}
/>
}
slotNextButton={
<SfButton
className="absolute disabled:hidden !rounded-full z-10 right-4 bg-white"
variant="secondary"
size="sm"
square
slotPrefix={<SfIconChevronRight size="sm" />}
/>
}
>
{images.map(({ imageThumbSrc, alt }, index) => (
<button
type="button"
aria-label={alt}
aria-current={activeIndex === index}
key={`${alt}-${index}-thumbnail`}
className={classNames(
'md:w-14 md:h-auto relative shrink-0 pb-1 my-2 -mr-2 border-b-4 snap-start cursor-pointer focus-visible:outline focus-visible:outline-offset transition-colors flex-grow md:flex-grow-0',
activeIndex === index ? 'border-primary-700' : 'border-transparent',
)}
onClick={() => setActiveIndex(index)}
>
<img
alt={alt}
className="object-contain border border-neutral-200"
width="78"
height="78"
src={imageThumbSrc}
/>
</button>
))}
</SfScrollable>
</div>
);
}
Product Gallery with bullets
Changing an image is provided by click on the buttons which are visible after hovering on the main image. Currently displayed image is highlighted by the bullets below the main image.
import { useState } from 'react';
import { SfScrollable, SfButton, SfIconChevronLeft, SfIconChevronRight } from '@storefront-ui/react';
import classNames from 'classnames';
const withBase = (filepath: string) => `https://storage.googleapis.com/sfui_docs_artifacts_bucket_public/production/gallery/${filepath}`;
const images = [
{ imageSrc: withBase('gallery_1.png'), alt: 'backpack1' },
{ imageSrc: withBase('gallery_2.png'), alt: 'backpack2' },
{ imageSrc: withBase('gallery_3.png'), alt: 'backpack3' },
{ imageSrc: withBase('gallery_4.png'), alt: 'backpack4' },
{ imageSrc: withBase('gallery_5.png'), alt: 'backpack5' },
{ imageSrc: withBase('gallery_6.png'), alt: 'backpack6' },
{ imageSrc: withBase('gallery_7.png'), alt: 'backpack7' },
{ imageSrc: withBase('gallery_8.png'), alt: 'backpack8' },
{ imageSrc: withBase('gallery_9.png'), alt: 'backpack9' },
{ imageSrc: withBase('gallery_10.png'), alt: 'backpack10' },
{ imageSrc: withBase('gallery_11.png'), alt: 'backpack11' },
];
export default function GalleryWithBullets() {
const [activeIndex, setActiveIndex] = useState(0);
return (
<div className="relative max-h-[600px] flex flex-col w-full aspect-[4/3] gap-1">
<SfScrollable
className="w-full h-full snap-x snap-mandatory [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
wrapperClassName="group/scrollable h-full"
activeIndex={activeIndex}
isActiveIndexCentered
prevDisabled={activeIndex === 0}
nextDisabled={activeIndex === images.length - 1}
buttonsPlacement="block"
onPrev={() => {
setActiveIndex(() => activeIndex - 1);
}}
onNext={() => {
setActiveIndex(() => activeIndex + 1);
}}
slotPreviousButton={
<SfButton
className="hidden group-hover/scrollable:block disabled:!hidden absolute !rounded-full !p-3 z-10 top-1/2 left-4 bg-white"
variant="secondary"
size="lg"
slotPrefix={<SfIconChevronLeft />}
/>
}
slotNextButton={
<SfButton
className="hidden group-hover/scrollable:block disabled:!hidden absolute !rounded-full !p-3 z-10 top-1/2 right-4 bg-white"
variant="secondary"
size="lg"
slotPrefix={<SfIconChevronRight />}
/>
}
>
{images.map(({ imageSrc, alt }, index) => (
<div
className="relative flex justify-center basis-full snap-center snap-always shrink-0 grow"
key={`${alt}-${index}`}
>
<img
aria-label={alt}
aria-hidden={activeIndex !== index}
className="object-contain w-auto h-full"
alt={alt}
src={imageSrc}
draggable="false"
/>
</div>
))}
</SfScrollable>
<div className="flex-shrink-0 basis-auto">
<div className="flex-row w-full flex gap-0.5 mt [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
{images.map(({ alt }, index) => (
<button
key={`${index}-bullet`}
aria-label={alt}
aria-current={activeIndex === index}
type="button"
className={classNames(
'w-full relative mt-1 border-b-4 transition-colors focus-visible:outline focus-visible:outline-offset-0',
{ 'border-primary-700': activeIndex === index, 'border-gray-200': activeIndex !== index },
)}
onClick={() => setActiveIndex(index)}
/>
))}
</div>
</div>
</div>
);
}
Product Gallery with arrow key navigation
In this block there is added arrow key navigation. When focus is on one of the thumbnails it's possible to change currently displayed image by pressing arrow keys - arrow up and arrow right will show a next image and arrow down and arrow left will show a previous image.
import { useState } from 'react';
import {
SfScrollable,
SfButton,
SfIconChevronLeft,
SfIconChevronRight,
type SfScrollableOnDragEndData,
} from '@storefront-ui/react';
import classNames from 'classnames';
const withBase = (filepath: string) => `https://storage.googleapis.com/sfui_docs_artifacts_bucket_public/production/gallery/${filepath}`;
const images = [
{ imageSrc: withBase('gallery_1.png'), imageThumbSrc: withBase('gallery_1_thumb.png'), alt: 'backpack1' },
{ imageSrc: withBase('gallery_2.png'), imageThumbSrc: withBase('gallery_2_thumb.png'), alt: 'backpack2' },
{ imageSrc: withBase('gallery_3.png'), imageThumbSrc: withBase('gallery_3_thumb.png'), alt: 'backpack3' },
{ imageSrc: withBase('gallery_4.png'), imageThumbSrc: withBase('gallery_4_thumb.png'), alt: 'backpack4' },
{ imageSrc: withBase('gallery_5.png'), imageThumbSrc: withBase('gallery_5_thumb.png'), alt: 'backpack5' },
{ imageSrc: withBase('gallery_6.png'), imageThumbSrc: withBase('gallery_6_thumb.png'), alt: 'backpack6' },
{ imageSrc: withBase('gallery_7.png'), imageThumbSrc: withBase('gallery_7_thumb.png'), alt: 'backpack7' },
{ imageSrc: withBase('gallery_8.png'), imageThumbSrc: withBase('gallery_8_thumb.png'), alt: 'backpack8' },
{ imageSrc: withBase('gallery_9.png'), imageThumbSrc: withBase('gallery_9_thumb.png'), alt: 'backpack9' },
{ imageSrc: withBase('gallery_10.png'), imageThumbSrc: withBase('gallery_10_thumb.png'), alt: 'backpack10' },
{ imageSrc: withBase('gallery_11.png'), imageThumbSrc: withBase('gallery_11_thumb.png'), alt: 'backpack11' },
];
export default function GalleryHorizontal() {
const [activeIndex, setActiveIndex] = useState(0);
const onDragged = (event: SfScrollableOnDragEndData) => {
if (event.swipeRight && activeIndex > 0) {
setActiveIndex((currentActiveIndex) => currentActiveIndex - 1);
} else if (event.swipeLeft && activeIndex < images.length - 1) {
setActiveIndex((currentActiveIndex) => currentActiveIndex + 1);
}
};
const activeArrowNavigation = (event: React.KeyboardEvent, index: number) => {
event.preventDefault();
const currentElement = event?.target as HTMLElement;
const nextElement = currentElement.nextElementSibling as HTMLElement;
const previousElement = currentElement.previousElementSibling as HTMLElement;
if ((event.code === 'ArrowRight' || event.code === 'ArrowUp') && index < images.length - 1) {
setActiveIndex(index + 1);
nextElement.focus();
} else if ((event.code === 'ArrowLeft' || event.code === 'ArrowDown') && index > 0) {
setActiveIndex(index - 1);
previousElement.focus();
}
};
return (
<div className="relative flex flex-col w-full max-h-[600px] aspect-[4/3]">
<SfScrollable
className="w-full h-full snap-x snap-mandatory [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
activeIndex={activeIndex}
wrapperClassName="h-full min-h-0"
buttonsPlacement="none"
isActiveIndexCentered
drag={{ containerWidth: true }}
onDragEnd={onDragged}
>
{images.map(({ imageSrc, alt }, index) => (
<div key={`${alt}-${index}`} className="flex justify-center h-full basis-full shrink-0 grow snap-center">
<img
aria-label={alt}
aria-hidden={activeIndex !== index}
className="w-auto h-full"
alt={alt}
src={imageSrc}
/>
</div>
))}
</SfScrollable>
<SfScrollable
className="items-center w-full [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']"
activeIndex={activeIndex}
buttonsPlacement="floating"
slotPreviousButton={
<SfButton
className="absolute disabled:hidden !rounded-full z-10 left-4 bg-white"
variant="secondary"
size="sm"
square
slotPrefix={<SfIconChevronLeft size="sm" />}
/>
}
slotNextButton={
<SfButton
className="absolute disabled:hidden !rounded-full z-10 right-4 bg-white"
variant="secondary"
size="sm"
square
slotPrefix={<SfIconChevronRight size="sm" />}
/>
}
>
{images.map(({ imageThumbSrc, alt }, index) => (
<button
type="button"
aria-label={alt}
aria-current={activeIndex === index}
key={`${alt}-${index}-thumbnail`}
className={classNames(
'md:w-14 md:h-auto relative shrink-0 pb-1 my-2 -mr-2 border-b-4 snap-start cursor-pointer focus-visible:outline focus-visible:outline-offset transition-colors flex-grow md:flex-grow-0',
activeIndex === index ? 'border-primary-700' : 'border-transparent',
)}
onClick={() => setActiveIndex(index)}
onKeyDown={(event) => activeArrowNavigation(event, index)}
>
<img
alt={alt}
className="object-contain border border-neutral-200"
width="78"
height="78"
src={imageThumbSrc}
/>
</button>
))}
</SfScrollable>
</div>
);
}