Splide Thumbnail Gallery — Two Synced Sliders

Splide Thumbnail Gallery · Layouts · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

One-line bidirectional sync
main.sync(thumbs) replaces all manual index reconciliation.
Correct mount ordering
sync() before mount(), the rule that silently breaks syncing.
Thumbnails as real controls
isNavigation adds focus, click handling and ARIA roles.
is-active styling hook
Splide marks the current thumb so CSS can highlight it.
Layout-safe selection
Opacity and ring shadow instead of a border that shifts the strip.
Centered active thumb
focus: center keeps the current image findable in long sets.
Fixed thumbnail sizing
fixedWidth/fixedHeight with a mobile breakpoint, not perPage.
Scroll-friendly dragging
dragAngleThreshold 40 distinguishes swipes from page scrolls.

About this UI Snippet

Splide Thumbnail Gallery — How Slider Syncing Actually Works

Screenshot of the Splide Thumbnail Gallery snippet rendered live

Every product page needs the same thing: a large image, a row of thumbnails below it, and both staying in agreement no matter which one you touch. Hand-building it means writing two carousels and then a third layer of code to keep their indexes reconciled — and that reconciliation layer is where the bugs live. Click a thumbnail while the main slider is mid-transition, or drag past the loop boundary, and the two drift apart.

Splide solves this with a first-class API rather than a workaround, and it does it in one line.

sync(), and the ordering rule that breaks people

main.sync(thumbs);

That single call establishes a bidirectional relationship: moving the main slider moves the thumbnails, and clicking a thumbnail moves the main slider. There is no index bookkeeping to write and no event handlers to wire.

The critical detail is the order of the next three lines:

main.sync(thumbs); main.mount(); thumbs.mount();

sync() must be called before either slider is mounted. It works by subscribing to internal move events, and mount() is what fires the setup those subscriptions need to exist for. Call sync() after mounting and you get no error, no warning, and two completely independent sliders — which is a genuinely frustrating way to lose an hour, because everything looks correctly written.

isNavigation is doing more than it sounds

isNavigation: true on the thumbnail slider is what converts its slides from passive content into controls. It makes each slide focusable and clickable, adds the correct ARIA roles so the strip announces itself as a set of controls rather than a second gallery, and — the part the CSS depends on — adds an is-active class to whichever slide matches the main slider's current index.

That class is the entire styling hook:

