WP Manifestindependent plugin directory
manifest / media / ecom-dynamic-gallery

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.

★ 0stars

Install

Download source zip No release zip yet

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.zip
No built-in updater. New versions won't appear under Dashboard → Updates. Install new releases yourself, or let Git Updater track the repository.

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.

Public surface: [ecom_gallery].

Distributed under the MIT License.

Ecom Dynamic Gallery — front-end preview


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 with srcset and sizes, so mobile clients download a right-sized variant instead of the full large source.
  • Intrinsic width/height on every <img> — eliminates Cumulative Layout Shift on first paint.
  • Keyboard navigation across thumbnails — roving tabindex (one tab stop), ←/→ move between thumbs, Home/End jump to first/last, Enter/Space activate.
  • 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--active class).
  • Roving tabindex on the thumbnail row. Only the active thumb has tabindex="0"; the rest are tabindex="-1". ←/→ move focus + activate, Home/End jump to first/last, Enter/Space activate. 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. Esc closes. 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 with Enter/Space.
  • .tg-main-image is a polite live region with aria-roledescription="Image gallery main view"; the <img> alt is 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: reduce disables the fade transition on the main image, the lightbox open/close transition, and forces behavior: 'auto' for scrollIntoView and the custom-scrollbar paging.
  • Focus-visible outline on .tg-thumb and .tg-main-image uses the brand color #1C4168; lightbox controls use a white outline.
  • Custom scrollbar arrows are aria-hidden="true" and tabindex="-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 with loading="lazy" and decoding="async". Off-screen thumbs are not requested until the row scrolls them near the viewport.
  • The main <img> is rendered with fetchpriority="high" so it is prioritized as a likely LCP candidate.
  • Responsive sources. Every <img> ships with srcset (built via wp_get_attachment_image_srcset) and a sizes hint that matches the responsive CSS — (max-width: 800px) 100vw, 800px for the main image, (max-width: 767px) 90px, 120px for thumbnails. Mobile and low-DPR clients download a smaller variant instead of the full large source.
  • Intrinsic dimensions. Every <img> carries width and height attributes from wp_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 ) in includes/helpers.php).
  • The custom scrollbar updates on the native scroll event (passive listener) — no requestAnimationFrame loop 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-coded id="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 / gallery field definitions. Reusable mode (with images="…") 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/Space on 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).