swiper-dispersion
v0.1.3
Published
WebGL edge-dispersion, liquid-glass refraction and motion blur addon for Swiper
Maintainers
Readme
swiper-dispersion
An add-on for Swiper (v11+, tested on v14) that renders the slides through WebGL and applies an edge-dispersion effect at the viewport boundary: prismatic channel separation, directional blur, a wavy "liquid glass" warp, a glowing boundary and a smear that follows drag speed.
Zero dependencies beyond Swiper itself. One JS file plus one CSS file.
How it works
It is a regular Swiper module, the same shape as Navigation or Pagination.
It registers its own parameters through extendParams({ dispersion: {...} }), so
you configure it inside the normal Swiper config, next to slidesPerView or
loop. There is no separate init API.
new Swiper('.swiper', {
modules: [Navigation, DispersionEffect], // sits beside the other modules
slidesPerView: 'auto',
navigation: { nextEl: '.next' },
dispersion: { mode: 'glass' }, // <- the plugin's parameters
});Under the hood:
- The plugin hooks the
init/update/resize/destroyevents and, oninit, inserts a<canvas>into the Swiper container. - It hides the DOM slides (
opacity: 0) but leaves them in the layout. Swiper still measures positions, drag and keyboard keep working, screen readers see ordinary HTML, and links keep their hit testing. - Every frame it reads
getBoundingClientRect()of each layer (image, video, text, card background) and draws them as textured quads into an offscreen buffer. That is the crux: the plugin does not know Swiper's maths, it only reads the result from the DOM. That is whyslidesPerView,slidesPerGroup,spaceBetween,loop,centeredSlides,freeMode,coverflowand the rest work without it knowing anything about them. - Velocity comes from the difference in the strip's rendered position between
frames (the wrapper's offset relative to the container), smoothed and passed
to the shader as
u_vel. Deliberately not fromswiper.translate: while dragging that property follows the pointer, but onslideNext(), arrows or autoplay it jumps to the target value at once and the cards cover the rest of the distance with a CSS transform. Velocity read from it would be a single spike instead of accompanying the whole travel. - A second pass measures each pixel's distance to the left and right edge and applies warp, turbulence, multi-sample spectral dispersion and the glowing band on that basis.
The types (src/swiper-dispersion.d.ts) add dispersion to SwiperOptions
through declaration merging, so in a TypeScript project the field is suggested
and type-checked inside a plain Swiper config.
Install
Bundler (Vite / webpack / Next)
npm i swiper swiper-dispersionimport Swiper from 'swiper';
import { FreeMode } from 'swiper/modules';
import 'swiper/css';
import { DispersionEffect } from 'swiper-dispersion';
import 'swiper-dispersion/css';
new Swiper('.swiper', {
modules: [DispersionEffect, FreeMode],
slidesPerView: 'auto',
spaceBetween: 14,
grabCursor: true,
freeMode: { enabled: true, momentumRatio: 0.7 },
dispersion: {
background: '#ffffff', // MUST match the section background
},
});Watch the modules list. Swiper has been modular since v9: options belonging
to a module you did not register are silently ignored, with no warning. If you
pass freeMode or autoplay without importing FreeMode / Autoplay from
swiper/modules, they simply do nothing. This is not specific to this plugin,
but it is the easiest way to conclude that the effect is broken when Swiper
itself never enabled the behaviour.
Presets are imported separately so you do not pull them in when unused:
import { presets } from 'swiper-dispersion/controls';From a CDN, no bundler (plain <script>)
jsDelivr and unpkg serve the package straight from npm, nothing has to be
uploaded anywhere. The Swiper bundle registers every module, so freeMode and
autoplay work without any extra imports here.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper@14/swiper-bundle.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper-dispersion@0/dist/swiper-dispersion.css">
<script src="https://cdn.jsdelivr.net/npm/swiper@14/swiper-bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/swiper-dispersion@0/dist/swiper-dispersion.iife.js"></script>
<script>
new Swiper('.swiper', {
modules: [SwiperDispersionEffect], // global from the IIFE build
slidesPerView: 'auto',
spaceBetween: 14,
dispersion: { background: '#ffffff' },
});
</script>From a CDN as an ES module
<script type="module">
import Swiper from 'https://cdn.jsdelivr.net/npm/swiper@14/swiper-bundle.min.mjs';
import { DispersionEffect } from 'https://cdn.jsdelivr.net/npm/swiper-dispersion@0/+esm';
new Swiper('.swiper', {
modules: [DispersionEffect],
slidesPerView: 'auto',
dispersion: { background: '#ffffff' },
});
</script>In production pin an exact version (@0.1.0) instead of @0, so a new release
cannot change how your page looks without warning.
The IIFE build is unminified (~40 kB, ~10 kB gzipped by the server). jsDelivr can
minify on the fly if you add .min before the extension
(swiper-dispersion.iife.min.js) - check that path after publishing, before you
put it into production.
Required slide structure
<div class="swiper-slide">
<img src="card.jpg" alt=""> <!-- card background -->
<div class="card-title">Title</div> <!-- text, rasterised into WebGL -->
<div class="card-quote">"Quote..."</div>
</div>The plugin collects from each slide:
| Element | What it does |
|---|---|
| the slide's background-color | the card fill |
| img, video, canvas | texture, respects object-fit: cover / contain / fill |
| leaf elements holding text | rasterised on a 2D canvas (font, colour, text-align, wrapping) |
| the slide's border-radius | rounds and clips every layer |
Mind CORS: cross-origin images need crossorigin="anonymous" and an
Access-Control-Allow-Origin header from the server. Without both, WebGL cannot
upload the texture (the plugin logs a warning and skips the layer), and the
canvas ends up tainted, which also kills toDataURL().
Text rasterisation supports a single style per element. If you need coloured fragments or gradient scrims, split them into separate elements or bake them into the image.
How many slides loop mode needs
Swiper's loop needs a healthy surplus of slides over slidesPerView. With too
few it quietly fails to build the loop blocks and the strip wedges at the end:
the arrows stop doing anything. Keep roughly twice slidesPerView in the DOM,
or leave loop off.
Modes
| mode | What it does |
|---|---|
| 'glass' (default) | the full effect: edge zone, wavy boundary, glowing band and velocity smear |
| 'motion' | motion blur only: no edge zone, no boundary, no warp. The carousel looks ordinary until you move it, and then gets directional smear with channel separation |
At rest, motion costs practically nothing (the shader exits after one texture
fetch per pixel), so it suits a plain slider that should just blur nicely while
moving:
dispersion: {
mode: 'motion',
motion: {
amount: 120, // smear length in px at full speed
blur: 18, // extra defocus blur
spectrum: 0.5, // how much channel split in the smear, 0 = clean blur
sensitivity: 0.35, // how quickly speed saturates the effect
damping: 0.35, // smoothing of the velocity signal, lower = softer
},
}The motion group is independent of edge, so you tune motion blur separately
in glass mode too. In motion mode the remaining groups (edge, warp,
aura) are ignored. motion.amount: 0 turns the smear off entirely.
Presets
A hundred and fifty compositions live in src/dispersion-presets.js under the
presets export, grouped into six families of 25. Each family has a base that
carries its character, and a single composition is that base plus a few explicit
path-written deviations - so one line shows what a given preset changes, instead
of 40 repeated numbers.
| Family | What drives it |
|---|---|
| Liquid glass | refraction with a glowing band at the zone boundary |
| Clean edge | the same refraction with the band turned down to zero |
| Motion only | velocity smear alone, zero cost at rest |
| Editorial | restrained, tuned so the text stays readable |
| Prismatic | led by colour, one hue per composition |
| Noir | for dark sections, each sets its own background |
import { presets, presetFamilies } from 'swiper-dispersion/controls';
presets['glass-liquid-glass'].dispersion // a ready config
presets['noir-obsidian'].dispersion
presetFamilies // [{ id, label, note, presets: [...] }]A preset key is ${family}-${name}, e.g. motion-cinematic,
prismatic-cobalt, editorial-footnote.
An honest note: at 150 entries this is a catalogue, not a curation. If you want a
starting point, take glass-liquid-glass, clean-silk, motion-glide,
editorial-editorial, prismatic-chromatic or noir-onyx - those are the
family bases and the rest are their variants.
Image quality
A few decisions that make a visible difference and are not obvious:
- Averaging in linear space. Blur computed directly on sRGB values eats highlights: a bright pixel smeared over a dark one lands much lower than where light actually mixes. The shader decodes samples to linear, averages, and encodes back. That is what makes the smear look lit rather than grey.
- Sample count follows smear length. The step stays under ~2.5px, so the smear is continuous instead of breaking into visible copies. There is no sample jitter - that would trade banding for grain, and the goal here is clean glass.
- No hard thresholds in the turbulence. A
step()in noise gives glitch lines; here there are only smooth refraction bands. - Mipmaps plus anisotropic filtering on the card textures. Card artwork is usually larger than the space it is drawn into, so without mipmaps sharp cards shimmer.
- Corner antialiasing computed in device px, not CSS px, so the rounding stays crisp on HiDPI screens.
- Sub-1-LSB dithering on the output, so long smooth gradients do not band on an 8-bit buffer. The amplitude is invisible - this is not grain.
- A text tail rather than copies. Text layers get one sharp copy and a series of fading ones, so it reads as movement instead of doubled glyphs.
Performance
- Pixels outside the edge zone (the sharp middle cards, usually most of the
canvas) skip the whole sampling loop: one texture fetch and done. Inside the
edge zone the sample count scales with how far a given pixel actually travels,
so you only pay full
samplesright at the edge. - The upper bound is
samplessamples per pixel. Atsamples: 28on a 1440x420 canvas @2x that is about 34M samples per frame in the worst case - fine on desktop; on a weaker phone drop tosamples: 12anddpr: 1.5. - Rendering stops while the carousel is outside the viewport (
pauseWhenHidden). - Video in slides is uploaded to the GPU every frame only while it is playing.
- Under
prefers-reduced-motion: reducethe effect does not start and CSS shows the ordinary DOM slides.
Fallback
If WebGL is unavailable the plugin logs a warning, does not add the
swiper-dispersion-active class, and the carousel works as a plain DOM Swiper.
The site
index.html in the repository root is a landing page, documentation and
playground in one: a live stage pinned to the top, a one-line entry per preset,
a parameter table and a tuning panel. Selecting a preset loads it straight into
the stage, and each entry hands you ready-to-paste code in three shapes: a whole
HTML page, an npm import and CDN tags.
The demo cards use photos from Unsplash with crossorigin="anonymous", so the
page needs a network connection.
The site is a Vite app: npm run dev (port 3001) and npm run build, which
writes it to dist-site/. The published library build is a separate command,
npm run build:lib, and that is what creates dist/.
Publishing to npm
npm publish # prepublishOnly runs build.mjs on its ownLater versions through npm version patch|minor|major, then npm publish.
Renaming a parameter or changing a default in a way that changes the look is
minor before 1.0 and major after 1.0.
What ships (npm pack --dry-run): src/ (ESM, types and CSS), dist/ (the
IIFE build plus CSS), README.md, LICENSE. The site sources under src/app/
and src/components/ stay in the repository only - files in package.json
lists the library files explicitly rather than the whole src directory.
A few things in this package.json that matter:
peerDependencies: { "swiper": ">=11" }- the user installs Swiper, the package does not duplicate it.exportswith separate./css,./controlsand./iifepaths, soimport 'swiper-dispersion/css'works and internal files cannot be reached by accident.sideEffectslists only the CSS and the IIFE build, so a bundler can drop unused code from the ESM module.swiper-dispersion.jsitself writes nothing towindow; the global name is attached bybuild.mjs.typesplus declaration merging inswiper-dispersion.d.tsmakedispersionautocomplete inside a Swiper config with no extra setup on the user's side.
Structure
src/swiper-dispersion.js the plugin (Swiper module + WebGL engine + shaders)
src/swiper-dispersion.css required styles
src/dispersion-controls.js the control schema behind the panel
src/dispersion-presets.js 150 presets in 6 families
build.mjs generates dist/ for <script> usage
index.html the site entry point (Vite)
src/app/ demo site: React + Tailwind + shadcn/ui
App.jsx top bar, hero, tabs, preset list
Stage.jsx the live carousel (Swiper mounted in a ref)
Panel.jsx the tuning panel shell
panel.js panel controls built from the schema
store.js effect state outside React + Swiper's life cycle
favourites.js starred presets, kept in localStorage
artwork.js demo card photos and placeholder copy
src/components/ui/ shadcn/ui componentsLicense
MIT
