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 MediaItems. 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 via useVideoThumbnails.tsx.
  • Custom: provide thumb on your MediaItem 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 your handleZoomToggle(e, imageRef).
  • The zoom amount (scale transform) is set via clickScale. Default value is 2.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 the zoomTo() function to set your desired clamp for the zoom amount. Default max scale is 3.

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 via Slider.tsx.
  • Mobile overall height: change 108.3vw via Slider.module.css.
  • Preview width (desktop): change 400px in.right_column/.image_container via index.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 and width: 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 (from Slider.module.css).
  • Media itself: .image (and .videoShell for videos) in index.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 (from Slider.module.css).
  • Media itself: .image (and .videoShell for videos) in index.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.