diff --git a/blocks/gallery/gallery.css b/blocks/gallery/gallery.css
new file mode 100644
index 0000000..5d67d19
--- /dev/null
+++ b/blocks/gallery/gallery.css
@@ -0,0 +1,257 @@
+.gallery {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-s);
+}
+
+.gallery .slides-wrapper {
+ position: relative;
+ min-width: 0;
+}
+
+.gallery .slides {
+ list-style: none;
+ display: flex;
+ overflow: scroll clip;
+ margin: 0;
+ padding: 0;
+ scroll-behavior: smooth;
+ scroll-snap-type: x mandatory;
+}
+
+.gallery .slides::-webkit-scrollbar {
+ display: none;
+}
+
+.gallery .slide {
+ flex: 0 0 100%;
+ width: 100%;
+ scroll-snap-align: start;
+}
+
+.gallery .slide figure {
+ margin: 0;
+}
+
+@media (width >= 900px) {
+ .gallery {
+ flex-direction: row;
+ align-items: stretch;
+ }
+
+ .gallery .slides-wrapper {
+ flex: 1;
+ }
+}
+
+.gallery .media-wrapper {
+ position: relative;
+ overflow: hidden;
+ background-color: var(--color-bg-light);
+ aspect-ratio: 16 / 9;
+}
+
+.gallery .media-wrapper picture {
+ position: absolute;
+ inset: 0;
+}
+
+.gallery .media-wrapper img {
+ height: 100%;
+ width: 100%;
+ object-fit: contain;
+}
+
+.gallery .media-wrapper .video-embed {
+ height: 100%;
+ width: 100%;
+}
+
+.gallery .media-wrapper .video-embed iframe {
+ height: 100%;
+ width: 100%;
+ border: none;
+}
+
+.gallery .media-wrapper .video-embed .placeholder {
+ position: absolute;
+ inset: 0;
+ margin: 0;
+ cursor: pointer;
+}
+
+.gallery .media-wrapper .video-embed .placeholder img {
+ height: 100%;
+ width: 100%;
+ object-fit: cover;
+}
+
+.gallery .media-wrapper .video-embed .placeholder .icon {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ height: 80px;
+ width: 80px;
+ color: var(--color-white);
+ filter: drop-shadow(0 1px 5px rgb(0 0 0 / 60%));
+ transform: translate(-50%, -50%);
+ pointer-events: none;
+}
+
+.gallery .body-wrapper {
+ padding: var(--space-s) 0;
+}
+
+.gallery .body-wrapper .count {
+ font-weight: bold;
+}
+
+/* controls */
+
+.gallery .controls {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ position: absolute;
+ inset-block-start: 0;
+ inset-inline: 0;
+ z-index: 3;
+ aspect-ratio: 16 / 9;
+ pointer-events: none;
+}
+
+.gallery .controls .button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 44px;
+ width: 44px;
+ margin: var(--space-xs);
+ border: 1px solid var(--color-border);
+ border-radius: 50%;
+ padding: 0;
+ background-color: var(--color-bg);
+ color: var(--color-text);
+ opacity: 0.6;
+ font-size: var(--heading-m);
+ cursor: pointer;
+ pointer-events: auto;
+ transition: background-color 0.2s, color 0.2s, opacity 0.2s;
+}
+
+.gallery .controls .button[hidden] {
+ visibility: hidden;
+}
+
+.gallery .controls .button:hover,
+.gallery .controls .button:focus-visible {
+ background-color: var(--color-brand);
+ color: var(--color-bg);
+ opacity: 1;
+}
+
+/* thumbnails */
+
+.gallery .thumbnails {
+ order: -1;
+}
+
+.gallery .thumbnails ol {
+ list-style: none;
+ display: flex;
+ flex-direction: row;
+ gap: var(--space-s);
+ overflow-x: auto;
+ margin: 0;
+ padding: 0;
+ scrollbar-color: var(--color-border) var(--color-bg);
+}
+
+.gallery .thumbnails ol::-webkit-scrollbar {
+ height: 5px;
+ width: 5px;
+}
+
+.gallery .thumbnails ol::-webkit-scrollbar-track {
+ background-color: var(--color-bg);
+}
+
+.gallery .thumbnails ol::-webkit-scrollbar-thumb {
+ background-color: var(--color-border);
+}
+
+.gallery .thumbnail {
+ display: block;
+ margin: 0;
+ border: 0;
+ padding: 0;
+ background: none;
+ cursor: pointer;
+}
+
+.gallery .thumbnail::after {
+ content: '';
+ display: block;
+ height: 5px;
+ margin-top: var(--space-100);
+ background-color: transparent;
+ transition: background-color 0.2s;
+}
+
+.gallery .thumbnail[aria-current="true"]::after {
+ background-color: var(--color-brand);
+}
+
+.gallery .thumbnail picture,
+/* stylelint-disable-next-line no-descending-specificity */
+.gallery .thumbnail img {
+ display: block;
+ height: auto;
+ width: 100px;
+ aspect-ratio: 16 / 9;
+ object-fit: cover;
+}
+
+.gallery .thumbnail picture {
+ filter: brightness(0.6);
+ transition: filter 0.2s;
+}
+
+.gallery .thumbnail:hover picture,
+.gallery .thumbnail:focus-visible picture,
+.gallery .thumbnail[aria-current="true"] picture {
+ filter: brightness(100%);
+}
+
+@media (width >= 900px) {
+ .gallery .thumbnails {
+ flex-shrink: 0;
+ order: 1;
+ position: relative;
+ width: 170px;
+ }
+
+ .gallery .thumbnails ol {
+ flex-direction: column;
+ position: absolute;
+ inset: 0;
+ overflow: visible auto;
+ padding-bottom: calc(5px + var(--space-100));
+ }
+
+ .gallery .thumbnail {
+ position: relative;
+ width: 100%;
+ }
+
+ .gallery .thumbnail::after {
+ position: absolute;
+ top: 100%;
+ width: 100%;
+ }
+
+ .gallery .thumbnail picture,
+ .gallery .thumbnail img {
+ width: 100%;
+ }
+}
diff --git a/blocks/gallery/gallery.js b/blocks/gallery/gallery.js
new file mode 100644
index 0000000..5f2d3fb
--- /dev/null
+++ b/blocks/gallery/gallery.js
@@ -0,0 +1,312 @@
+import { isYouTubeHref, loadCopy } from '../../scripts/scripts.js';
+import { createOptimizedPicture, decorateIcons } from '../../scripts/aem.js';
+import { createYouTubeEmbed, createPlaceholder } from '../video/video.js';
+
+/**
+ * Returns `true` if a column child is a paragraph containing a single YouTube link.
+ * @param {HTMLElement} el - Column child element to test
+ * @returns {boolean}
+ */
+function isVideoLink(el) {
+ if (el.tagName !== 'P' || el.children.length !== 1) return false;
+ const a = el.firstElementChild;
+ return a.tagName === 'A' && isYouTubeHref(a.href);
+}
+
+/**
+ * Replaces a media column's YouTube link with a lazy-loaded embed behind a poster placeholder.
+ * @param {HTMLElement} col - Media column containing the link
+ * @param {string} href - YouTube URL to embed
+ */
+function decorateVideo(col, href) {
+ const embed = createYouTubeEmbed(href);
+ if (!embed) return;
+
+ const container = document.createElement('div');
+ container.className = 'video-embed';
+
+ const picture = col.querySelector('picture');
+ const placeholder = createPlaceholder(picture, () => {
+ const src = new URL(embed.src);
+ src.searchParams.set('autoplay', 1);
+ embed.src = src.href;
+ if (!embed.isConnected) container.append(embed);
+ });
+
+ if (placeholder) container.append(placeholder);
+ col.replaceChildren(container);
+
+ const observer = new IntersectionObserver((entries) => {
+ entries.forEach((entry) => {
+ if (!entry.isIntersecting) return;
+ container.append(embed);
+ observer.disconnect();
+ });
+ }, { threshold: 1 });
+ observer.observe(container);
+}
+
+/**
+ * Finds a YouTube link in a media column and converts it to a video embed.
+ * @param {HTMLElement} col - Media column to inspect and potentially transform
+ */
+function transformVideoLinks(col) {
+ const videoLink = [...col.children].find(isVideoLink);
+ if (videoLink) decorateVideo(col, videoLink.firstElementChild.href);
+}
+
+/**
+ * Builds a slide's media figure and caption from the row's media/caption columns.
+ * @param {HTMLElement} row - Authored row div with one or two column divs
+ * @param {HTMLElement} slide - Slide list item to populate
+ * @param {number} slideIndex - Zero-based index of this slide
+ * @param {number} total - Total number of slides in the gallery
+ * @param {Object} copy - Localized UI strings
+ */
+function layoutGallerySlide(row, slide, slideIndex, total, copy) {
+ const [mediaCol, captionCol] = row.querySelectorAll(':scope > div');
+ if (!mediaCol) return;
+
+ transformVideoLinks(mediaCol);
+ mediaCol.className = 'media-wrapper';
+
+ const figure = document.createElement('figure');
+ figure.append(mediaCol);
+ slide.append(figure);
+
+ const hasCaption = captionCol && captionCol.textContent.trim();
+ if (total > 1 || hasCaption) {
+ const figcaption = document.createElement('figcaption');
+ figcaption.className = 'body-wrapper';
+ if (total > 1) {
+ const count = document.createElement('p');
+ count.className = 'count';
+ count.textContent = `${slideIndex + 1} ${copy.of || 'of'} ${total}`;
+ figcaption.append(count);
+ }
+ if (hasCaption) figcaption.append(...captionCol.children);
+ figure.append(figcaption);
+ }
+}
+
+/**
+ * Returns the source image src/alt to build a slide's thumbnail from, or `null` if none.
+ * @param {HTMLElement} slide - Slide list item
+ * @returns {{src: string, alt: string}|null}
+ */
+function getSlideThumbnail(slide) {
+ const img = slide.querySelector('.media-wrapper img');
+ if (!img || !img.src) return null;
+ return { src: img.src, alt: img.alt || '' };
+}
+
+/**
+ * Creates a low-res thumbnail button for a slide, skipping slides with no usable image.
+ * @param {HTMLElement} slide - Slide the thumbnail navigates to
+ * @param {number} i - Zero-based index of the slide
+ * @param {number} total - Total number of slides in the gallery
+ * @param {Object} copy - Localized UI strings
+ * @param {HTMLOListElement} thumbList - List to append the thumbnail item to
+ */
+function createThumbnail(slide, i, total, copy, thumbList) {
+ const thumb = getSlideThumbnail(slide);
+ if (!thumb) return;
+
+ const item = document.createElement('li');
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'thumbnail';
+ button.dataset.targetSlide = i;
+ button.setAttribute('aria-label', `${copy.showSlide || 'Show Slide'} ${i + 1} ${copy.of || 'of'} ${total}`);
+ button.append(createOptimizedPicture(thumb.src, thumb.alt, false, [{ width: '150' }]));
+ item.append(button);
+ thumbList.append(item);
+}
+
+/**
+ * Creates a slide list item and lays out its content from the authored row.
+ * @param {HTMLElement} row - Authored row div for this slide
+ * @param {number} slideIndex - Zero-based index of this slide
+ * @param {number} total - Total number of slides in the gallery
+ * @param {Object} copy - Localized UI strings
+ * @param {number} id - Gallery instance id, used to build a unique slide id
+ * @returns {HTMLLIElement}
+ */
+function createSlide(row, slideIndex, total, copy, id) {
+ const slide = document.createElement('li');
+ slide.dataset.slideIndex = slideIndex;
+ slide.setAttribute('id', `gallery-${id}-slide-${slideIndex}`);
+ slide.classList.add('slide');
+ layoutGallerySlide(row, slide, slideIndex, total, copy);
+ return slide;
+}
+
+/**
+ * Shows or hides the prev/next buttons based on how close the active slide is to either end.
+ * @param {HTMLElement} block - Gallery block element
+ * @param {number} slideIndex - Active slide index
+ */
+function updateNavigation(block, slideIndex) {
+ const slides = block.querySelectorAll('.slide');
+ const prev = block.querySelector('.controls .prev');
+ const next = block.querySelector('.controls .next');
+ if (!prev || !next) return;
+
+ prev.hidden = slideIndex <= 0;
+ next.hidden = slideIndex >= slides.length - 1;
+}
+
+/**
+ * Syncs aria-hidden, tabindex, and the active thumbnail to the given slide.
+ * @param {HTMLElement} slide - Slide to mark as active
+ */
+function updateActiveSlide(slide) {
+ const block = slide.closest('.gallery');
+ const slideIndex = parseInt(slide.dataset.slideIndex, 10);
+ block.dataset.activeSlide = slideIndex;
+
+ block.querySelectorAll('.slide').forEach((aSlide, i) => {
+ const hidden = i !== slideIndex;
+ aSlide.setAttribute('aria-hidden', hidden);
+ aSlide.querySelectorAll('a[href], iframe').forEach((el) => {
+ if (hidden) el.setAttribute('tabindex', '-1');
+ else el.removeAttribute('tabindex');
+ });
+ aSlide.querySelectorAll('[role="button"]').forEach((el) => {
+ el.setAttribute('tabindex', hidden ? '-1' : '0');
+ });
+ });
+
+ block.querySelectorAll('.thumbnail').forEach((button, i) => {
+ if (i === slideIndex) button.setAttribute('aria-current', 'true');
+ else button.removeAttribute('aria-current');
+ });
+
+ updateNavigation(block, slideIndex);
+}
+
+/**
+ * Whether the user has requested reduced motion.
+ * @returns {boolean}
+ */
+function prefersReducedMotion() {
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+}
+
+/**
+ * Scrolls to a target slide index and syncs all active state.
+ * @param {HTMLElement} block - Gallery block element
+ * @param {number} slideIndex - Target slide index (clamped to valid range)
+ */
+function showSlide(block, slideIndex = 0) {
+ const slides = block.querySelectorAll('.slide');
+ const realSlideIndex = Math.max(0, Math.min(slideIndex, slides.length - 1));
+ const activeSlide = slides[realSlideIndex];
+
+ updateActiveSlide(activeSlide);
+ block.querySelector('.slides').scrollTo({
+ top: 0,
+ left: activeSlide.offsetLeft,
+ behavior: prefersReducedMotion() ? 'auto' : 'smooth',
+ });
+}
+
+/**
+ * Attaches click handlers to thumbnails/arrows and an IntersectionObserver to keep
+ * the active slide in sync.
+ * @param {HTMLElement} block - Gallery block element
+ */
+function bindEvents(block) {
+ block.querySelectorAll('.thumbnail').forEach((button) => {
+ button.addEventListener('click', (e) => {
+ showSlide(block, parseInt(e.currentTarget.dataset.targetSlide, 10));
+ });
+ });
+
+ const prev = block.querySelector('.controls .prev');
+ const next = block.querySelector('.controls .next');
+ if (prev) {
+ prev.addEventListener('click', () => {
+ const i = parseInt(block.dataset.activeSlide, 10);
+ if (i > 0) showSlide(block, i - 1);
+ });
+ }
+ if (next) {
+ next.addEventListener('click', () => {
+ const i = parseInt(block.dataset.activeSlide, 10);
+ const slides = block.querySelectorAll('.slide');
+ if (i < slides.length - 1) showSlide(block, i + 1);
+ });
+ }
+
+ const slideObserver = new IntersectionObserver((entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) updateActiveSlide(entry.target);
+ });
+ }, { threshold: 0.5 });
+ block.querySelectorAll('.slide').forEach((slide) => slideObserver.observe(slide));
+}
+
+let galleryId = 0;
+
+/**
+ * Builds the gallery: media slides, thumbnail nav, arrow controls, and behavior.
+ * @param {HTMLElement} block - Gallery block element
+ */
+export default async function decorate(block) {
+ galleryId += 1;
+ block.setAttribute('id', `gallery-${galleryId}`);
+ const rows = block.querySelectorAll(':scope > div');
+ const isSingleSlide = rows.length < 2;
+
+ const copy = await loadCopy(import.meta.url);
+ block.setAttribute('role', 'region');
+ block.setAttribute('aria-roledescription', copy.gallery || 'Gallery');
+
+ const slidesWrapper = document.createElement('div');
+ slidesWrapper.className = 'slides-wrapper';
+
+ const slidesList = document.createElement('ul');
+ slidesList.className = 'slides';
+
+ let thumbList;
+ rows.forEach((row, i) => {
+ const slide = createSlide(row, i, rows.length, copy, galleryId);
+ slidesList.append(slide);
+ if (!isSingleSlide) {
+ if (!thumbList) thumbList = document.createElement('ol');
+ createThumbnail(slide, i, rows.length, copy, thumbList);
+ }
+ if (row.parentElement === block) row.remove();
+ });
+
+ slidesWrapper.append(slidesList);
+
+ if (!isSingleSlide) {
+ const controls = document.createElement('nav');
+ controls.className = 'controls';
+ controls.innerHTML = `
+
+
+ `;
+ decorateIcons(controls);
+ slidesWrapper.append(controls);
+ }
+
+ block.append(slidesWrapper);
+
+ if (!isSingleSlide) {
+ const thumbNav = document.createElement('nav');
+ thumbNav.className = 'thumbnails';
+ thumbNav.setAttribute('aria-label', copy.gallerySlideControls || 'Gallery Slide Controls');
+ thumbNav.append(thumbList);
+ block.append(thumbNav);
+
+ bindEvents(block);
+ updateActiveSlide(slidesList.querySelector('.slide'));
+ }
+}
diff --git a/blocks/gallery/gallery.json b/blocks/gallery/gallery.json
new file mode 100644
index 0000000..6c39d95
--- /dev/null
+++ b/blocks/gallery/gallery.json
@@ -0,0 +1,18 @@
+{
+ "en": {
+ "gallery": "Gallery",
+ "gallerySlideControls": "Gallery Slide Controls",
+ "previousSlide": "Previous Slide",
+ "nextSlide": "Next Slide",
+ "showSlide": "Show Slide",
+ "of": "of"
+ },
+ "fr": {
+ "gallery": "Galerie",
+ "gallerySlideControls": "Contrôles de la galerie",
+ "previousSlide": "Diapositive précédente",
+ "nextSlide": "Diapositive suivante",
+ "showSlide": "Afficher la diapositive",
+ "of": "sur"
+ }
+}