Ecom Dynamic Gallery
Self-contained WordPress module that renders a photo gallery: a main image plus a horizontal-scrolling thumbnail row, a custom scrollbar with arrows, mouse drag-to-scroll on desktop, and swipe gestures on mobile. Single shortcode, no DB, no admin UI. Vanilla JS, no jQuery.
Install
This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/josipmestrovic/ecom-dynamic-gallery/archive/refs/heads/main.zipSelf-contained WordPress module that renders a photo gallery: a main image plus a horizontal-scrolling thumbnail row, a custom scrollbar with arrows, mouse drag-to-scroll on desktop, and swipe gestures on mobile. Single shortcode, no DB, no admin UI. Vanilla JS, no jQuery.
Public surface: [ecom_gallery].
Distributed under the MIT License.

Features
- Main image + horizontal thumbnail row (capped at 16 images).
- Click a thumbnail → main image fades, swaps, fades back in.
- Full-screen lightbox viewer — click (or Enter/Space on) the main image to open. Prev/next buttons, ←/→/Home/End keys, ESC to close, click-backdrop to close, focus trap, body scroll lock, touch swipe, and an image counter ("3 / 12").
- Responsive image sources — every
<img>ships withsrcsetandsizes, so mobile clients download a right-sized variant instead of the fulllargesource. - Intrinsic
width/heighton every<img>— eliminates Cumulative Layout Shift on first paint. - Keyboard navigation across thumbnails — roving tabindex (one tab
stop),
←/→move between thumbs,Home/Endjump to first/last,Enter/Spaceactivate. - Custom scrollbar under the thumbnails with prev/next arrows, a draggable thumb, and click-to-page behavior.
- Mouse drag-to-scroll on the thumbnail row (desktop / pen).
- Touch swipe on the main image (mobile / pen) → previous / next.
- Lazy-loaded thumbnails; main image marked
fetchpriority="high". - ARIA live region announces the new image's alt text on swap.
- Honors
prefers-reduced-motion(no fade, no smooth scroll, no lightbox open/close transition). - Two render modes: ACF-driven (default) or explicit attachment IDs (any page).
Installation
Option 1 — As a (child) theme module
Clone or copy the folder into your theme:
cd wp-content/themes/<your-theme>
git clone https://github.com/josipmestrovic/ecom-dynamic-gallery.git dynamic-gallery
Then add this line to your theme's functions.php:
require_once get_stylesheet_directory() . '/dynamic-gallery/dynamic-gallery.php';
Option 2 — As a must-use plugin
Copy the folder into wp-content/mu-plugins/dynamic-gallery/, then add
a one-line loader at wp-content/mu-plugins/load-dynamic-gallery.php:
<?php
require_once WPMU_PLUGIN_DIR . '/dynamic-gallery/dynamic-gallery.php';
Option 3 — Composer
composer require josipmestrovic/ecom-dynamic-gallery
No DB, no admin UI, no AJAX, no options.
Usage
ACF mode (default)
[ecom_gallery]
[ecom_gallery post_id="42"]
Without post_id, the shortcode resolves the current post — including
Divi Theme Builder templates, where it falls back to
get_queried_object_id() so the gallery shows images from the post
being viewed (not from the template post).
Reusable mode — any page (About Us, landing pages, etc.)
Pass a comma-separated list of attachment IDs. ACF is not consulted
and post_id is ignored:
[ecom_gallery images="123,124,125,126"]
[ecom_gallery images="123,124,125,126" featured="125"]
| Attribute | Purpose |
|---|---|
post_id |
ACF mode only. Resolves a specific post; ignored when images is set. |
images |
Comma-separated attachment IDs, in display order. Capped at 16. Invalid IDs are silently dropped. |
featured |
Optional. Attachment ID promoted to the main image slot. Must appear in images; otherwise the first valid ID wins. |
Get the attachment ID from the Media Library (URL bar: ?item=123 or
the File URL field in the attachment details panel).
Rendered markup
Skeleton of what the shortcode prints. Class names and ARIA attributes are stable hooks — themers can rely on them.
<div class="tg-gallery">
<div class="tg-main-image"
role="region"
aria-roledescription="Image gallery main view"
aria-live="polite"
aria-atomic="true">
<!-- JS upgrades this wrapper to role="button" tabindex="0" so
it opens the lightbox via click / Enter / Space. -->
<img id="tg-main-img"
src="…"
srcset="… 480w, … 1024w"
sizes="(max-width: 800px) 100vw, 800px"
width="1024" height="683"
alt="…" fetchpriority="high">
</div>
<div class="tg-thumbnails">
<button class="tg-thumb tg-thumb--active"
type="button"
tabindex="0"
aria-pressed="true"
aria-label="…"
data-large="…"
data-large-srcset="…"
data-large-sizes="…"
data-large-w="1024" data-large-h="683"
data-full="…"
data-alt="…">
<img src="…"
srcset="…" sizes="(max-width: 767px) 90px, 120px"
width="300" height="200"
alt="…" loading="lazy" decoding="async">
</button>
<!-- … more .tg-thumb buttons (tabindex="-1" until activated) … -->
</div>
<div class="tg-scrollbar" aria-hidden="true">
<button class="tg-scrollbar__arrow tg-scrollbar__arrow--prev …">…</button>
<div class="tg-scrollbar__track">
<div class="tg-scrollbar__thumb"></div>
</div>
<button class="tg-scrollbar__arrow tg-scrollbar__arrow--next">…</button>
</div>
</div>
The thumbnail row + scrollbar are only rendered when there is more than one image to show.
Behavior
| Surface | Trigger | Notes |
|---|---|---|
| Thumbnail click | mouse / keyboard / touch | Sets tg-thumb--active and aria-pressed="true", scrolls itself into view, fades the main image, swaps src + srcset + sizes + width/height + alt on transitionend. Rapid clicks cancel via a swapToken so only the latest swap lands. |
| Thumbnail row keyboard | ← / → / Home / End |
Roving tabindex: only the active thumb is in the tab order; arrow keys move focus and activate the next/previous thumb. Enter/Space activate the focused thumb (native <button> behavior). |
| Thumb row drag | left-mouse / pen | After 5px movement, .tg-thumbnails enters drag mode (grabbing cursor); the synthetic post-drag click is suppressed so the underlying thumb doesn't activate. Touch is skipped — native swipe handles it. |
| Scrollbar arrows | click | Scroll one thumb width + gap; disabled at scroll start / end. aria-hidden, tabindex="-1" — pointer-only by design. |
| Scrollbar thumb | drag | Pointer-Events with pointer capture; scrollLeft follows the thumb position. |
| Scrollbar track | click outside thumb | Pages by ~80% of the visible thumbnail row. |
| Main-image swipe | touch / pen | ≥40px horizontal-dominant move within 600ms → activates previous / next thumbnail. |
| Main-image tap / click | mouse / touch / pen | Non-swipe pointer release (movement <6px) → opens the lightbox at the active image. |
| Main-image keyboard | Enter / Space while focused |
Opens the lightbox. The wrapper is upgraded to tabindex="0" role="button" by JS. |
| Lightbox controls | click / keyboard / touch | Prev/next buttons; ←/→ cycle, Home/End jump, Esc closes, click on backdrop closes; touch swipe (≥40px / <600ms) cycles. Focus is trapped inside; body scroll is locked while open; focus is restored to the trigger on close. |
Accessibility
Implemented:
- Active thumbnail exposes
aria-pressed="true|false"(in addition to the.tg-thumb--activeclass). - Roving tabindex on the thumbnail row. Only the active thumb has
tabindex="0"; the rest aretabindex="-1".←/→move focus + activate,Home/Endjump to first/last,Enter/Spaceactivate. Tab leaves the row in a single hop. - Lightbox dialog.
role="dialog"+aria-modal="true", focus is trapped inside while open, focus moves to the close button on open, and is restored to the triggering element on close.Esccloses. Body scroll is locked (overflow: hidden) while open. Counter is a polite live region. - The main image wrapper is keyboard-activatable
(
tabindex="0"role="button") so non-mouse users can open the lightbox withEnter/Space. .tg-main-imageis a polite live region witharia-roledescription="Image gallery main view"; the<img>altis updated on swap, so screen readers announce the new image.- Alt-text input is escaped with
esc_attr()everywhere it lands in an HTML attribute. prefers-reduced-motion: reducedisables the fade transition on the main image, the lightbox open/close transition, and forcesbehavior: 'auto'forscrollIntoViewand the custom-scrollbar paging.- Focus-visible outline on
.tg-thumband.tg-main-imageuses the brand color#1C4168; lightbox controls use a white outline. - Custom scrollbar arrows are
aria-hidden="true"andtabindex="-1"so they don't add noise to the tab order or the screen-reader tree.
Not implemented (intentional, may be added later):
- No keyboard-exposed scrollbar arrows /
role="scrollbar"ARIA on the custom track (A1's roving tabindex already gives keyboard users a way to traverse the gallery).
Performance
- Each thumbnail
<img>is rendered withloading="lazy"anddecoding="async". Off-screen thumbs are not requested until the row scrolls them near the viewport. - The main
<img>is rendered withfetchpriority="high"so it is prioritized as a likely LCP candidate. - Responsive sources. Every
<img>ships withsrcset(built viawp_get_attachment_image_srcset) and asizeshint that matches the responsive CSS —(max-width: 800px) 100vw, 800pxfor the main image,(max-width: 767px) 90px, 120pxfor thumbnails. Mobile and low-DPR clients download a smaller variant instead of the fulllargesource. - Intrinsic dimensions. Every
<img>carrieswidthandheightattributes fromwp_get_attachment_image_src, so the browser can reserve aspect-ratio space and Cumulative Layout Shift stays at zero on first paint. Dimensions are also re-applied on swap. - Galleries are capped at 16 images in both ACF mode and reusable
mode (
array_slice( …, 0, 16 )inincludes/helpers.php). - The custom scrollbar updates on the native
scrollevent (passive listener) — norequestAnimationFrameloop is running while idle. - The lightbox markup is built lazily on first open and reused for subsequent opens; the per-gallery overhead is one DOM node.
Responsive behavior
| Viewport | Main image height | Thumbnail width | Custom scrollbar |
|---|---|---|---|
| ≥ 1024px (desktop) | 481px | 120px | Visible |
| 768–1023px (tablet) | 360px | 120px | Visible |
| ≤ 767px (mobile) | 240px | 90px | Visible (same as desktop) |
The main image radius softens to 12px on mobile.
ACF fields consumed
(ACF mode only.)
| Field | Type | Required | Notes |
|---|---|---|---|
featured_image |
Image (Array return format) | Yes | Main image. Must yield at least a large size or a url, otherwise the shortcode renders nothing. |
gallery |
Gallery | No | Capped at 16 images. Featured image is auto-deduplicated if present here too. Plain attachment IDs are also accepted (for the "Image ID" return format). |
Alt text is read from each attachment's WordPress alt field and escaped before being placed in HTML attributes.
File structure
dynamic-gallery/
├── README.md ← you are here
├── LICENSE ← MIT
├── composer.json
├── dynamic-gallery.php ← thin bootstrap, requires every includes/*.php
├── dynamic-gallery.css ← gallery styles
├── dynamic-gallery.js ← swap logic, custom scrollbar, drag, swipe, lightbox
└── includes/
├── assets.php ← register CSS/JS with filemtime cache-buster
├── helpers.php ← post-id resolver + image collectors (ACF + IDs)
└── shortcode-gallery.php ← [ecom_gallery] render callback
Public surface
| Surface | Where | Notes |
|---|---|---|
[ecom_gallery] |
includes/shortcode-gallery.php |
Optional post_id, images, featured attributes. |
Constants ECOM_DG_DIR / ECOM_DG_VERSION |
dynamic-gallery.php |
Module paths / version. |
Style handle ecom-dynamic-gallery-css |
includes/assets.php |
Registered, enqueued by the shortcode. |
Script handle ecom-dynamic-gallery-js |
includes/assets.php |
Registered, enqueued by the shortcode. |
Filter ecom_dg_base_url |
includes/assets.php |
Override the public URL the module is served from. Useful only if your install lives outside wp-content/. |
Public PHP functions
| Function | File | Purpose |
|---|---|---|
ecom_dg_enqueue_assets() |
includes/assets.php |
Hooked to wp_enqueue_scripts; registers CSS/JS only. |
ecom_dg_resolve_post_id( $atts_post_id ) |
includes/helpers.php |
Resolves the target post ID (Theme-Builder safe). |
ecom_dg_collect_images( $post_id ) |
includes/helpers.php |
Pure helper: returns featured + gallery image data from ACF, or null. |
ecom_dg_collect_images_from_ids( $ids, $featured_id ) |
includes/helpers.php |
Pure helper: same return shape, but built from an explicit list of attachment IDs. |
ecom_dg_shortcode( $atts ) |
includes/shortcode-gallery.php |
Shortcode render callback. |
CSS class hooks
| Class | Element | Notes |
|---|---|---|
.tg-gallery |
Outer wrapper | max-width: 800px. |
.tg-main-image |
Big image container | Live region; border-radius: 18px. |
.tg-thumbnails |
Thumbnail scroller | Horizontal flex row; native scrollbar hidden. |
.tg-thumbnails--dragging |
Modifier on .tg-thumbnails |
Set during desktop drag-to-scroll. |
.tg-thumb |
Each thumbnail button | Reserved 4.2px transparent border to prevent layout shift. |
.tg-thumb--active |
Modifier on .tg-thumb |
Border #1C4168 + inset highlight. |
.tg-scrollbar |
Custom scrollbar wrapper | Pill-shaped (border-radius: 100px). |
.tg-scrollbar__track |
Track | 8px tall. |
.tg-scrollbar__thumb |
Draggable thumb | #9B9EA5. |
.tg-scrollbar__arrow |
Prev / next button | 24×24 hit area over a 15px visual zone. |
.tg-scrollbar__arrow--prev / --next |
Direction modifier | Right arrow flips the SVG with scaleX(-1). |
.tg-scrollbar__arrow--disabled |
State | At scroll start / end. |
.tg-scrollbar__thumb--dragging |
State | While the user drags the thumb. |
.tg-scrollbar--hidden |
State | Set when there is no horizontal overflow. |
.tg-lightbox |
Lightbox root (appended to <body> on first open) |
role="dialog", aria-modal="true". Hidden until opened. |
.tg-lightbox--open |
Modifier on .tg-lightbox |
Triggers the fade-in transition. |
.tg-lightbox__backdrop |
Dimmed background | Click closes the lightbox. |
.tg-lightbox__stage |
Centered content area | Holds the image, controls, counter. |
.tg-lightbox__img |
Full-size image | object-fit: contain. |
.tg-lightbox__close |
Top-right close button | Receives initial focus on open. |
.tg-lightbox__nav |
Prev/next button | [disabled] at first/last image. |
.tg-lightbox__nav--prev / --next |
Direction modifier | |
.tg-lightbox__counter |
"3 / 12" indicator | Polite live region. |
body.tg-lightbox-open |
On <body> while a lightbox is open |
Sets overflow: hidden to lock page scroll. |
Styling tokens
Quick reference for the values currently hard-coded in
dynamic-gallery.css. Edit there to retheme.
| Token | Value | Where it lives |
|---|---|---|
| Active thumb border | 4.2px solid #1C4168 |
.tg-thumb (transparent) + .tg-thumb--active |
| Active thumb inner highlight | inset 0 0 0 1px rgba(255,255,255,0.6) |
.tg-thumb--active |
| Thumb hover shadow | 0 2px 6px rgba(0,0,0,0.08) |
.tg-thumb:not(.tg-thumb--active):hover |
| Main image radius | 18px desktop / 12px mobile |
.tg-main-image, .tg-main-image img |
| Thumb radius | 4.5px non-active / 5px active |
.tg-thumb, .tg-thumb--active |
| Scrollbar border | 1px solid #EFEFEF, radius 100px |
.tg-scrollbar |
| Scrollbar thumb | #9B9EA5, radius 5px |
.tg-scrollbar__thumb |
| Scrollbar arrow color | #9B9EA5 (in inline SVG <path fill>) |
includes/shortcode-gallery.php |
| Swipe distance / time | 40px / 600ms |
dynamic-gallery.js (SWIPE_DISTANCE, SWIPE_MAX_TIME) |
| Drag-to-scroll threshold | 5px |
dynamic-gallery.js (DRAG_THRESHOLD) |
How to extend
| Want to… | Edit |
|---|---|
| Reuse the gallery on a non-ACF page | Use Reusable mode above — paste attachment IDs into the images attribute. No code change. |
| Change the cap of 16 gallery images | includes/helpers.php — both array_slice( ..., 0, 16 ) calls (ACF + IDs paths). |
| Restyle the gallery | dynamic-gallery.css. |
| Change the swap / drag / swipe behavior | dynamic-gallery.js. |
| Change the markup or arrow SVG | includes/shortcode-gallery.php. |
| Tune main-image breakpoints | dynamic-gallery.css, @media (max-width: 1023px) and @media (max-width: 767px). |
| Add another shortcode (e.g. grid view) | Drop a new includes/shortcode-*.php file and require_once it from dynamic-gallery.php. |
Constraints
- One gallery per page. The main
<img>uses a hard-codedid="tg-main-img"; rendering two instances on the same page will collide. Multi-instance support would require switching to a scoped class lookup. - ACF dependency. ACF mode requires the Advanced Custom Fields
plugin and the
featured_image/galleryfield definitions. Reusable mode (withimages="…") does not. - Pointer Events required. The drag, swipe, and custom-scrollbar thumb drag all use the Pointer Events API. All evergreen browsers support it; legacy browsers will fall back to native scroll only.
- Mouse-click on the main image opens the lightbox. Clicks (no
drag) and
Enter/Spaceon the focused main image both open the full-screen viewer; touch/pen swipes still cycle the active image.
Browser support
Targets evergreen Chrome, Firefox, Edge, and Safari ≥ 15.4. Required
APIs: Pointer Events, CSS aspect-ratio, CSS scroll-behavior,
Element.scrollBy({ behavior }), requestAnimationFrame, and
MediaQueryList (for prefers-reduced-motion).