Splide Thumbnail Gallery — Two Synced Sliders
Splide Thumbnail Gallery · Carousels · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="stg-wrap">
<div class="stg-head">
<span class="stg-tag">splide · two synced sliders</span>
<h2>Aurora Chair</h2>
</div>
<div id="stgMain" class="splide stg-main" aria-label="Product images">
<div class="splide__track">
<ul class="splide__list">
<li class="splide__slide"><div class="stg-shot v1"><span>Front</span></div></li>
<li class="splide__slide"><div class="stg-shot v2"><span>Angle</span></div></li>
<li class="splide__slide"><div class="stg-shot v3"><span>Detail</span></div></li>
<li class="splide__slide"><div class="stg-shot v4"><span>Back</span></div></li>
<li class="splide__slide"><div class="stg-shot v5"><span>In situ</span></div></li>
</ul>
</div>
</div>
<div id="stgThumbs" class="splide stg-thumbs" aria-label="Choose an image">
<div class="splide__track">
<ul class="splide__list">
<li class="splide__slide"><div class="stg-thumb v1"></div></li>
<li class="splide__slide"><div class="stg-thumb v2"></div></li>
<li class="splide__slide"><div class="stg-thumb v3"></div></li>
<li class="splide__slide"><div class="stg-thumb v4"></div></li>
<li class="splide__slide"><div class="stg-thumb v5"></div></li>
</ul>
</div>
</div>
<p class="stg-note">Drag the main image, or pick a thumbnail — both sliders stay in step.</p>
</div>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#f5f6fa;color:#0f172a;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:26px}
.stg-wrap{width:min(560px,94vw)}
.stg-head{margin-bottom:16px}
.stg-tag{display:inline-block;font-size:10.5px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:#0d9488;background:#ccfbf1;border:1px solid #99f6e4;padding:5px 11px;border-radius:99px;margin-bottom:10px}
.stg-head h2{font-size:clamp(22px,4.4vw,30px);font-weight:800;letter-spacing:-.02em}
.stg-main{border-radius:18px;overflow:hidden;box-shadow:0 24px 54px -28px rgba(15,23,42,.5)}
.stg-shot{position:relative;height:340px;display:flex;align-items:flex-end;padding:20px}
.stg-shot span{font-size:12px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:#fff;background:rgba(15,23,42,.4);backdrop-filter:blur(6px);padding:7px 14px;border-radius:99px}
.stg-thumbs{margin-top:12px}
.stg-thumb{height:60px;border-radius:11px;cursor:pointer;opacity:.45;transition:opacity .2s,transform .2s}
.stg-thumbs .splide__slide:hover .stg-thumb{opacity:.8}
/* Splide adds is-active to the current navigation slide — that is the hook. */
.stg-thumbs .splide__slide.is-active .stg-thumb{opacity:1;transform:translateY(-2px);box-shadow:0 0 0 2px #0d9488}
.v1{background:linear-gradient(140deg,#c7d2fe,#6366f1)}
.v2{background:linear-gradient(140deg,#a7f3d0,#0d9488)}
.v3{background:linear-gradient(140deg,#fed7aa,#ea580c)}
.v4{background:linear-gradient(140deg,#e9d5ff,#a855f7)}
.v5{background:linear-gradient(140deg,#bae6fd,#0284c7)}
.stg-main .splide__arrow{background:rgba(255,255,255,.92);opacity:1;height:38px;width:38px;box-shadow:0 4px 14px -4px rgba(15,23,42,.5)}
.stg-main .splide__arrow svg{fill:#0f172a;height:15px}
.stg-main .splide__arrow:disabled{opacity:.35}
.stg-note{font-size:13px;color:#64748b;margin-top:16px;line-height:1.6}var main = new Splide('#stgMain', {
type: 'loop',
perPage: 1,
pagination: false,
arrows: true,
speed: 520,
dragAngleThreshold: 40
});
var thumbs = new Splide('#stgThumbs', {
// isNavigation is what turns these slides into clickable controls and
// gives the current one an .is-active class to style against.
isNavigation: true,
fixedWidth: 92,
fixedHeight: 60,
gap: 10,
rewind: true,
pagination: false,
arrows: false,
focus: 'center',
isActiveOnFocus: true,
breakpoints: {
480: { fixedWidth: 68, fixedHeight: 46, gap: 8 }
}
});
// sync() must run BEFORE either slider is mounted — it wires the internal
// events, and a mounted instance has already missed them.
main.sync(thumbs);
main.mount();
thumbs.mount();Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Splide Thumbnail Gallery — How Slider Syncing Actually Works
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.
Step by step
How to Use
- 1Add both Splide CDNsSplide needs its core CSS as well as its JS — include both.
- 2Paste HTML, CSS, and JSA main gallery renders above a synced thumbnail strip.
- 3Drag the main imageThe thumbnail strip follows and re-centers the active thumb.
- 4Click a thumbnailThe main slider jumps to it — the sync is bidirectional.
- 5Tab through the stripisActiveOnFocus previews each image on keyboard focus.
- 6Swap in real imagesReplace the gradient divs with img tags inside the same structure.
Real-world uses
Common Use Cases
main.sync(thumbs) in one line.isNavigation giving thumbnails focus, click handling and ARIA roles.is-active class.sync() must be called before mount(), the ordering rule that silently breaks synchronisation when reversed.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.