Packages & add-ons
What do I actually need?
The gallery is plain React. You only must install react
and react-dom
. The rest are optional add-ons used by specific variations (custom scrollbars, video slides) or by this demo app.
- Required:
react
,react-dom
- Optional:
simplebar
+simplebar-react
(nice scrollbars),plyr-react
(video slides) - Used in this repo’s demo app:
next
(not required to use the components) - TypeScript/dev:
typescript
,@types/*
, ESLint (optional)
Core install
npm i react react-dom
# TypeScript projects
npm i -D typescript @types/react @types/react-dom
Optional: SimpleBar (custom scrollbars)
A lightweight dependency used by the Thumbnails component to ensure consistent scrollbars (in terms of style and functionality) across all devices. It keeps the native overflow: auto scroll and only replaces the scrollbar visual appearance. Skip if you prefer native scroll.
npm i simplebar simplebar-react
# (optional TS types) npm i -D @types/simplebar
import 'simplebar-react/dist/simplebar.min.css';
import SimpleBar from 'simplebar-react';
export function Thumbs({ children }: { children: React.ReactNode }) {
return (
<SimpleBar autoHide={false} forceVisible="y" style={{ maxHeight: 200 }}>
<div className="flex gap-2">{children}</div>
</SimpleBar>
);
}
If your TS setup complains about simplebar-react
, add a shim:
declare module 'simplebar-react' {
import * as React from 'react';
import type SimpleBarCore from 'simplebar';
export interface SimpleBarProps extends React.HTMLAttributes<HTMLElement> {
forceVisible?: boolean | 'x' | 'y';
autoHide?: boolean;
scrollbarMinSize?: number;
scrollbarMaxSize?: number;
timeout?: number;
scrollableNodeProps?: React.HTMLAttributes<HTMLElement>;
contentNodeProps?: React.HTMLAttributes<HTMLElement>;
}
const SimpleBarReact: React.ForwardRefExoticComponent<
SimpleBarProps & React.RefAttributes<SimpleBarCore | null>
>;
export default SimpleBarReact;
}
Optional: Video slides (plyr-react)
Only needed if you want video inside slides.
npm i plyr-react
import 'plyr-react/plyr.css';
import { Plyr } from 'plyr-react';
export function VideoSlide({ src }: { src: string }) {
return <Plyr source={{ type: 'video', sources: [{ src }] }} />;
}
About Next.js in this repo
The repo uses Next.js for the demo/docs site. You don’t need Next.js to use the components—drop them into any React app.
# only if you’re building a Next app
npm i next
Data
Accepted shapes
Every slider accepts either a simple array of URLs or a typed array of MediaItem
s. The library normalizes both. Use MediaItem
when you want custom video posters or alt text. Mix images and videos freely; order is preserved.
// Simple
type Urls = string[];
// Rich (recommended when you need thumbs or alts)
export type MediaItem =
| { kind: 'image'; src: string; alt?: string }
| { kind: 'video'; src: string; alt?: string; thumb?: string };
Passing plain URLs
const urls = [
'https://.../image-1.jpg',
'https://.../13927516_3840_2160_60fps.mp4',
'https://.../image-2.jpg'
];
// Works with all sliders — they internally normalize:
<ThumbnailSlider urls={urls} />
<GroupedCellsSlider urls={urls} />
<ResponsiveSlider urls={urls} />
<HeroSlider urls={urls} />
<Autoplay urls={urls} />
<MediaQuerySlider urls={urls} />
Passing MediaItem[]
const items: MediaItem[] = [
{ kind: 'image', src: 'https://.../image-1.jpg', alt: 'Image 1' },
{ kind: 'video', src: 'https://.../13927516_3840_2160_60fps.mp4',
thumb: 'https://.../beach-video-thumb-portrait.jpg', alt: 'Beach video' },
{ kind: 'image', src: 'https://.../image-2.jpg', alt: 'Image 2' },
];
<ThumbnailSlider items={items} />
<GroupedCellsSlider items={items} />
<ResponsiveSlider items={items} />
<HeroSlider items={items} />
<Autoplay items={items} />
<MediaQuerySlider items={items} />
Video posters (auto + custom)
- Automatic: if a video has no
thumb
, a poster is generated in-browser viauseVideoThumbnails.tsx
. - Custom: provide
thumb
on yourMediaItem
to override.
// Auto poster (no thumb provided)
{ kind: 'video', src: 'https://.../clip.mp4' }
// Custom poster
{ kind: 'video', src: 'https://.../clip.mp4', thumb: 'https://.../poster.jpg' }
Accessibility (alt text)
Include alt
on images and videos in MediaItem
for screen readers.
{ kind: 'image', src: 'https://.../hero.jpg', alt: 'Sunset over beach' }
Performance tips
- Use lower-res sources in the inline slider and high-res in fullscreen for best perf.
- Remote
https://
assets are fine; make sure they’re publicly accessible (CORS). - Large videos? Consider shorter loops or compressed sources; posters avoid autoplay on load.
Mapping your own data
Have a CMS? Map whatever shape you have into MediaItem
or a string[]
in one place.
// Example CMS → MediaItem
const items: MediaItem[] = cms.records.map(r =>
r.type === 'video'
? { kind: 'video', src: r.url, thumb: r.posterUrl, alt: r.title }
: { kind: 'image', src: r.url, alt: r.alt }
);
Building blocks
Overview
This library gives you three composable primitives for building premium media experiences: an inline slider for on-page browsing, a fullscreen modal + slider for immersive viewing, and gesture-driven zoom/pan/pinch. Use them standalone, mix and match, or wire them together for a seamless gallery flow.
- Inline Slider: smooth, physics-based free scroll with optional grouping/wrapping, wheel/touch/pointer navigation, and responsive layouts.
- Fullscreen Modal + Slider: fly-out transition from the thumbnail, edge-to-edge media, chevrons/counter UI, swipe/drag navigation, and smart wrapping that respects current index.
- Zoom · Pan · Pinch: click/tap to zoom, two-finger pinch on touch and trackpads, inertial panning, and safe bounds so images never “escape.”
Compose it your way
- Inline-only: product carousels, logo rails, editorial strips.
- Inline → Fullscreen: tap a slide to enter an immersive viewer, preserving the selected index.
- Fullscreen-only: open a media viewer from any trigger.
- Zoom layer anywhere: attach zoom/pan/pinch to any image element.
Inline Slider
Slide index is tracked via slideStore.tsx
. If you only want to use the inline slider with no fullscreen capabilities then refer to the Auto Scroll and Free Scroll components. Auto Scroll has wrapping logic while Free Scroll does not.
Fullscreen modal + slider
Slide index is tracked via fullscreenSlideStore.tsx
. Overlay, close button, chevrons and counter are wired up via the toggleFullscreen()
function inside Slider.tsx
.
All components have identical FullscreenModal.tsx
and FullscreenSlider.tsx
files with the exception of the Hero
component which sets sliderX
inside FullscreenModal.tsx
differently due to center cell alignment.
sliderX.current = totalWidth <= slider.current.clientWidth
? cellLeft
: isWrapping.current
? -matchSlide.target + (containerWidth - cellWidth) / 2 // center alignmnet
: -matchSlide.target;
Preload fullscreen images
When the component is mounted and wrapped list is ready, refs are created per item and images are preloaded:
useEffect(() => {
if (!wrappedItems.length) return;
imageRefs.current = wrappedItems.map(() => createRef());
wrappedItems.forEach(item => {
const img = new Image();
img.src = item.src;
});
}, [wrappedItems]);
Zoom, pan & pinch
The zoom state (true for zoomed in, false for zoomed out) is tracked via scaleStore.tsx
. Panning (translate) transforms are applied to the fullscreen image container while zoom (scale) transforms are applied to the image itself.
Click / tap to zoom
- Triggered on pointer up inside
FullscreenSlider.tsx
, which calls yourhandleZoomToggle(e, imageRef)
. - The zoom amount (scale transform) is set via
clickScale
. Default value is2.5
.
Pan (when zoomed)
- Initiated with
handlePanPointerStart(e, imageRef)
on the fullscreen image container.
Pinch zoom (touch & trackpad)
- Both touch and wheel events use the same
zoomTo()
function for zooming in and out. - Look for
const finalZoom = clamp(destZoomLevel, 1, 3)
inside thezoomTo()
function to set your desired clamp for the zoom amount. Default max scale is3
.
Slide changes
- On
slideIndexSync
change the zoom state is reset.
Components
Thumbnails
One cell equals one slide. Works best when all images share the same dimensions. A horizontal scrollbar appears at ≤ 535px.
Usage
import ThumbnailSlider from '@/app/components/Thumbnails';
<ThumbnailSlider items={items} />
Size and Layout
- Overall height (desktop): set inline in
Slider.tsx
on both the outer container and the fade wrapper:<div ref={sliderContainer} className={styles.slider_container} style={{ position:'relative', height: imageCount > 2 ? '606px' : '600px' }} /> <div className={styles.fade_container} style={{ position:'relative', height: imageCount > 2 ? '606px' : '600px' }} />
- Overall height (mobile ≤ 535px): forced via a media query in
Slider.module.css
:@media (max-width: 535px) { .slider_container, .fade_container { height: 108.3vw !important; } }
- Wrapper defaults (desktop):
index.module.css
caps the visible area:.container { height: 606px; max-height: 606px; overflow-x: hidden; }
- Preview (right) column width (desktop): fixed at 400px:
.right_column { width: 400px; position: relative; overflow: hidden; } .image_container { position: absolute; left: 0; max-width: 400px; }
- Thumbnails column (desktop): 100px wide, vertical stack:
.thumbnail_container { width: 100px; flex-direction: column; gap: 4px; padding-right: 4px; } .thumbnails { max-width: 100px; width: 100dvw; object-fit: contain; }
- Mobile layout (≤ 535px): columns become a row of thumbnails under the preview, and the preview expands to the viewport width:
@media (max-width: 535px) { .container { height: 100%; max-height: 753px; } .columns_container { flex-direction: column-reverse; } .right_column_container, .right_column { width: 100dvw; } .thumbnail_container { flex-direction: row; width: 100%; } .image_container { width: calc(100% / 1.4); } }
width: calc(100% / 1.4)
shows 100% of one image plus 40% of the next one.
What to tweak (quick reference)
- Desktop overall height: change
606px / 600px
viaSlider.tsx
. - Mobile overall height: change
108.3vw
viaSlider.module.css
. - Preview width (desktop): change
400px
in.right_column
/.image_container
viaindex.module.tsx
. - Preview width (mobile): adjust
.right_column
(e.g., keep 100dvw) and.image_container
(e.g.,width: calc(100% / 1.2)
for larger preview). - Thumb size: change
max-width: 100px
in.thumbnails
andwidth: 100px
in.thumbnail_container
.
Mouse drag inside Thumbnail Container
The thumbnails rail supports physics-based drag scrolling with the mouse. This is scoped to the Thumbnails component only and doesn’t affect other sliders. It works on the actual scrollable element (SimpleBar’s inner scroller), not the wrapper.
Usage
import { useScrollDrag } from './useScrollDrag';
const { containerRef, handleMouseDown } = useScrollDrag();
// wire the hook to SimpleBar's scrollable element
useEffect(() => {
if (simpleBarRef.current) {
containerRef.current = simpleBarRef.current.getScrollElement() as HTMLElement;
}
}, [simpleBarRef, containerRef]);
<div
className={styles.thumbnail_container}
ref={thumbnailContainerRef}
onMouseDown={(e) => handleMouseDown(e.nativeEvent)}
onPointerOver={() => setIsHovering(true)}
onPointerLeave={() => setIsHovering(false)}
style={{ display: normalizedItems.length > 1 ? 'flex' : 'none' }}
>
{/* ...thumbnails... */}
</div>
What it does
- Mouse drag → scroll: converts horizontal mouse movement into
scrollLeft
changes. - Inertia: after mouseup, continues scrolling with velocity decay (friction) via
requestAnimationFrame
. - Bounds safety: clamps at the start/end of the scroll range; stops the loop when velocity is near zero.
- Coexists with SimpleBar: we target
simpleBarRef.current.getScrollElement()
, so custom track/scrollbar still work.
Why mouse only?
Touch and trackpads already provide native inertial scrolling; the hook adds parity for desktop mouse users without fighting the browser’s built-in touch behavior.
Grouped Cells
Cells are grouped per slide. Accommodates mixed image sizes. In the demo, height is 300px and becomes 50vw
at ≤ 600px. Wrapping is disabled once the second-to-last image is visible (you control when/why wrapping happens).
Usage
import GroupedCellsSlider from '@/app/components/Grouped Cells';
<GroupedCellsSlider items={heroItems} />
- Number of cells per slide adapts to how many fit in the container.
- You can gate wrapping based on any condition (e.g., visibility, index, width).
Size and Layout
The Grouped Cells slider uses a fixed height on desktop (300px
) and switches to a responsive height on small screens (50vw
). Three places must agree on height:
- Container shell:
.slider_container
and its inner.fade_container
(fromSlider.module.css
). - Media itself:
.image
(and.videoShell
for videos) inindex.module.css
. - Inline style on Slider.tsx: the
style={{ height: 300px }}
applied to both shells.
/* index.module.css */
.container { overflow-x: hidden; }
.image {
position: absolute;
left: 0;
height: 300px;
display: block;
object-fit: contain;
cursor: zoom-in;
user-select: none;
}
.image_container { position: relative; display: flex; }
@media (max-width: 600px) {
.image, .videoShell { height: 50vw !important; }
}
/* Slider.module.css */
@media (max-width: 600px) {
.slider_container, .fade_container { height: 50vw !important; }
}
/* Slider.tsx */
<div ref={sliderContainer}
className={styles.slider_container}
style={{ position: 'relative', height: '300px', backgroundColor: '#f8f9fa', zIndex: 1 }}>
{!isReady && <div className={styles.shimmerOverlay} aria-hidden />}
<div className={`${styles.fade_container} ${isReady && inView ? styles.fadeInActive : styles.fadeInStart}`}
style={{ position: 'relative', height: '300px' }}>
</div>
</div>
Responsive
Column-based layout that guarantees full image visibility per slide. The count of cells per slide is derived from your maxWidth
(220 in the demo) with a minimum of 2 visible slides. Best with uniform image sizes. Just look for the calculateImagesPerSlide()
function at the top of the index.tsx
file to change the maxWidth
value.
Usage
import ResponsiveSlider from '@/app/components/Responsive';
<ResponsiveSlider items={items} />
- Tune
maxWidth
in the component to control columns. - Great for galleries where every slide should be fully visible.
Using resize event listener
Located at the top of index.tsx
useEffect(() => { const handleResize = () => { const perSlide = calculateImagesPerSlide(); imagesPerSlide.current = perSlide; }; window.addEventListener("resize", handleResize); handleResize(); return () => window.removeEventListener("resize", handleResize); }, []);
Hero
A variation of the Grouped Cells slider with the only differences being centered slides, one cell per slide and height. Supports mixed image sizes.
Usage
import HeroSlider from '@/app/components/Hero';
<HeroSlider items={heroItems} />
- Ideal for banners and landing “hero” sections with a single focal slide.
Size and Layout
The Grouped Cells slider uses a fixed height on desktop (400px
) and switches to a responsive height on small screens (50vw
). Three places must agree on height:
- Container shell:
.slider_container
and its inner.fade_container
(fromSlider.module.css
). - Media itself:
.image
(and.videoShell
for videos) inindex.module.css
. - Inline style on Slider.tsx: the
style={{ height: 400px }}
applied to both shells.
/* index.module.css */
.container { overflow-x: hidden; }
.image {
position: absolute;
left: 0;
height: 400px;
display: block;
object-fit: contain;
cursor: zoom-in;
user-select: none;
}
.image_container { position: relative; display: flex; }
@media (max-width: 600px) {
.image, .videoShell { height: 50vw !important; }
}
/* Slider.module.css */
@media (max-width: 600px) {
.slider_container, .fade_container { height: 50vw !important; }
}
/* Slider.tsx */
<div ref={sliderContainer}
className={styles.slider_container}
style={{ position: 'relative', height: '400px', backgroundColor: '#f8f9fa', zIndex: 1 }}>
{!isReady && <div className={styles.shimmerOverlay} aria-hidden />}
<div className={`${styles.fade_container} ${isReady && inView ? styles.fadeInActive : styles.fadeInStart}`}
style={{ position: 'relative', height: '400px' }}>
</div>
</div>
Center Alignment
The code for centering slides is declared 6 times inside the Slider.tsx
file and once inside the FullscreenModal.tsx
file:
(containerWidth - cellWidth) / 2
One Cell Per Slide
The code for creating one slide per cell is inside the buildPages()
function which is inside the Slider.tsx
file. Active only if wrapping is enabled, else we pack as many cells as we can fit using the greedy packing algorihtm:
if (isWrapping.current) { // one‑cell slides data.forEach((d, idx) => { pages.push({ els: [d.el], target: idx === 0 ? 0 : d.left }); }); } else { // pack as many as can fit
Auto Scroll
Constant-speed horizontal scroll powered by a RAF (requestAnimationFrame) loop. You can easily apply auto scrolling to any slider. The Demo component uses grouped cells, wrapping and just the inline slider. Fullscreen mode is intentionally disabled here because I wanted to demonstrate a common yet simple use case which is a logo slider that auto scrolls. The auto-scrolling behavior is inside a tiny useEffect
that you can paste anywhere. Go inside Slider.tsx
then look for const SPEED = 0.1
. The useEffect
is right under that variable. You have full control over when to pause the auto scrolling (hover, pointer down, focus, when the fullscreen modal is open, etc.).
Usage
import AutoScroll from '@/app/components/Auto Scroll';
<AutoScroll urls={logoUrls} />
- Great for logo belts and marquee-style content.
Wrapping Correction
When applying auto scroll to a custom component, make sure to paste this code inside the wrapSelect()
function directly under the line that checks if isDragSelect.current
is false
.
const translateX = getCurrentXFromTransform(slider.current); const wrapBound = translateX < -slider.current.getBoundingClientRect().width; if (selectedIndex.current === 0 && wrapBound) { console.log('do nothing') } else { sliderX.current = translateX; }
What if Fullscreen Mode is enabled?
It's recommended to include the showFullscreenModal
state inside the useEffect dependency array and the early return check when using fullscreen features to optimize animation performance.
if ( !slider.current || !isWrapping.current || isPointerDown.current || showFullscreenSlider || isAnimating.current ) return; ... }, [showFullscreenSlider]);
Autoplay
Automatically advances to the next slide every N seconds (3s in the demo) using the next()
function which is inside a setInterval
. You can easily apply autoplay to any slider component. The code lives inside a tiny useEffect
that you can paste anywhere. Aside from SPEED_DELAY
, you can also tweak the value for RESUME_DELAY
which is how long you want to wait before resuming autoplay after a pointer up event. You have full control over when to pause the autoplay (hover, pointer down, focus, when the fullscreen modal is open, etc.).
Usage
import Autoplay from '@/app/components/Autoplay';
<Autoplay items={items} />
Media Query
Full control over how many cells to show per slide across breakpoints.
Usage
import MediaQuerySlider from '@/app/components/Media Query';
<MediaQuerySlider items={items} />
- Example: 2 cells on phones, 3 on tablets, 4 on desktop and 5 on large desktop.
Using resize event listener
Located at the top of index.tsx
useEffect(() => { const handleResize = () => { if (window.innerWidth > 0) { imagesPerSlide.current = 2 } if (window.innerWidth >= 767) { imagesPerSlide.current = 3 } if (window.innerWidth >= 1024) { imagesPerSlide.current = 4 } if (window.innerWidth >= 1500) { imagesPerSlide.current = 5 } }; window.addEventListener("resize", handleResize); handleResize(); return () => window.removeEventListener("resize", handleResize); }, []);
Free Scroll
No snapping to cells after a drag gesture. The component has no wrapping, cells are grouped and fullscreen mode is disabled. This is the simplest example in the list of components and it's the only one that doesn't wrap. So, instead of creating and passing clonedChildren
to the inline slider, we simply pass children
since we don't have to clone any cells.
Usage
import FreeScrollSlider from '@/app/components/Free Scroll';
<FreeScrollSlider items={items} />
Free Scroll Friction
You can adjust freeScrollFriction
which targets drag gestures. Normal friction
targets slider animations for the pagination dots and prev/next buttons.
function getFrictionFactor() { const frictionValue = isFreeScrolling.current === true ? freeScrollFriction : friction return 1 - frictionValue; }
Bounds Attraction
You can adjust boundsAttraction
which applies a force only on the first cell and last cell of the slider if the x
position passes the bounds during a drag animation. Normal attraction
targets slider animations for the pagination dots and prev/next buttons.
Physics
Attraction & Friction
Attraction
and friction
are the two options for controlling speed and motion. You can adjust these values for the inline slider, fullscreen slider and image panning.
Attraction
attracts the x | y
position to the selected cell/image bound. Higher attraction makes the animation move faster. Lower makes it move slower.
- Inline Slider: Default value is 0.025.
- Fullscreen Slider: Default value is 0.045.
- Image Panning: Default value is 0.015.
Friction
slows the movement of the animation. Higher friction makes the animation feel stickier and less bouncy. Lower friction makes it feel looser and more wobbly
- Inline Slider: Default value is 0.28.
- Fullscreen Slider: Default value is 0.36.
- Image Panning: Default value is 0.15.
Fixed-timestep (60 FPS) animations
RequestAnimationFrame updates are throttled to a fixed 60 FPS tick so animations feel identical on any display, even 90/120/144/240 Hz phones and monitors.
- We compute how much time passed since the last processed tick (msPassed = now - prevTimeRef.current).
- If it's less than one frame at 60 FPS (~16.67 ms), you skip doing work and schedule the next RAF. This prevents ultra-high-refresh devices from running your physics too fast.
- When enough time has passed, you process one tick and then set prevTimeRef.current = now - excessTime, where excessTime = msPassed % MS_PER_FRAME. That “carry the remainder” step keeps your tick aligned to real time and avoids long-term drift or jitter when frames aren't perfectly spaced.
Wrapping
Infinite Loop: Overview
The iconic infinite loop is achieved by (1) creating edge clones of the first/last items and (2) folding the track position back into a fixed range using modulo math. Every component in the Demos page has wrapping except for the FreeScroll slider.
When Wrapping Turns On
We compare how many items fit in view (visibleImages
) against the originals. If more originals than visible, we enable wrap mode and remember how many clones to add.
const per = visibleImages; const raw = Children.toArray(children).filter(isValidElement); isWrapping.current = (raw.length - 1) > per; clonesCountRef.current = isWrapping.current ? per : 0;
Edge Clones: Before & After
In wrap mode we prepend the last per
items and append the first per
items. Each cloned element is registered in cells.current
for measurements.
const per = visibleImages; const slides = [ ...raw.slice(-per).map((c, i) => cloneSlide(c, `before-${i}`, -per + i, cells)), ...raw.map((c, i) => cloneSlide(c, `original-${i}`, i, cells)), ...raw.slice(0, per).map((c, i) => cloneSlide(c, `after-${i}`, i, cells)), ];
Measure & Lock Layout
We first let CSS size slides, then measure widths, then lock widths inline. The initialtranslateX
offset equals the total width of the before clones so the first original is visually at x=0.
const widths = slides.map(sl => sl.getBoundingClientRect().width); slides.forEach((sl, i) => { sl.style.width = widths[i] + 'px'; }); const before = widths.slice(0, clonesCountRef.current); let runningX = -before.reduce((s, w) => s + w, 0); slides.forEach((sl, i) => { sl.style.transform = `translateX(${runningX}px)`; runningX += widths[i]; });
Define the Loop Period (sliderWidth)
sliderWidth.current
is the width of the originals only(exclude clones). This is the loop period — the distance after which the strip repeats.
const originals = widths.slice(clonesCountRef.current, widths.length - clonesCountRef.current); sliderWidth.current = originals.reduce((s, w) => s + w, 0);
Keep X in Range (Modulo Folding)
While dragging/scrolling in wrap mode, we fold the current x back into[-sliderWidth, 0]
. This connects the ends seamlessly.
let x = sliderX.current; x = ((x % sliderWidth.current) + sliderWidth.current) % sliderWidth.current; x -= sliderWidth.current; // now in [-sliderWidth, 0] setTranslateX(x);
Selecting Across the Seam (wrapSelect)
When a programmatic selection crosses edges, we nudge the track by ±sliderWidth
so motion stays continuous instead of “jumping” through clones.
const length = /* logical originals length */; if (!isDragSelect.current) { const wrapIndex = ((index % length) + length) % length; const delta = Math.abs(wrapIndex - selectedIndex.current); const backDelta = Math.abs(wrapIndex + length - selectedIndex.current); const fwdDelta = Math.abs(wrapIndex - length - selectedIndex.current); if (backDelta < delta) index += length; else if (fwdDelta < delta) index -= length; } if (index < 0) sliderX.current -= sliderWidth.current; else if (index >= length) sliderX.current += sliderWidth.current;
Paging & Targets Use Originals Only
Pages/snap targets are computed from originals only (clones stripped). Logical indices are always modulo the originals count so UI state never lands on a clone.
const wrapIndex = ((i % originalsCount) + originalsCount) % originalsCount; // build pages from originals and map back to logical indices
Resizing: Rebuild Clones & Pages
On resize we recompute visibleImages
, rebuild before/after clones, remeasure, and rebuild pages/targets. That keeps the loop seamless at every viewport size.
Wrapped vs Non-Wrapped
1) DOM: clones vs originals
- Wrapped: renders
clonedChildren
= before clones + originals + after clones. - Non-wrapped: renders originals only via
{children}
.
3) Measuring widths
- Wrapped: Every cell (clones + originals) is measured so each gets a locked width. However, the loop period (the distance that repeats) is computed from the originals only. That period is what defines “one full wrap”.
- Non-wrapped: Only the originals are measured and summed. That sum is the total scrollable width of the strip. If that total is ≤ the container width, the strip is considered “fit-to-center”.
4) Positioning cells (initial layout)
- Wrapped: Cells are laid out in sequence, but the starting offset is shifted left by exactly the width of the before-clones. Visually, you start on the first original cell, with valid content available immediately to the left and right.
- Non-wrapped: Cells are laid out starting at the strip’s left edge (no negative offset). If the content is narrower than the container, the whole strip is centered horizontally inside the viewport.
5) Coordinate space & movement
- Wrapped: The horizontal position is interpreted modulo the loop period. As you scroll, the value is folded back into the same visual range, so you never hit a hard boundary.
- Non-wrapped: The position is clamped between a left and right limit. You can’t scroll beyond the first cell or past the last fully visible page.
6) Page targets & snapping
- Wrapped: Page targets are computed from the originals. When you cross the seam, the internal position is shifted by exactly one loop period so the snapping remains continuous and seamless.
- Non-wrapped: Targets run from the first page (0 offset) to the last page (total width minus container). Attraction pulls you toward those discrete targets, with special handling near the edges.
7) Resize behavior
- Wrapped: Recompute “visible images”, rebuild the number of before/after clones, re-measure all cells, and reposition so the starting view still shows the first original. The loop period is updated from the new originals’ total.
- Non-wrapped: Re-measure originals and recompute page targets. If content now fits, center the strip; otherwise update the min/max bounds for clamping.
Cell Grouping
One Cell = One Slide (Hero Slider)
This variation treats every individual cell as its own slide. It's perfect for “hero” presentations where each image or card deserves the full spotlight and navigation should move one item at a time.
Where to copy from: open the Hero Slider's Slider.tsx
. Inside a useEffect
, you'll find a buildPages()
function. That function creates one page per original child—no packing or grouping logic—so the number of pages equals the number of cells. Just reuse that buildPages()
for this mode.
How it behaves: each slide aligns the selected cell cleanly in the viewport. Pagination dots equal the number of cells, and next/previous always advances exactly one cell. On resize, pages are rebuilt but the one-cell mapping stays stable: one item → one page.
Edge handling: for non-wrapping sliders, the last slide's target is clamped so you don't scroll past the end. If wrapping is enabled elsewhere, the modulo logic handles continuity but the page construction remains one-cell per page.
Group Cells to Fill the View (Grouped Cells Slider)
This variation packs as many cells that can fit into the container to form a slide. It's ideal for carousels showing multiple cards at once and snapping by “pages” of items.
Where to copy from: open the Grouped Cells Slider's Slider.tsx
. Inside a useEffect
, locate the same buildPages()
function. That version measures each child relative to the container and groups contiguous cells until adding another would overflow the container width. Each such group becomes one page.
How it behaves: the first slide starts at the container's left edge, intermediate slides start at the left offset of their first cell, and the final slide is adjusted so the rightmost content ends flush with the container. Pagination dots represent groups, and next/previous jumps one group at a time.
Responsive notes: because grouping depends on measured widths, the slides are rebuilt on resize. If the container grows or shrinks, the grouping boundaries shift automatically so each slide always contains fully visible cells.
Variable widths & edge cases: mixed cell widths are supported—the algorithm always includes at least one item per slide, even if the first cell is wider than the viewport. On very narrow screens, slides may become “one cell” by necessity, and expand into multi-cell groups on larger screens.