v2.0

Multislider

A responsive, dependency free, multi-slide slideshow.

npm i @tjblackman/multislider

Live examples

All three sliders below use the same markup and the same CSS. Only the options differ.

One slide at a time

{ interval: 2500 }

A full page at a time

{ advanceBy: "page", interval: 3000, duration: 800 }

Marquee

{ mode: "marquee", speed: 70 }

Hover the marquee to pause it.

About

Multislider shows more than one slide at a time. How many is up to your CSS. A slide with width: 20% means five visible slides, and a media query at a smaller breakpoint can drop that to two. The library never sets slide widths and has no slidesVisible option.

It is TypeScript, has zero runtime dependencies, and ships as ESM plus one IIFE build for script tag users. It runs in every evergreen browser; exact versions are under browser support. Slides never get cloned or reordered to fake a loop, so your DOM stays the shape you wrote it, with one exception noted under markup.

Markup

Four parts, three of which you have to supply.

Part How it is found
Root The element or selector you pass to the constructor. Events fire here.
Viewport First descendant matching [data-ms="viewport"], .ms-viewport, or .MS-content. The last one is the v1 name and still works.
Slides The element children of the viewport at init time. Any HTML you like inside them.
Buttons Optional descendants matching [data-ms="prev"] / [data-ms="next"], or the v1 names .MS-left / .MS-right.
<div id="mySlider">
  <div class="MS-content">              <!-- viewport -->
    <div class="item">Slide 1</div>    <!-- slides -->
    <div class="item">Slide 2</div>
    <div class="item">Slide 3</div>
  </div>
  <button type="button" data-ms="prev">Prev</button>
  <button type="button" data-ms="next">Next</button>
</div>

At init the library creates a div.ms-track inside the viewport and moves the slides into it exactly once. destroy() puts the original DOM back.

Critical styles are set inline, so there is no stylesheet to import. The viewport gets overflow: hidden. The track gets display: flex, width: 100%, and will-change: transform. Slides get flex: 0 0 auto. Percentage widths resolve against the track, whose width matches the viewport content box, which is why v1 era CSS keeps working.

An endless loop needs enough content to cover the viewport plus the widest slide. If your slides do not add up to that, the library duplicates the whole set as many times as the viewport needs, marks the copies aria-hidden="true" and inert, and carries on. Three logos on an ultrawide monitor might clone six sets; the clone count is capped by the maxClones option (default 600). Past that cap looping turns off, the copies are removed, the slider clamps at the ends with autoplay rewinding to the start, and a warning goes to the console. This is the only case where anything gets cloned.

CSS

Slide count is a width percentage. Divide 100 by the number of slides you want to see.

.item { width: 50%; }      /* 2 slides */
.item { width: 33.3333%; } /* 3 slides */
.item { width: 25%; }      /* 4 slides */
.item { width: 20%; }      /* 5 slides */

Change the count per breakpoint with media queries.

#mySlider .item {
  width: 20%;
}