.stg-thumbs .splide__slide.is-active .stg-thumb { opacity: 1; transform: translateY(-2px); box-shadow: 0 0 0 2px #0d9488 }

Inactive thumbnails sit at 45% opacity, hover lifts them to 80%, and the active one goes fully opaque with a ring and a small rise. Using opacity as the primary signal rather than a border is deliberate: borders change an element's box and can shift the strip by a pixel as selection moves.

isActiveOnFocus: true extends the same behavior to keyboard focus, so tabbing through the strip previews each image rather than requiring a click.

focus: center, and why it matters with many images

focus: 'center' keeps the active thumbnail in the middle of the visible strip rather than wherever it happens to fall. With five images it is a nicety. With twenty it is essential — without it, the active thumbnail eventually sits at the edge of the viewport or scrolls out of view entirely, and the user loses track of where they are in the set.

fixedWidth and fixedHeight are the right sizing choice for thumbnails specifically. perPage would divide the available width by a slide count, so thumbnails would resize as the viewport changes; fixed dimensions keep them a consistent, predictable size and simply show more or fewer as space allows. The breakpoints block shrinks them below 480px so a phone still shows several rather than two enormous ones.

The main slider's settings

type: 'loop' lets the gallery wrap from last to first, which is expected behavior in a product viewer. Splide handles the slide cloning internally, and sync() correctly maps cloned indexes back to real ones — one of the specific things that is painful to get right by hand.

dragAngleThreshold: 40 is a mobile detail worth knowing. It sets how many degrees off horizontal a drag can be before Splide ignores it and lets the page scroll instead. The default is tighter; raising it to 40 means a slightly diagonal swipe still changes the image, while a mostly-vertical one scrolls the page. Getting this wrong produces a gallery that either steals every scroll attempt or feels unresponsive to swipes.

Reusing it

Replace the gradient placeholders with real <img> tags — keep the .splide__track and .splide__list structure exactly, since Splide queries for those class names specifically. Add loading="lazy" to everything except the first image. For a lightbox on click, pair it with an image lightbox; for a zoom-on-hover detail view, an image magnifier sits naturally over the main slide.

Build with AI

Build, Understand, Optimize, and Extend It With AI

This snippet is short but has one ordering rule that causes an unusual amount of pain, so it is worth having it explained rather than memorized. Paste the HTML, CSS, and JS into an AI assistant like Claude and ask it to explain why main.sync(thumbs) must come before main.mount() and thumbs.mount(), and what specifically happens internally if the order is reversed — then reverse it and confirm the sliders silently stop talking to each other. Ask it what isNavigation adds beyond click handling, especially the ARIA roles and the is-active class the CSS depends on. Then ask why the active thumbnail is highlighted with opacity and a box-shadow rather than a border, and what layout artifact a border would introduce. For optimization, ask how to lazy-load real images in this structure without breaking Splide measurement, and what dragAngleThreshold should be for a gallery inside a long scrolling page. To extend it: have it swap the gradient divs for real img tags with correct lazy-loading, add a lightbox on main-slide click, wire keyboard arrow support, or drive both sliders from a product data array. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a product image gallery with a main slider and a synced thumbnail strip using Splide 4 from a CDN — include BOTH its core CSS and its JS — in plain HTML, CSS, and JavaScript.

Requirements:
- Two Splide instances using the required markup structure exactly (.splide > .splide__track > .splide__list > .splide__slide), since Splide queries those class names specifically.
- Link them with a single main.sync(thumbs) call for bidirectional syncing, and CRITICALLY call sync() BEFORE mounting either instance — then mount main, then mount thumbs. Add a comment explaining that sync() subscribes to internal move events which mount() sets up, so calling sync after mount produces two silently independent sliders with no error or warning.
- Configure the thumbnail slider with isNavigation: true, and explain that this is what turns its slides into real controls: focusable, clickable, correctly ARIA-roled, and given an is-active class on the slide matching the main slider — which is the CSS styling hook.
- Style thumbnails so inactive ones sit around 45% opacity, hover raises them to about 80%, and the active one is fully opaque with a small upward translate and a ring drawn with box-shadow. Use opacity and box-shadow rather than a border, and explain that a border changes the box model and would shift the strip by a pixel every time selection moves.
- Set focus: 'center' on the thumbnail slider so the active thumb stays centered in the strip, and explain that this matters most with long image sets where the active thumb would otherwise drift out of view.
- Size thumbnails with fixedWidth and fixedHeight plus a breakpoint that shrinks them below 480px — not perPage — and explain that perPage would resize thumbnails with the viewport whereas fixed sizing keeps them consistent and simply fits more or fewer.
- Set isActiveOnFocus: true so keyboard tabbing through the strip previews each image without requiring a click.
- Give the main slider type: 'loop' and a dragAngleThreshold around 40, explaining that this controls how far off horizontal a drag can be before Splide ignores it and lets the page scroll — too tight feels unresponsive to swipes, too loose steals vertical scrolling.
- Use CSS gradient placeholder blocks instead of external image files, styled as a clean light product page.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Step by step

How to Use

  1. 1
    Add both Splide CDNsSplide needs its core CSS as well as its JS — include both.
  2. 2
    Paste HTML, CSS, and JSA main gallery renders above a synced thumbnail strip.
  3. 3
    Drag the main imageThe thumbnail strip follows and re-centers the active thumb.
  4. 4
    Click a thumbnailThe main slider jumps to it — the sync is bidirectional.
  5. 5
    Tab through the stripisActiveOnFocus previews each image on keyboard focus.
  6. 6
    Swap in real imagesReplace the gradient divs with img tags inside the same structure.

Real-world uses

Common Use Cases

Product detail pages
The standard ecommerce gallery, above a variant selector.
Real estate listings
Room-by-room photos where the strip acts as a floor plan index.
Portfolio case studies
Step through project shots without leaving the page.
Recipe and tutorial steps
Thumbnails double as a visual progress indicator.
Lightbox source
Feed the active image into an image lightbox.
Learning slider syncing
A reference for navigation sliders and mount ordering.

Got questions?

Frequently Asked Questions

sync() works by subscribing to the sliders internal move events, and mount() is what fires the setup those subscriptions depend on. Calling sync() after mounting produces no error and no warning — you simply get two independent sliders that ignore each other, which is hard to debug because the code looks correct.

It converts the thumbnail slides from passive content into controls: they become focusable and clickable, they get correct ARIA roles so assistive tech announces a set of controls rather than a second gallery, and the slide matching the main slider gets an is-active class, which is the hook the CSS uses for highlighting.

A border changes the element box model, so adding one to the active thumbnail can shift the whole strip by a pixel each time selection moves. Opacity plus an outset box-shadow ring costs no layout, so the strip stays perfectly still while the highlight moves.

It keeps the active thumbnail centered in the visible strip rather than wherever it naturally falls. With five images it is cosmetic; with twenty it is essential, because otherwise the active thumbnail drifts to the edge or out of view and the user loses their place in the set.

perPage divides available width by a slide count, so thumbnails would grow and shrink with the viewport. Fixed dimensions keep them a consistent size and simply fit more or fewer as space allows, which is the correct behavior for a thumbnail strip. A breakpoint then shrinks them on small screens so a phone still shows several.

Official @splidejs/react-splide and @splidejs/vue-splide wrappers exist; with them you get refs to both instances and call sync in a mount effect, still before mounting completes. With the vanilla build, construct both instances in a mount effect, call sync then mount in that order, and call destroy() on both in cleanup so remounts do not leave orphaned listeners.