swiper-curve
v0.1.1
Published
Curved / arc slider addon for Swiper: bend the strip along a dome, a bowl, a wave, a ramp or a full circle. 300 presets
Downloads
341
Maintainers
Readme
swiper-curve
An add-on for Swiper (v11+, tested on v14) that bends the strip of slides along a shape instead of laying it out flat: a dome, a bowl, a sine wave, a straight ramp - or a full circle. Every slide gets its place on the curve, a tangential rotation and - if you want it - a 3D turn, a push into the depth, a shrink, a fade or a blur towards the edges.
Zero dependencies beyond Swiper itself. One JS file plus one CSS file, plain CSS transforms, no canvas and no WebGL.
How it works
It is a regular Swiper module, the same shape as Navigation or Pagination.
It registers its own parameters through extendParams({ curve: {...} }), 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, CurveEffect], // sits beside the other modules
slidesPerView: 'auto',
navigation: { nextEl: '.next' },
curve: { shape: 'arch', radius: 1400 }, // <- the plugin's parameters
});Under the hood:
- In
beforeInitthe plugin turns onwatchSlidesProgress(Swiper's own effects do the same) and adds aswiper-curveclass to the container. - On every
setTranslateit reads each slide'sswiperSlideOffsetand the currentswiper.translate, and works out how far that slide sits from the apex of the curve, in pixels. That is the crux: the plugin does not know Swiper's maths, it only reads the result. Which is whyslidesPerView,slidesPerGroup,spaceBetween,loop,centeredSlidesandfreeModework without it knowing anything about them. - The distance goes through the shape function, which returns a displacement
across the strip and the tangent at that point. The slide gets one
transform:translate3dfor the displacement,rotatefor the tangent, plus optionalrotateX/rotateY/ Z push, and a scale. - On a transition Swiper hands over the duration and the slides get the same one, so the arc animates together with the strip instead of snapping.
- With
overflow: 'fit'(the default) the plugin also pads the container by exactly as much as the arc needs, including the extra height a rotated card takes up - so a bent strip never gets clipped and the layout reserves the room for it.
The types (src/swiper-curve.d.ts) add curve 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-curveimport Swiper from 'swiper';
import { FreeMode } from 'swiper/modules';
import 'swiper/css';
import { CurveEffect } from 'swiper-curve';
import 'swiper-curve/css';
new Swiper('.swiper', {
modules: [CurveEffect, FreeMode],
slidesPerView: 'auto',
spaceBetween: 18,
centeredSlides: true,
grabCursor: true,
freeMode: { enabled: true, momentumRatio: 0.7 },
curve: {
shape: 'arch',
radius: 1400,
},
});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.
Presets are imported separately so you do not pull them in when unused:
import { presets } from 'swiper-curve/controls';From a CDN, no bundler (plain <script>)
<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-curve@0/dist/swiper-curve.css">
<script src="https://cdn.jsdelivr.net/npm/swiper@14/swiper-bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/swiper-curve@0/dist/swiper-curve.iife.js"></script>
<script>
new Swiper('.swiper', {
modules: [SwiperCurveEffect], // global from the IIFE build
slidesPerView: 'auto',
spaceBetween: 18,
curve: { shape: 'arch', radius: 1400 },
});
</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.
Shapes
| shape | What it does |
|---|---|
| 'arch' (default) | the middle rides high, the ends fall away. radius sets how tight the dome is - smaller means a stronger bend |
| 'bowl' | the same circle mirrored: the middle dips, the ends climb |
| 'wave' | a sine along the strip, driven by the wave group (amplitude, length, phase) instead of radius |
| 'ramp' | a straight diagonal at ramp.angle degrees. No bend at all |
| 'circle' | the strip closes into a full ring. See below |
strength multiplies the displacement of any of them: 1 is the exact
geometric curve, 0 flattens it, 2 doubles it while keeping the shape.
origin moves the apex to 'left' or 'right' instead of the middle, so the
strip rises out of a corner.
The full circle
shape: 'circle' is the only shape that also moves the slide along the strip:
an arc length of d px lands at the chord position r * sin(d / r), so the row
closes into a ring instead of running off the sides. The angle wraps, so a strip
longer than the circumference simply keeps going round.
curve: {
shape: 'circle',
overflow: 'free', // a ring is wider than its box
rotate: { max: 0 }, // 0 = uncapped, a ring turns the whole way
circle: {
fit: true, // radius derived from the strip length: one closed ring
spin: 0, // where the middle slide sits, in degrees. 0 = the top
},
}Two things decide whether it looks right:
- Card size. With
circle.fitthe radius isstripLength / 2π, so ten 300px cards give a ring 950px across and twenty give 1900px. Small cards, or fewer of them, keep the ring inside a section. - Room around it.
overflow: 'free'stops the plugin clipping the ring on either axis;'fit'instead pads the container by the ring's radius, which keeps everything inside the box at the price of a very tall block.
strength squashes the ring into an ellipse (0.5 is half as tall as it is
wide) and the rotation follows the squashed tangent. With rotate.enabled:
false the cards stay upright while travelling round - a clock face rather than
a wheel. edge.fade / edge.scale / edge.blur ramp with the angle here, so
they dim the far side of the ring rather than the sides of the viewport.
Vertical room
A bent strip is taller than a flat one, so something has to give. That is what
overflow decides:
| overflow | What it does |
|---|---|
| 'fit' (default) | the plugin pads the container so the whole arc stays inside its box. Nothing is clipped, the layout reserves the space |
| 'spill' | the container is clipped along the strip only (clip-path), so the arc runs over the section above and below |
| 'clip' | plain Swiper clipping: whatever leaves the box is cut off |
| 'free' | nothing is clipped on either axis. What shape: 'circle' usually wants |
recenter: true (default) keeps the arc balanced in its box instead of letting
it hang off one side.
Spacing
overflow buys room across the strip. The other axis needs it too: Swiper lays
the strip out from the untransformed slide width, but a card turned by 45° eats
noticeably more room along the strip than a card sitting flat. On a steep arch
that is enough to run neighbours into each other, and no spaceBetween you pick
by hand is right for every shape in the set.
So spacing.auto (on by default) works it out. It replays the strip at a
candidate gap, tests neighbouring cards for real overlap, and finds the smallest
gap that clears - which is a good deal tighter than the arithmetic suggests. A
bounding box grows fast with rotation, but two cards turned by similar angles
sit inside each other's boxes without ever touching. Measured on a deep bowl,
the box arithmetic asked for 141px where 60px was enough, and those 81px cost
three of the seven cards on screen.
spacing.gap is the gap you want left between two neighbours once the rotation
is paid for. null, the default, means "whatever spaceBetween this carousel
was built with" - so your own value stays the visual gap and is not overwritten.
Move spaceBetween later and that becomes the new base.
Two cases sit it out. shape: 'circle' is left alone, because a ring already
spreads its cards evenly and circle.fit reads the strip length to size itself,
so the two would chase each other. And a layout that is meant to overlap - a
deck, a fanned hand of cards - turns it off:
curve: { shape: 'arch', radius: 460, spacing: { auto: false } }Fading the ends
mask puts a gradient over both ends of the container, so the strip fades out
instead of ending on a hard cut:
curve: {
mask: { enabled: true, width: 0.14 }, // 14% of the container at each end
}This is the container fading, not the slides - it catches whatever is under it,
including half-visible cards, which is what edge.fade cannot do (that one
changes each slide's own opacity). The two combine happily.
The mask paints inside the container's box, so pair it with overflow: 'fit'
or 'clip'. With 'spill' or 'free' the mask also cuts off whatever leaves
the box across the strip, which is usually not what you meant.
sides: 'start' | 'end' fades only one end - handy when the row is anchored to
one side of the page (origin: 'left') and should only dissolve into the other.
Depth of field
edge.blur is a ramp from the middle of the viewport: everything gets softer
the further out it sits. dof is a lens - a focal plane you can move, a sharp
zone around it, and a front/back bias:
curve: {
dof: {
enabled: true,
focus: 0, // 0 is the middle of the viewport, 1 the right-hand edge
range: 0.14, // sharp zone: 14% of a half-width either side of the focus
blur: 16, // px of blur once you are all the way out
falloff: 2, // exponent of the ramp outside the sharp zone
dim: 0.25, // out-of-focus cards also lose a quarter of their opacity
bias: 1, // and only the background blurs, like a real lens
},
}Distances are normalised to half the container, so focus and range read the
same whatever the viewport is: focus: -0.5 puts the sharp card halfway between
the middle and the left edge, range: 0 gives a single sharp plane.
bias is the photographic part: +1 leaves everything in front of the focal
plane sharp and blurs only what is past it, -1 does the opposite, 0 treats
both sides alike.
It stacks with edge: blur adds up, dimming and shrink multiply. The whole
Depth of field preset family is built on it (dof-rack-focus,
dof-eighty-five, dof-backdrop, dof-hyperfocal and twenty-one more), as are
focus-aperture, focus-macro, focus-tilt-shift and arch-lens.
A word on cost. A CSS filter: blur() on a moving element repaints every
frame. On a strip of eight or ten cards that is fine; on forty it is not. Keep
dof.blur for carousels with a handful of slides on screen, or accept a lower
frame rate on weak hardware.
Parameters
curve: {
enabled: true,
shape: 'arch', // arch | bowl | wave | ramp | circle
radius: 1400, // px, smaller = tighter bend (arch / bowl / circle)
strength: 1, // multiplier on the displacement
origin: 'center', // center | left | right
overflow: 'fit', // fit | spill | clip | free
recenter: true,
respectReducedMotion: false,
rotate: {
enabled: true, // sit each slide on the tangent
amount: 1, // multiplier over the tangent angle
max: 30, // cap in degrees, 0 = uncapped
},
spacing: { // see "Spacing" below
auto: true, // widen spaceBetween by what the rotation costs
gap: null, // null = keep the spaceBetween you set as the gap
},
wave: { // shape: 'wave' only
amplitude: 90, // px
length: 900, // px per period
phase: 0, // 0..1 of a period
},
circle: { // shape: 'circle' only
fit: true, // radius from the strip length, so the ring closes
spin: 0, // where the middle slide sits, degrees. 0 = the top
},
ramp: { // shape: 'ramp' only
angle: 8, // degrees, negative tilts the other way
},
depth: { // 3D, all off by default
perspective: 1400, // px, on the container
z: 0, // px pushed back at the edges
tilt: 0, // rotateX at the edges, degrees
turn: 0, // rotateY at the edges, degrees
},
mask: { // gradient fade over the ends of the container
enabled: false,
width: 0.12, // width of each fade, 0..0.5 of the container
sides: 'both', // both | start | end
},
dof: { // depth of field
enabled: false,
focus: 0, // focal plane along the strip, in half-widths
range: 0.18, // half-width of the sharp zone, same units
blur: 12, // px at the far end
falloff: 1.6, // how fast the blur ramps up outside the zone
dim: 0.2, // dimming that rides with the blur, 0..1
scale: 0, // shrink that rides with the blur, 0..1
bias: 0, // 0 both sides, 1 background only, -1 foreground only
},
edge: { // falloff towards the edge of the viewport
scale: 0, // 0..1 shrink
fade: 0, // 0..1 opacity loss
blur: 0, // px
falloff: 1, // exponent of the ramp
stack: true, // z-index by distance from the centre
},
}Runtime API
swiper.curve.setParams({ radius: 800 }); // live, from the next frame
swiper.curve.setParams({ rotate: { max: 45 } }); // deep-merged into the tree
swiper.curve.refresh(); // after swapping slides by hand
swiper.curve.params; // current stateEvery parameter is read fresh each frame, so anything in the tree is free to
animate (from GSAP, a scroll handler or your own requestAnimationFrame).
Slide structure
Nothing special is required - ordinary Swiper slides:
<div class="swiper">
<div class="swiper-wrapper">
<div class="swiper-slide"><img src="card.jpg" alt=""></div>
...
</div>
</div>Two things to keep in mind:
- The plugin writes
transform,opacity,filterandz-indexon the.swiper-slideelements. Do not animate those on the slides yourself, and do not combine this with Swiper's own 3D effects (effect: 'coverflow','cards','creative') - both would write to the same property. Put your own transforms on a child element inside the slide instead. loopneeds a healthy surplus of slides overslidesPerView. With too few Swiper quietly fails to build the loop blocks and the strip wedges at the end. Keep roughly twiceslidesPerViewin the DOM, or leaveloopoff.
The effect also works on a vertical Swiper: the curve then runs down the page and the displacement is horizontal.
Presets
Three hundred compositions live in src/curve-presets.js under the presets
export, grouped into twelve families of twenty-five. 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 a repeated tree of numbers.
| Family | What drives it | |---|---| | Arch | the middle rides high and the ends fall away | | Bowl | the mirror image: the middle dips and the ends climb | | Wave | a sine along the strip, from a ripple to a full swell | | Wheel | the arc plus 3D: cards turn, recede and stack | | Editorial | small bends tuned for sections of real content | | Ramp | straight diagonals, no bend at all | | Hero | big statement curves for a landing section, most of them spilling over it | | Focus | the middle card is the subject: the rest shrinks, dims, blurs or steps back | | Marquee | shallow ribbons built to run on autoplay - these also set up the carousel | | Deck | a hand of cards: tight radius, heavy rotation, cards overlapping | | Depth of field | one card in focus, the rest defocused by a lens - these size the cards too, because blur costs a repaint per card | | Circle | the strip closed into a full ring - these size the cards too, because a ring only closes if the strip fits around it |
import { presets, presetFamilies } from 'swiper-curve/controls';
presets['arch-arch'].curve // a ready config
presets['wheel-carousel'].curve
presetFamilies // [{ id, label, note, presets: [...] }]A preset key is ${family}-${name}, e.g. bowl-hammock, wave-serpentine,
editorial-footnote.
If you want a starting point, take arch-arch, bowl-bowl, wave-wave,
wheel-wheel, editorial-editorial, ramp-ramp, hero-hero, focus-focus,
marquee-marquee, deck-deck, dof-depth-of-field or circle-circle - those
are the family bases and the rest are their variants.
An honest note: at 300 entries this is a catalogue, not a curation. A preset
carrying a swiper block (the whole Marquee family, a couple elsewhere) also
implies carousel options - autoplay, speed, card width - and the generated
snippets include them.
Performance
- One
transformstring per slide per frame, written only for the slides Swiper currently keeps in the DOM. No canvas, no layout reads beyond what Swiper has already measured. edge.bluris the one expensive knob: a CSSfilteron a moving element forces a repaint. Use it on a handful of cards, not on a 40-slide strip.depth.*puts the container into a 3D context, which promotes the slides to their own layers - usually a win, but worth checking on old Android.respectReducedMotion: trueturns the whole effect off underprefers-reduced-motion: reduceand leaves an ordinary flat 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, so the page needs a network connection.
The site is a Vite app: npm run dev (port 3002) 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 ownWhat 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.
Structure
src/swiper-curve.js the plugin (Swiper module + curve engine)
src/swiper-curve.css required styles
src/curve-controls.js the control schema behind the panel
src/curve-presets.js 300 presets in 12 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