@media (max-width: 1200px) { #mySlider .item { width: 25%; } }
@media (max-width: 992px)  { #mySlider .item { width: 33.3333%; } }
@media (max-width: 768px)  { #mySlider .item { width: 50%; } }

After a breakpoint change the slider re-measures through a ResizeObserver and keeps the same slide leading, so nothing jumps.

Padding, borders, and margins on a slide count toward its size, and so does gap on the track, so spacing works however you like to write it.

#mySlider .ms-track { gap: 16px; }  /* or margins on .item, both work */

One caveat: changing only a gap or a margin at a breakpoint does not resize the track, so the ResizeObserver cannot see it. Call refresh() after such a change. Changing slide widths remeasures automatically. Also avoid order, flex-direction: row-reverse, and transitions on transform for slides or the track: the wrap math assumes DOM order is layout order and that transforms apply instantly. Prefer margins over horizontal padding on the viewport, since slides wrap at the content box edge and padding would show them popping in and out. Everything else, including the arrow buttons, is yours to style.

Usage

ESM

import { Multislider } from '@tjblackman/multislider';

const slider = new Multislider('#mySlider', {
  interval: 3000,
  duration: 600,
});

Script tag

The IIFE build puts the class on window.Multislider. Bare unpkg and jsdelivr URLs serve it directly.

<script src="https://unpkg.com/@tjblackman/multislider"></script>
<script>
  new Multislider('#mySlider', { mode: 'marquee', speed: 40 });
</script>

Bundler users who want the same global build can import '@tjblackman/multislider/global', which also types window.Multislider for TypeScript.

The constructor takes a selector string or an element. It throws if the target or its viewport cannot be found, or if that element is already a slider. Many independent instances on one page are fine.

Options

Every option is optional. Defaults are below.

Option Type Default What it does
mode "step" | "marquee" "step" Stepped slideshow, or constant speed scrolling to the left forever with linear easing.
advanceBy "one" | "page" "one" How far autoplay and the prev/next buttons move: one slide, or a full viewport worth.
interval number 2000 Milliseconds between autoplay steps. 0 turns autoplay off.
duration number 500 Milliseconds per step, ease in out cubic.
speed number 60 Marquee speed in pixels per second.
hoverPause boolean true Pause while the pointer is over the viewport. In marquee mode it stops mid animation and picks up where it left off.
pauseAbove number | null null Pause when the viewport is wider than this many pixels. Uses matchMedia, not resize handlers.
pauseBelow number | null null Same idea, for viewports narrower than this many pixels.
draggable boolean true Pointer and touch dragging with momentum and a snap to the nearest slide.
respectReducedMotion boolean true Under prefers-reduced-motion: reduce, step duration drops to 0, autoplay is off, and the marquee stays paused.
direction "auto" | "ltr" | "rtl" "auto" auto reads the computed direction of the root. RTL flips every movement.
maxClones number 600 Cap on cloned slide elements added for endless looping. 0 means never clone; the slider clamps at the ends instead.

Methods

Method What it does
next(count = 1)
prev(count = 1)
Step by count slides. Works on a paused slider, and resets the autoplay timer.
nextPage()
prevPage()
Step by the shortest run of consecutive slides that covers the viewport. Correct even when slides have different widths.
pause()
play()
Add and remove the "api" pause reason. play() will not start autoplay when interval is 0.
refresh() Re-measure slides and viewport. Call it after you change slide content.
setMode(mode) Switch between "step" and "marquee" at runtime.
destroy() Drop every listener, observer, and animation frame loop, remove injected DOM and styles, and restore the original markup. The instance is dead after this.
paused Getter. true when any pause reason is active.
element Getter. The root element.

Prev and next buttons call prev() / next() when advanceBy is "one", and prevPage() / nextPage() when it is "page".

Pausing is a set of reasons, not a boolean. Hover, keyboard focus, an off screen tab, a matched pauseAbove query, a drag, reduced motion, and pause() each add their own reason ("hover", "focus", "hidden", "media", "drag", "reduced-motion", "api"). Autoplay runs only when the set is empty, which is why hovering a slider you already paused through the API does not accidentally resume it.

Events

Native CustomEvents, dispatched on the root element, bubbling.

Event Detail Notes
multislider:beforechange { from, to, direction, count } Cancelable. preventDefault() blocks the step. from and to are head slide indices, direction is 1 or -1.
multislider:afterchange { from, to, direction, count } Once per step that completes, never per frame. A step superseded by a pointer grab, setMode(), or destroy() fires no afterchange. Marquee mode fires neither event.
multislider:settle { index } The terminal signal: motion came to rest in step mode, after a step, a drag's snap, or a mode switch realignment. Guaranteed even when a superseded step fired no afterchange. Marquee mode never fires it.
multislider:pause { reasons: string[] } Fires when the reason set goes from empty to nonempty.
multislider:play { reasons: string[] } Fires when the last reason clears.
const el = document.querySelector('#mySlider');

el.addEventListener('multislider:afterchange', (e) => {
  console.log(e.detail.from, '->', e.detail.to);
});

el.addEventListener('multislider:beforechange', (e) => {
  if (someCondition) e.preventDefault();
});

Accessibility

  • The root gets role="region" and aria-roledescription="carousel". It also gets aria-label="slideshow", but only if you did not label it yourself.
  • Buttons with no accessible name get aria-label="Previous slide" or "Next slide". Give them your own label and the library leaves it alone.
  • Autoplay pauses on focusin anywhere inside the root and resumes on focusout, under its own "focus" reason. It applies even with hoverPause: false, so a stray mouse movement can never resume a slider a keyboard user is reading.
  • Slides get role="group", aria-roledescription="slide", and a positional label like "2 of 5" unless you labelled them yourself. The track carries aria-live: "off" while auto-rotating, "polite" while paused or when autoplay is disabled.
  • Tabbing to a link or button in a partly hidden slide jumps the track, with no tween, so the focused slide is fully on screen.
  • ArrowLeft and ArrowRight step the slider when focus is on the root or on one of the buttons. The arrow points at the edge new content enters from, so RTL flips the mapping, and presses with a modifier key held are left to the browser. The root only receives arrow keys if you give it a tabindex; the buttons work out of the box.
  • prefers-reduced-motion: reduce sets step duration to 0, disables autoplay, and holds the marquee still. Set respectReducedMotion: false to opt out, though think hard before you do.

Browser support

The build targets ES2022, and the limiting syntax feature is private class methods. That sets the floor:

Browser Runs from Full behavior from
Chrome / Edge 84 102
Firefox 90 112
Safari / iOS Safari 15.0 15.5

"Full behavior" is the inert attribute on cloned slides; older browsers ignore it, and a cloned slide's links could take keyboard focus despite being aria-hidden. Everything else degrades gracefully: without ResizeObserver you call refresh() after layout changes, and without MediaQueryList.addEventListener the pause breakpoints evaluate once at init.

The CSS bar is far lower than the JS bar: the page needs flexbox and 2D transforms (Safari 9 era), and the library sets everything it needs inline. The newest CSS it uses is touch-action: pan-y (Safari 13) so touch dragging and vertical page scrolling coexist. Any browser that can parse the JS clears all of it.

Migrating from v1

v1 was a jQuery plugin. v2 is a class. The markup you already have keeps working, since the old .MS-content, .MS-left, and .MS-right hooks are still recognized. The v1 source lives on the v1 branch and the v1.0.0 tag.

v1 v2
$('#s').multislider({...}) new Multislider('#s', {...})
continuous: true mode: "marquee"
slideAll: true advanceBy: "page"
duration as marquee speed speed in pixels per second
.multislider('pause' / 'unPause') .pause() / .play()
.multislider('next' / 'prev') .next() / .prev()
.multislider('nextAll' / 'prevAll') .nextPage() / .prevPage()
.multislider('continuous') .setMode("marquee")
ms.before.animate / ms.after.animate multislider:beforechange / multislider:afterchange
Required CSS: white-space: nowrap, display: inline-block, and friends Gone. The library sets its own critical styles. You set slide widths.
jQuery Gone.

More examples

Logo marquee

A slow marquee with no buttons. Good for partner logos, where the point is motion rather than navigation.

Show the code
new Multislider('#logos', {
  mode: 'marquee',
  speed: 35,
  draggable: false,
});

Card slider

Three cards on a wide screen, two on a tablet, one on a phone. Same options either way.

Show the code
new Multislider('#cards', {
  interval: 4000,
  duration: 700,
});

/* CSS */
#cards .item { width: 33.3333%; }
@media (max-width: 992px) { #cards .item { width: 50%; } }
@media (max-width: 700px) { #cards .item { width: 100%; } }