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.
<template>
<div class="relative flex w-full max-h-[600px] aspect-[4/3]">
<SfScrollable
ref="thumbsRef"
class="items-center w-full [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
direction="vertical"
:active-index="activeIndex"
:previous-disabled="activeIndex === 0"
:next-disabled="activeIndex === images.length - 1"
buttons-placement="floating"
>
<template #previousButton="defaultProps">
<SfButton
v-if="!firstThumbVisible"
v-bind="defaultProps"
:disabled="activeIndex === 0"
class="absolute !rounded-full z-10 top-4 rotate-90 bg-white"
variant="secondary"
size="sm"
square
>
<SfIconChevronLeft size="sm" />
</SfButton>
</template>
<button
v-for="({ imageThumbSrc, alt }, index) in images"
:key="`${alt}-${index}-thumbnail`"
:ref="(el) => assignRef(el, index)"
type="button"
:aria-label="alt"
:aria-current="activeIndex === index"
:class="`md:w-[78px] md:h-auto relative shrink-0 pb-1 mx-4 -mb-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'
}`"
@mouseover="activeIndex = index"
@focus="activeIndex = index"
>
<img :alt="alt" class="border border-neutral-200" width="78" height="78" :src="imageThumbSrc" />
</button>
<template #nextButton="defaultProps">
<SfButton
v-if="!lastThumbVisible"
v-bind="defaultProps"
:disabled="activeIndex === images.length"
class="absolute !rounded-full z-10 bottom-4 rotate-90 bg-white"
variant="secondary"
size="sm"
square
>
<SfIconChevronRight size="sm" />
</SfButton>
</template>
</SfScrollable>
<SfScrollable
class="w-full h-full snap-y snap-mandatory [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
:active-index="activeIndex"
direction="vertical"
wrapper-class="h-full m-auto"
is-active-index-centered
buttons-placement="none"
:drag="{ containerWidth: true }"
@on-drag-end="onDragged"
>
<div
v-for="({ imageSrc, alt }, index) in images"
:key="`${alt}-${index}`"
class="flex justify-center h-full basis-full shrink-0 grow snap-center snap-always"
>
<img
:aria-label="alt"
:aria-hidden="activeIndex !== index"
class="object-cover w-auto h-full"
:alt="alt"
:src="imageSrc"
/>
</div>
</SfScrollable>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import {
SfScrollable,
SfButton,
SfIconChevronLeft,
SfIconChevronRight,
type SfScrollableOnDragEndData,
} from '@storefront-ui/vue';
import { unrefElement, useIntersectionObserver } from '@vueuse/core';
import { watch, type ComponentPublicInstance } from 'vue';
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' },
];
const thumbsRef = ref<HTMLElement>();
const firstThumbRef = ref<HTMLButtonElement>();
const lastThumbRef = ref<HTMLButtonElement>();
const firstThumbVisible = ref(false);
const lastThumbVisible = ref(false);
const activeIndex = ref(0);
watch(
thumbsRef,
(thumbsRef) => {
if (thumbsRef) {
useIntersectionObserver(
firstThumbRef,
([{ isIntersecting }]) => {
firstThumbVisible.value = isIntersecting;
},
{
root: unrefElement(thumbsRef),
rootMargin: '0px',
threshold: 1,
},
);
}
},
{ immediate: true },
);
watch(
thumbsRef,
(thumbsRef) => {
if (thumbsRef) {
useIntersectionObserver(
lastThumbRef,
([{ isIntersecting }]) => {
lastThumbVisible.value = isIntersecting;
},
{
root: unrefElement(thumbsRef),
rootMargin: '0px',
threshold: 1,
},
);
}
},
{ immediate: true },
);
const onDragged = (event: SfScrollableOnDragEndData) => {
if (event.swipeRight && activeIndex.value > 0) {
activeIndex.value -= 1;
} else if (event.swipeLeft && activeIndex.value < images.length - 1) {
activeIndex.value += 1;
}
};
const assignRef = (el: Element | ComponentPublicInstance | null, index: number) => {
if (!el) return;
if (index === images.length - 1) {
lastThumbRef.value = el as HTMLButtonElement;
} else if (index === 0) {
firstThumbRef.value = el as HTMLButtonElement;
}
};
</script>
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.
<template>
<div class="relative flex flex-col w-full max-h-[600px] aspect-[4/3]">
<SfScrollable
class="w-full h-full snap-x snap-mandatory [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
:active-index="activeIndex"
wrapper-class="h-full min-h-0"
buttons-placement="none"
:drag="{ containerWidth: true }"
is-active-index-centered
@on-drag-end="onDragged"
>
<div
v-for="({ imageSrc, alt }, index) in images"
:key="`${alt}-${index}`"
class="flex justify-center h-full basis-full shrink-0 grow snap-center snap-always"
>
<img :aria-label="alt" :aria-hidden="activeIndex !== index" class="w-auto h-full" :alt="alt" :src="imageSrc" />
</div>
</SfScrollable>
<SfScrollable
class="items-center w-full [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
:active-index="activeIndex"
buttons-placement="floating"
>
<template #previousButton="defaultProps">
<SfButton
v-bind="defaultProps"
class="absolute disabled:hidden !rounded-full z-10 left-4 bg-white"
variant="secondary"
size="sm"
square
>
<SfIconChevronLeft size="sm" />
</SfButton>
</template>
<button
v-for="({ imageThumbSrc, alt }, index) in images"
:key="`${alt}-${index}-thumbnail`"
type="button"
:aria-label="alt"
:aria-current="activeIndex === index"
:class="[
'md:w-14 md:h-auto relative shrink-0 pb-1 my-4 -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',
]"
@click="activeIndex = index"
>
<img :alt="alt" class="object-contain border border-neutral-200" width="78" height="78" :src="imageThumbSrc" />
</button>
<template #nextButton="defaultProps">
<SfButton
v-bind="defaultProps"
class="absolute disabled:hidden !rounded-full z-10 right-4 bg-white"
variant="secondary"
size="sm"
square
>
<SfIconChevronRight size="sm" />
</SfButton>
</template>
</SfScrollable>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import {
SfScrollable,
SfButton,
SfIconChevronLeft,
SfIconChevronRight,
type SfScrollableOnDragEndData,
} from '@storefront-ui/vue';
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' },
];
const activeIndex = ref(0);
const onDragged = (event: SfScrollableOnDragEndData) => {
if (event.swipeRight && activeIndex.value > 0) {
activeIndex.value -= 1;
} else if (event.swipeLeft && activeIndex.value < images.length - 1) {
activeIndex.value += 1;
}
};
</script>
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.
<template>
<div class="relative max-h-[600px] flex flex-col w-full aspect-[4/3] gap-1">
<SfScrollable
class="w-full h-full snap-x snap-mandatory [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
:active-index="activeIndex"
wrapper-class="h-full group/scrollable"
is-active-index-centered
:previous-disabled="activeIndex === 0"
:next-disabled="activeIndex === images.length - 1"
buttons-placement="block"
@on-prev="activeIndex -= 1"
@on-next="activeIndex += 1"
>
<template #previousButton="defaultProps">
<SfButton
v-bind="defaultProps"
:disabled="activeIndex === 0"
class="absolute hidden group-hover/scrollable:block disabled:!hidden !rounded-full !p-3 z-10 top-1/2 left-4 bg-white"
variant="secondary"
size="lg"
square
>
<SfIconChevronLeft />
</SfButton>
</template>
<div
v-for="({ imageSrc, alt }, index) in images"
:key="`${alt}-${index}`"
class="relative flex justify-center basis-full snap-center snap-always shrink-0 grow"
>
<img
class="object-cover w-auto h-full"
:aria-label="alt"
:aria-hidden="activeIndex !== index"
:alt="alt"
:src="imageSrc"
draggable="false"
/>
</div>
<template #nextButton="defaultProps">
<SfButton
v-bind="defaultProps"
:disabled="activeIndex === images.length - 1"
class="absolute hidden group-hover/scrollable:block disabled:!hidden !rounded-full !p-3 z-10 top-1/2 right-4 bg-white"
variant="secondary"
size="lg"
square
>
<SfIconChevronRight />
</SfButton>
</template>
</SfScrollable>
<div class="flex-shrink-0 basis-auto">
<div
class="flex-row w-full flex gap-0.5 mt [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
>
<button
v-for="({ alt }, index) in images"
:key="`${index}-bullet`"
:aria-current="activeIndex === index"
:aria-label="alt"
:class="[
'w-full relative mt-1 border-b-4 transition-colors focus-visible:outline focus-visible:outline-offset-0 pointer-events-none',
activeIndex === index ? 'border-primary-700' : 'border-gray-200',
]"
@click="activeIndex = index"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { SfScrollable, SfButton, SfIconChevronLeft, SfIconChevronRight } from '@storefront-ui/vue';
import { ref } from 'vue';
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' },
];
const activeIndex = ref(0);
</script>
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.
<template>
<div class="relative flex flex-col w-full max-h-[600px] aspect-[4/3]">
<SfScrollable
class="w-full h-full snap-x snap-mandatory [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
:active-index="activeIndex"
wrapper-class="h-full min-h-0"
buttons-placement="none"
:drag="{ containerWidth: true }"
is-active-index-centered
@on-drag-end="onDragged"
>
<div
v-for="({ imageSrc, alt }, index) in images"
:key="`${alt}-${index}`"
class="flex justify-center h-full basis-full shrink-0 grow snap-center"
>
<img :aria-label="alt" :aria-hidden="activeIndex !== index" class="w-auto h-full" :alt="alt" :src="imageSrc" />
</div>
</SfScrollable>
<SfScrollable
class="items-center w-full [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
:active-index="activeIndex"
buttons-placement="floating"
>
<template #previousButton="defaultProps">
<SfButton
v-bind="defaultProps"
class="absolute disabled:hidden !rounded-full z-10 left-4 bg-white"
variant="secondary"
size="sm"
square
>
<SfIconChevronLeft size="sm" />
</SfButton>
</template>
<button
v-for="({ imageThumbSrc, alt }, index) in images"
:key="`${alt}-${index}-thumbnail`"
type="button"
:aria-label="alt"
:aria-current="activeIndex === index"
:class="[
'md:w-14 md:h-auto relative shrink-0 pb-1 my-4 -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',
]"
@keydown.right.prevent="activeArrowNavigation($event, index)"
@keydown.up.prevent="activeArrowNavigation($event, index)"
@keydown.left.prevent="activeArrowNavigation($event, index)"
@keydown.down.prevent="activeArrowNavigation($event, index)"
@click="activeIndex = index"
>
<img :alt="alt" class="object-contain border border-neutral-200" width="78" height="78" :src="imageThumbSrc" />
</button>
<template #nextButton="defaultProps">
<SfButton
v-bind="defaultProps"
class="absolute disabled:hidden !rounded-full z-10 right-4 bg-white"
variant="secondary"
size="sm"
square
>
<SfIconChevronRight size="sm" />
</SfButton>
</template>
</SfScrollable>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import {
SfScrollable,
SfButton,
SfIconChevronLeft,
SfIconChevronRight,
type SfScrollableOnDragEndData,
} from '@storefront-ui/vue';
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' },
];
const activeIndex = ref(0);
const onDragged = (event: SfScrollableOnDragEndData) => {
if (event.swipeRight && activeIndex.value > 0) {
activeIndex.value -= 1;
} else if (event.swipeLeft && activeIndex.value < images.length - 1) {
activeIndex.value += 1;
}
};
const activeArrowNavigation = ($event: KeyboardEvent, index: number) => {
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) {
activeIndex.value = index + 1;
nextElement.focus();
} else if (($event.code === 'ArrowLeft' || $event.code === 'ArrowDown') && index > 0) {
activeIndex.value = index - 1;
previousElement.focus();
}
};
</script>