Slider
A complete slider library with a batteries-included API and polished animation engine.
A composable and deeply customizable gallery system for React with stunning animations, layout primitives, SSR-stable skeletons and rich fullscreen API.
These cover the core gallery patterns while sharing responsive controls, loading states, reveal transitions, and fullscreen sync.
A complete slider library with a batteries-included API and polished animation engine.
Direct-child CSS Grid layout with auto-fill columns, explicit responsive tracks, and item spans.
Measured, server-predicted layouts for uneven cards, with balanced, round-robin, horizontal-order placement and responsive spans.
Structured editorial rows where each entry can own text, metadata, and an embedded slider, grid, or masonry gallery.
A fullscreen carousel that can run standalone or stay synced with a base layout, featuring composable UI layers and image inspection.
Captions that render as part of the fullscreen slide.
A synced navigation rail that can sit alongside slide or overlay captions.
Viewport-attached caption panels layered over fullscreen content.
All content and navigation layers support independent placement on any side of the viewport:
Caption width and height accept numeric pixel values, px strings, percentage strings, and responsive maps. Percent widths resolve against the viewport width, while percent heights resolve against the viewport height.
Displays the current index and total count.
You can choose between:
Transition duration and easing are fully customizable, with 0ms producing an instant slide change.
Closing is context-aware and designed to keep the return animation always on-screen. The close interaction is applied to both the close control and the overlay itself (tap/click the overlay to close). The fullscreen slide can be dragged vertically to close the modal.
With the Control and Utility Layers API, you can:
The opening and closing transitions originate directly from the thumbnail's visible crop, animating both the clip-path and rendered fullscreen surface in perfect sync to create a seamless, cinematic morph into fullscreen. React Motion Gallery uses a nested clip stack: one clipper for the media crop plus up to two clipping ancestors, preserving thumb, container and viewport masks through the transition. Transitions can be transform-based (default) or a fade effect. Duration and easing are customizable for both, with 0ms producing an instant change.
Designed for a fast, intentional single click/tap interaction. Zooming in/out near image bounds will keep them flushed to the viewport if necessary. Rapidly toggling zoom stays incredibly smooth, and zoom animations remain stable inside loop seams. If a zoom-in is triggered during an active carousel animation or when a slide isn't centered, the carousel will automatically animate the slide towards the center of the viewport.
Clicking prev/next arrows or a thumbnail while zoomed in automatically triggers a zoom-out animation while simultaneously changing slides.
You can tweak:
Uses the same fluid animation engine powering freeScroll drag in the base slider, but configured for both the x and y axes.
Wheel and touchpad support are built in, and boundary interactions resolve with super smooth spring physics so overscroll and “rubber band” behavior feels natural.
You can tweak:
Driven by tracking two active pointers with a highly native, predictable feel, and stable scaling that stays locked to the user's intent. If a pinch is triggered during an active carousel animation or when a slide isn't centered, the carousel will automatically animate the slide towards the center of the viewport.
Pinch also includes built-in wheel and touchpad support, so the same high-quality zoom behavior translates across devices and input methods.
You can tweak:
Fullscreen rendering is driven by a dedicated indexed item list, which keeps your base layout and fullscreen experience intentionally decoupled.
You can provide a simple list of URLs for image-first galleries, structured image or video items with metadata, or kind: "node" items for arbitrary React markup.
The base layout can render one thing while fullscreen renders its own content for the same position. The only shared contract is the index, which keeps navigation, thumbnails, captions, and transitions perfectly aligned.
That index contract also powers the lazy-load handshake. As the base layout observes visible items, GalleryCore publishes the matching index so fullscreen can prewarm the full-resolution image or video before the modal opens. Once fullscreen is open, the fullscreen slider publishes its active index back through the same core, keeping lazy slides, thumbnail rails, captions, overlays, and base media state in sync.
If you want Next.js image optimization in fullscreen, render your own Image via fullscreen.renderImage. To opt custom renders into the built-in fullscreen spinner and decode flow, also enable fullscreen.lazyLoad.images.enabled.
import Image from "next/image"; fullscreen: { lazyLoad: { images: { enabled: true, }, }, renderImage: ({ item, className, baseStyle }) => ( <Image src={item.src} alt={item.alt ?? ""} width={item.width ?? 1600} height={item.height ?? 1200} sizes="100vw" className={className} style={{ ...baseStyle, position: "static", width: "auto", height: "auto", display: "block", }} /> ), }
Slider motion runs on a fixed timestep with alpha interpolation, keeping the simulation stable while rendering smoothly across different refresh rates.
Interaction is powered by a custom DragTracker that samples recent movement, carries release velocity into snap resolution, and still treats a true stop as stillness. Flicks feel intentional without making tiny paused gestures accidentally advance.
That release model avoids the common “flick into a wall” feeling: momentum continues through the handoff, then resolves into the final snap instead of abruptly dying on the current slide.
Looping is handled by responsive clones plus vector rebasing. Clone counts scale from a minimum buffer to the number of cells visible in the viewport, so the loop seam has enough content to stay covered during fast drags.
Looping automatically disables when there is only one item or the content already fits inside the viewport, avoiding unnecessary clones when the track cannot actually scroll.
Video clones are non-interactive snapshots of the original slide rather than extra live players. Snapshots refresh as playback state changes, so looping video galleries stay visually coherent without running duplicate players.
By default, the Base Slider uses one cell per slide. When groupCells is enabled, the slider automatically groups cells based on what is visible inside the slider's viewport. As the slider's viewport resizes, slides are rebuilt so they stay accurate across breakpoints and layout changes. When looping is disabled, the final snap target clamps to the maximum scroll position so you never overshoot past the end of the track.
Many layout and presentation props support responsive customization out of the box. Properties like layout.cellsPerSlide, layout.gap, and the standalone Skeleton slider layout accept breakpoint-aware values.
// Using default breakpoint keys (xs / sm / md / lg / xl) <Slider layout={{ cellsPerSlide: { xs: 1, sm: 2, md: 3, lg: 4, xl: 5, }, }} > {children} </Slider> // Using custom breakpoint values (explicit viewport widths) <Slider layout={{ cellsPerSlide: { 0: 1, // mobile 640: 2, // small tablets 768: 3, // tablets 1024: 4, // desktops 1280: 5, // large screens }, }} > {children} </Slider>
Any styling hook that accepts a className can be driven by your own stylesheets and media queries, including containers, viewports, thumbnail regions, controls, and individual thumbnail items.
Visual effects are first-class rather than plugin-only: parallax, scale, fade, and crossfade integrate directly with slider motion.
For UI, you can use the built-in arrows, dots, progress, scrollbar, and ripple — or supply your own renderers and styles.
The Base Slider exposes an imperative handle for advanced surfaces: move to an index, jump instantly, scroll next or previous, read progress, detect visible cells, and access the current root, viewport, container, and slide nodes.
When Slider is used as the primary layout inside GalleryCore, the shared gallery API can manage the item list too, including append, prepend, insert, remove, replace, and set-all operations.
Wheel and trackpad input are handled by the core slider engine. It detects horizontal or vertical intent, preserves momentum, temporarily pauses autoScroll/autoPlay, and respects scroll limits when loop is disabled.
The Thumbnails Slider is a lightweight companion to the Base Slider. It reuses the same motion primitives, runs free-scroll by default, and can switch back to normal snap behavior when you want stricter navigation.
It still supports groupCells, loop, and skipSnaps. Clicking a thumbnail animates the Base Slider to that index, and the active thumbnail can be centered when the strip has room to scroll.
Thumbnails can be placed on any side of the gallery — top, right, bottom, or left — automatically switching between horizontal and vertical behavior. Width, height, container sizing, centering, and per-item styling are all configurable, so it can act like a minimal filmstrip or a fully styled navigation rail.
Wheel and trackpad support are built in here too, following the thumbnail strip's active axis.
The Fullscreen Slider is a lighter snap-focused slider built for modal viewing. It shares the motion primitives without carrying every Base Slider layout option into fullscreen.
It uses one fullscreen item per snap, loops by default when more than one slide exists, and keeps the runtime focused on inspection, navigation, and close behavior.
Horizontal drag is prioritized for slide changes, while vertical drag can become a natural “pull-to-close” gesture, including fade feedback tied to distance, plus a smooth snap-back when the close threshold isn't met.
Videos are treated as first-class slides: dragging doesn't trigger Plyr controls/events, and players near the active slide are automatically paused to prevent multiple players from running at once.
Fullscreen index changes can use normal scroll motion or crossfade requests, with duration and easing configurable when desired.
Wheel and trackpad input are built in, including the same optional crossfade wheel gesture used by the Base Slider.
The Fullscreen Thumbnails Slider is a lightweight wrapper around the Thumbnails Slider. It reuses the same thumbnail engine, but wires it directly into the fullscreen index system so thumbnails always stay in sync with the active fullscreen slide.
Under the hood it creates a dedicated index channel that listens to fullscreen events and instantly updates the thumbnail highlight/scroll position. Clicking a thumbnail then calls fsSub.requestSet(idx, 'animated') so fullscreen navigates with the normal snap animation.
It also includes a small visibility layer for UI polish: the strip can fade and translate in or out via visible / invisible, with pointerEvents automatically disabled while hidden so it never blocks the fullscreen content.
Like the base thumbnail strip, it can sit on any side (top / right / bottom / left), supports centering for short rows, and exposes styling hooks for spacing, dimensions, and per-thumb className/style.
Grid is the direct-child layout for ordered gallery items. With no track config it builds an auto-fill grid from minColumnWidth; use columns for responsive equal-width tracks, or templateColumns for custom CSS Grid templates.
Reach for columns when you want a known track count: columns={12} gives you a familiar 12-track grid for feature spans, while smaller responsive counts work well for simple rows. Use templateColumns for asymmetric columns, sidebar-style compositions, or breakpoint-specific track definitions. When both are present, templateColumns wins over columns and minColumnWidth.
Spans require explicit tracks. Wrap a child in Grid.Item and set span to a number, "full", or a responsive map. In auto-fill minColumnWidth mode, spans are ignored because the track count is fluid.
Inside GalleryCore, each grid item can open fullscreen. Keep the default fullscreenTrigger="media" to open from the clicked media node, or switch to fullscreenTrigger="item" to make the full item shell interactive.
function GridGallery({ images }) { const { ref: gridRef, ready: gridReady } = useGridReady(); return ( <Skeleton ready={gridReady} layout={{ radius: 14, layout: { kind: "grid", count: images.length, item: { kind: "rect", style: { aspectRatio: "4 / 5" }, }, }, }} grid={{ count: images.length, minColumnWidth: 220, gap: { 0: 10, 900: 18 }, }} > <Grid ref={gridRef} minColumnWidth={220} gap={{ 0: 10, 900: 18 }} fullscreenTrigger="item" plugins={[gridLazyLoad()]} > {images.map((image) => ( <img key={image.src} src={image.src} alt={image.alt} /> ))} </Grid> </Skeleton> ); }
Masonry is built for galleries whose cards resolve to different heights. It renders from a deterministic, server-predicted track model, then measures live items and refines placement as images, videos, text, and responsive spans settle.
Use placement="balanced" to pack each card into the shortest fitting column group, roundRobin for deterministic column cycling, or horizontalOrder when wide cards should still read in a stronger left-to-right sequence. Live content stays hidden until the current item set has produced a real measurement pass, so visible placement is based on actual card geometry rather than height guesses.
Masonry.Item carries per-card layout metadata. A card can span multiple tracks, span "full", change span by breakpoint, and add wrapper class or style overrides while the placement engine clamps wide cards to the active column count.
Pair Masonry with the standalone Skeleton wrapper when you want a loading state. The Skeleton core is Masonry-aware: ratios or explicit heights seed card rhythm, structured slots can override spans and placeholder trees, and the real Masonry layout still owns measurement and readiness.
Masonry also keeps the shared gallery contracts: arbitrary React children, intro reveal, fullscreen triggers from the media node or whole item shell, root and item class hooks, rootRef, and a custom root element via as.
function MasonryGallery({ cards }) { const { ref: masonryRef, ready: masonryReady } = useMasonryReady(); return ( <Skeleton ready={masonryReady} layout={{ ratios: [55, 90, 130, 75], radius: 12, layout: { kind: "masonry", item: { kind: "rect", style: { width: "100%", height: "100%" }, }, slots: [{ span: { 0: "full", 1100: 2 } }], }, }} masonry={{ count: cards.length, columns: { 0: 1, 700: 2, 1100: 3 }, gap: { 0: 12, 1100: 20 }, placement: "balanced", }} > <Masonry ref={masonryRef} columns={{ 0: 1, 700: 2, 1100: 3 }} gap={{ 0: 12, 1100: 20 }} placement="balanced" plugins={[masonryLazyLoad()]} > {cards.map((card) => ( <Masonry.Item key={card.id} span={card.featured ? { 0: "full", 1100: 2 } : 1}> <img src={card.src} alt={card.alt} /> </Masonry.Item> ))} </Masonry> </Skeleton> ); }
Entries is the structured-data surface for record-driven galleries. Instead of rendering anonymous children, you pass records with arbitrary fields plus a media array, then shape the row with render.card, render each media item with render.media, and render fullscreen entry context with render.overlay. That makes it a natural fit for product and customer reviews, editorial feeds, case studies, or any UI where the media belongs to a richer record.
Each entry's media can be laid out as a slider, grid, or masonry block through renderMediaContainer. Under the hood, the runtime flattens every entry's media into one fullscreen index space while preserving the entry and local media index for each slide.
That ownership model is what makes fullscreen overlays, scroll-to-entry close behavior, and per-entry slider synchronization work without forcing your base UI into a rigid schema.
Entry loading uses two IntersectionObserver windows. nearMargin mounts the row and starts media work before it reaches the viewport, while viewMargin and threshold mark the actual reveal gate. With waitForDecode enabled, an entry with trackable media stays on its skeleton until every tracked media URL has loaded and decoded; the current entry-level gate tracks image media and falls back at the decode timeout.
The fade-in order follows readiness, not just DOM order. Rows become revealable when their in-view gate and decode gate are both satisfied, then receive the next intro delay slot based on when they actually finished loading. Fast entries can fade in while slower entries keep their skeleton layer visible.
The fullscreen close path uses the same readiness contract. If someone opens fullscreen, navigates to a slide owned by an entry they have not viewed yet, and closes from there, React Motion Gallery resolves the slide back to its owner entry, shows a temporary loading spinner while that entry mounts and decodes, scrolls the row into view, forces the skeleton and content layers into their final revealed state, and only then runs the close animation back to the now-visible media.
const flat = flattenEntries(entries); <GalleryCore layout="entries" fullscreenItems={flat.flattenedMedia}> <Entries entries={{ items: entries, mediaLayout: "grid", render: { card: ({ entry, media }) => ( <article className="entryCard"> <h3>{entry.title}</h3> <p>{entry.excerpt}</p> {media} </article> ), overlay: ({ entry, opacity, style, containerProps }) => ( <div {...containerProps} style={{ ...style, opacity }}> <strong>{entry.title}</strong> </div> ), }, loading: { enabled: true, waitForDecode: true, }, }} renderMediaContainer={({ mediaNodes }) => ( <Grid columns={{ 0: 1, 800: 2 }} gap={12}> {mediaNodes} </Grid> )} /> </GalleryCore>
Video is the gallery-aware, Plyr-backed primitive you can use standalone, inside one of the four primary layouts (Slider, Grid, Masonry, or Entries), or in fullscreen flows. It can build a default MP4 Plyr source from src and poster, accept a full Plyr source, or use a sourceBuilder; player options can be fixed or resolved from { src, index }. Plyr mounts lazily by default, uses preload: "none" unless autoplay is enabled, and exposes a replaceable video spinner.
Looping sliders and fullscreen clone/crossfade previews render non-interactive snapshots instead of extra live players. HTML5/MP4 snapshots can refresh from the current frame and control state, while YouTube and Vimeo fall back to poster-backed snapshots. That keeps loop and transition previews visually coherent without duplicating playback, controls, or network work.
Fullscreen coordinates the base and fullscreen players: the base player is suspended while fullscreen is open, offscreen fullscreen players are paused, inactive/lazy video slides can stay static until needed, and drag/post-drag events are guarded around Plyr controls. Fullscreen lazy loading is split between lazyLoad.images and lazyLoad.videos, while standalone Video keeps its own lazyLoad controls.
Video support is optional. If you never render Video, you do not need the plyr or plyr-react peer dependencies.
<div style={{ width: "100%", aspectRatio: "16 / 9" }}> <Video src="/trailers/lookbook.mp4" poster="/trailers/lookbook-poster.jpg" options={({ index }) => ({ controls: ["play", "progress", "mute", "fullscreen"], ratio: "16:9", })} lazyLoad={{ enabled: true, spinner: ({ kind }) => <Spinner label={kind} />, }} /> </div>
Slider, Grid, and Masonry now keep loading UI outside the layout runtime. Compose them with Skeleton and the matching readiness hook: useSliderReady, useGridReady, or useMasonryReady. Entries and thumbnails still keep their specialized loading layers because they own row/thumbnail-specific viewport behavior.
During development, Skeleton.force can keep the skeleton layer visible for visual comparison. Pass an object with enabled, showContent, and skeletonOpacity to preview the ready UI underneath a translucent skeleton, making spacing, text bars, and layout drift easy to spot before shipping.
The shared Skeleton.timing controls make skeletons feel intentional instead of flickery. minVisibleMs keeps the loading layer on screen for a minimum amount of time before it can exit, while exitMs controls the fade-out duration and how long the exiting layer remains mounted once real content is ready.
Slider, Grid, and Masonry skeleton layouts use a small composable node DSL with shapes like rect, circle, text, row, and stack. Masonry adds a layout-aware spec on top: ratios or explicit heights seed card rhythm, placement follows the same balanced, round-robin, or horizontal-order model as the real layout, and per-slot overrides can change spans, wrapper styling, heights, ratios, or the placeholder tree itself.
During skeleton development, the browser-measured text workflow can scan a live page in headless Chrome and generate the exact lines, barWidth, lastBarWidth, and text metric values (barHeight, lineHeight) used by skeleton text nodes. That keeps responsive cards, entries, equal-height sliders, and reflow-sensitive masonry placeholders aligned with the real content instead of hand-guessed bars.
The browser manifest can define the viewport scan range with viewportMin and viewportMax, set the scan height with viewportHeight, and split work across multiple browser pages with viewportWorkers. You can have an AI agent add selectors, write the manifest, run the generator, and wire the generated sidecar with a simple prompt, or run the script yourself to produce the same .skeleton-text.generated.ts module.
Entry loading is intentionally different because rows use two observer windows instead of a single fade-in. nearMargin mounts the row and starts image decode before it reaches the viewport, viewMargin records when the row has actually entered view, and threshold, waitForDecode, and decodeTimeoutMs decide when the skeleton can hand off to content.
Each entry can reserve row height with loading.minHeight, resolve a shared or per-entry structured skeleton from loading.skeleton, override the skeleton with render.skeleton, style the wrapper with loading.skeletonWrap, and tune force/compare states with loading.force.
const { ref: sliderRef, ready: sliderReady } = useSliderReady(); <Skeleton ready={sliderReady} force={{ enabled: false, showContent: true, skeletonOpacity: 0.45 }} timing={{ minVisibleMs: 220, exitMs: 600 }} layout={{ mode: "fit", visibleCount: { 0: 1, 900: 3 }, layout: { kind: "slider", count: 3, item: { kind: "stack", children: [ { kind: "rect", style: { aspectRatio: "4 / 5" } }, { kind: "text", barHeight: 16, lineHeight: 1.35, lines: { 0: 2, 900: 1 }, lastBarWidth: "56%", style: { width: "88%" }, }, ], }, }, }} > <Slider ref={sliderRef}>{slides}</Slider> </Skeleton> <Entries entries={{ items: entries, loading: { enabled: true, minHeight: 320, nearMargin: "700px 0px", waitForDecode: true, }, }} renderMediaContainer={({ mediaNodes }) => <Grid>{mediaNodes}</Grid>} />
Slider, Grid, and Masonry keep lazy media as explicit plugins, so the base layout imports stay small. The lazy-load option shape stays intentionally small: enable it, keep the built-in spinner, or replace that spinner with your own React node or resolver based on { kind, isClone }.
Video keeps a direct lazyLoad prop because it is already a media runtime. Fullscreen uses fullscreenLazyLoad() and splits configuration into lazyLoad.images and lazyLoad.videos so you can tune image and video behavior independently.
Fullscreen lazy loading runs in two stages. Base viewport visibility preloads the matching fullscreen media early, while fullscreen index changes decide which canonical slide is allowed to mount or apply its source. Images keep decoded media warm after first reveal; videos can be prewarmed from their poster/source and then force-mounted so navigation lands on prepared media instead of a blank fullscreen slide.
Entries does not expose a top-level lazy-load prop because entry rows already have viewport/decode gating. If you want per-item lazy behavior inside an entry, apply a lazy-load plugin to the embedded Slider, Grid, or Masonry, or use the direct lazyLoad prop on embedded Video components.
<Slider plugins={[sliderLazyLoad({ spinner: true })]} /> <Grid plugins={[ gridLazyLoad({ spinner: ({ kind }) => <Spinner label={kind} />, }), ]} /> <Masonry plugins={[masonryLazyLoad()]} /> <Video lazyLoad={{ enabled: true }} /> useFullscreenController({ plugins: [ fullscreenSlider(), fullscreenLazyLoad({ images: { enabled: true }, videos: { enabled: true, spinner: false }, }), ], });