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"andaria-roledescription="carousel". It also getsaria-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
focusinanywhere inside the root and resumes onfocusout, under its own"focus"reason. It applies even withhoverPause: 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 carriesaria-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: reducesets step duration to 0, disables autoplay, and holds the marquee still. SetrespectReducedMotion: falseto 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%; } }