A lightweight 3D carousel UI component library. Panels rotate on a CSS rotateY cylinder using the Pointer Events API for unified mouse, touch, and stylus input.

Originally by Jim Ing (@jim_ing) for BlackBerry Limited (2013–2014). Refactored to modern ES6+ (2026).
Licensed under the Apache License 2.0.
| File | Description |
|---|---|
PantherUI.js |
Library — Carousel3D class and Panther.Carousel module |
PantherUI.css |
Base carousel styles |
<link rel="stylesheet" href="PantherUI.css"/>
<!-- Carousel container -->
<div class="panther-Carousel-container" id="container-demo">
<div class="panther-Carousel-carousel" id="my-carousel">
<figure>
<div class="panther-Carousel-panelTitleBar">
<span class="panther-Carousel-panelTitle">Panel 1</span>
</div>
<div class="panther-Carousel-panelContent">Content A</div>
</figure>
<figure>
<div class="panther-Carousel-panelTitleBar">
<span class="panther-Carousel-panelTitle">Panel 2</span>
</div>
<div class="panther-Carousel-panelContent">Content B</div>
</figure>
<figure>
<div class="panther-Carousel-panelTitleBar">
<span class="panther-Carousel-panelTitle">Panel 3</span>
</div>
<div class="panther-Carousel-panelContent">Content C</div>
</figure>
</div>
</div>
<script src="PantherUI.js"></script>
<script>
Panther.Carousel.init({
id: 'my-carousel',
easing: 'easeInOutCubic',
callback(c) {
console.log('Now showing panel', c.sideIndex);
}
});
</script>
A minimum of 3 panels is required for the 3D geometry to work.
.panther-Carousel-container ← receives pointer events, sized to fill parent
.panther-Carousel-carousel ← rotates in 3D (transform-style: preserve-3d)
figure ← one per panel (any count ≥ 3)
.panther-Carousel-panelTitleBar
.panther-Carousel-panelTitle
.panther-Carousel-panelContent
The container must have an explicit width and height (or be inside a sized parent) before init() is called, since panel geometry is calculated from offsetWidth at init time.
All methods are on the Panther.Carousel namespace.
init(opts) → Carousel3D | nullInitialise a carousel. Must be called after the element is in the DOM with a non-zero width.
Panther.Carousel.init({
id: 'my-carousel', // required — ID of the carousel element
backgroundColor: '#1a1a2e', // optional — panel background colour
easing: 'easeOutExpo', // optional — named key or raw CSS transition
callback: (c) => {} // optional — called on every panel change
});
callback receives the Carousel3D instance. Useful fields:
| Property | Type | Description |
|---|---|---|
c.sideIndex |
number |
0-based index of the currently visible panel |
c.panelCount |
number |
Total number of panels |
c.rotation |
number |
Current rotation in degrees |
refresh()Recalculate geometry for all carousels. Call after a resize or orientation change (PantherUI also does this automatically via window.addEventListener('resize', ...)).
Panther.Carousel.refresh();
turnNext(carouselId)Rotate one panel forward.
Panther.Carousel.turnNext('my-carousel');
turnPrev(carouselId)Rotate one panel backward.
Panther.Carousel.turnPrev('my-carousel');
turnTo(carouselId, side, callback?)Rotate to a specific panel by 1-based index. Accepts a single ID or an array of IDs to turn multiple carousels simultaneously.
Panther.Carousel.turnTo('my-carousel', 3);
// Turn multiple carousels to the same panel
Panther.Carousel.turnTo(['carousel-0', 'carousel-1', 'carousel-2'], 2, (id, side) => {
console.log(id, 'turned to', side);
});
setEasing(carouselId, easing)Set the snap-transition easing for a single carousel. Accepts a named key from EASINGS or a raw CSS transition string.
Panther.Carousel.setEasing('my-carousel', 'easeOutBack');
Panther.Carousel.setEasing('my-carousel', '500ms ease-in-out');
setAllEasings(easing)Set the snap-transition easing for all active carousels at once.
Panther.Carousel.setAllEasings('easeOutExpo');
setSensitivity(value)Set the drag sensitivity multiplier for all carousels. 1.0 requires dragging one full panel-width to rotate one face. Values above 1.0 reduce the distance required. Clamped to 0.5 – 5.0.
Panther.Carousel.setSensitivity(2.0); // half the panel width rotates one face
maximize(carouselId)Expand a carousel’s container to fill the viewport. Hides all other carousels.
Panther.Carousel.maximize('my-carousel');
restore(carouselId)Restore a maximized carousel to its original size and show all other carousels.
Panther.Carousel.restore('my-carousel');
addPanel(title, content) — instance methodDynamically add a new panel to a carousel. Calls refresh() automatically.
const c = Panther.Carousel.all['my-carousel'];
c.addPanel('Panel 4', '<p>Dynamic content</p>');
removeLastPanel(carouselId) → booleanRemove the last panel. Enforces a minimum of 3 panels; returns false if at minimum.
Panther.Carousel.removeLastPanel('my-carousel');
allRegistry of all active Carousel3D instances, keyed by element ID.
const c = Panther.Carousel.all['my-carousel'];
console.log(c.sideIndex, c.panelCount, c.rotation);
Named easing presets available via setEasing / setAllEasings / init({ easing }):
| Key | CSS value | Character |
|---|---|---|
ease |
750ms ease |
Browser default |
linear |
750ms linear |
Constant speed |
easeIn |
750ms ease-in |
Slow start |
easeOut |
750ms ease-out |
Slow end |
easeInOut |
750ms ease-in-out |
Slow start and end |
easeInOutSine |
750ms cubic-bezier(0.45, 0, 0.55, 1) |
Gentle S-curve |
easeInOutCubic |
750ms cubic-bezier(0.65, 0, 0.35, 1) |
Medium S-curve (default) |
easeInOutQuart |
750ms cubic-bezier(0.76, 0, 0.24, 1) |
Strong S-curve |
easeInOutQuint |
750ms cubic-bezier(0.87, 0, 0.13, 1) |
Very strong S-curve |
easeOutBack |
750ms cubic-bezier(0.34, 1.56, 0.64, 1) |
Slight overshoot |
easeOutExpo |
400ms cubic-bezier(0.16, 1, 0.3, 1) |
Snappy, fast settle |
easeInOutBack |
1100ms cubic-bezier(0.68, -0.55, 0.27, 1.55) |
Elastic bounce |
Access the full map at runtime via Panther.Carousel.EASINGS.
Input is handled via the Pointer Events API, which unifies mouse, touch, and stylus into a single event stream. setPointerCapture keeps the drag live even if the pointer leaves the element.
Pixel-to-degrees mapping:
rotation_delta = dx_pixels × (theta / panelSize) × sensitivity
Where theta = 360 / panelCount and panelSize = element.offsetWidth.
At the default sensitivity of 1.0, dragging one full panel-width rotates exactly one face. At 2.0, half the panel-width is sufficient.
Flick / momentum: if the pointer was moving faster than 0.3 deg/ms at release, the carousel automatically advances one extra panel in the flick direction.
Deadzone: movements under 6px are treated as clicks, not drags.
| Property | Default | Description |
|---|---|---|
--panther-transition |
750ms cubic-bezier(0.65, 0, 0.35, 1) |
Snap transition applied to the carousel element. Set per-container by setEasing(). |
| Property | Type | Default | Description |
|---|---|---|---|
id |
string |
'' |
Element ID |
rotation |
number |
0 |
Current rotation in degrees |
panelCount |
number |
— | Number of panels |
panelSize |
number |
— | offsetWidth of the carousel element |
theta |
number |
— | Degrees per face (360 / panelCount) |
radius |
number |
— | Cylinder radius in pixels |
sideIndex |
number |
0 |
0-based index of the front-facing panel |
sensitivity |
number |
1.0 |
Drag multiplier |
backgroundColor |
string |
'rgb(209,209,209)' |
Default panel background |
maximized |
boolean |
false |
Whether the carousel is currently maximized |
callback |
function\|null |
null |
Called on every panel change |
Requires: CSS transform-style: preserve-3d, CSS Custom Properties, Pointer Events API, ES6 classes.
Tested on: Chrome 120+, Firefox 121+, Safari 17+, Chrome for Android, Safari for iOS.