@minusplusmultiply/axis
v1.0.0
Published
A modern, accessible full-bleed slider for React built on native scrolling and CSS scroll-snap
Maintainers
Readme
Axis
Axis is a React slider for full-bleed horizontal layouts that keeps native browser scrolling and CSS scroll-snap intact.
When To Use It
Use Axis when you want:
- full-bleed horizontal rails that align with your page container
- native touch, trackpad, wheel, and keyboard scrolling
- variable slide widths
- built-in controls or fully custom headless composition
- optional autoplay, thumbnails, virtualization, and infinite rails
Installation
pnpm add @minusplusmultiply/axisnpm install @minusplusmultiply/axisyarn add @minusplusmultiply/axisImport one stylesheet explicitly:
import "@minusplusmultiply/axis/styles";@minusplusmultiply/axis/styles is the browser-ready default stylesheet.
import "@minusplusmultiply/axis/styles/vanilla";@minusplusmultiply/axis/styles/vanilla is a compatibility import backed by the same built CSS asset.
If your app compiles Tailwind-authored CSS directly, use:
import "@minusplusmultiply/axis/styles/tailwind";The package does not auto-import CSS.
If your app is Tailwind-first and you want stronger class conflict resolution, install tailwind-merge in the consuming app. Axis works without it.
Quick Start
import { Axis, AxisContent, AxisControl } from "@minusplusmultiply/axis";
import "@minusplusmultiply/axis/styles";
export function FeaturedWorkSlider() {
return (
<Axis aria-label="Featured work" containerMax={1200} gutter="px-6" gap={24}>
<AxisContent>
<article className="w-[320px] rounded-2xl bg-neutral-900 p-6 text-white">
Project One
</article>
<article className="w-[460px] rounded-2xl bg-neutral-900 p-6 text-white">
Project Two
</article>
<article className="w-[320px] rounded-2xl bg-neutral-900 p-6 text-white">
Project Three
</article>
</AxisContent>
<AxisControl variant="paddles" />
</Axis>
);
}CSS-Only / No-JS
If you only want the base layout, scroll behavior, and scroll-snap styling, you can use the compiled stylesheet with plain markup and no React enhancements.
import "@minusplusmultiply/axis/styles";<section
class="axis"
data-axis-layout="full"
data-axis-align="start"
style="
--axis-container-max: 1200px;
--axis-page-gutter: 1.5rem;
--axis-gap: 24px;
"
>
<div class="axis-slider">
<div class="axis-track">
<div class="axis-slide"><article style="width: 320px;">Slide 1</article></div>
<div class="axis-slide"><article style="width: 460px;">Slide 2</article></div>
<div class="axis-slide"><article style="width: 320px;">Slide 3</article></div>
</div>
</div>
</section>CSS-only mode includes:
- full-bleed and contained layout math
- native horizontal scrolling
- CSS scroll-snap
- slide spacing and width constraints
CSS-only mode does not include:
- JS controls behavior
- autoplay
- active-slide state
- virtualization
- infinite rebasing
- keyboard enhancements
Core Patterns
Sizing examples
<Axis containerMax={1600} gutter={2} gap={32} />
<Axis containerMax="--container-max" gutter="--page-edge" gap="--card-gap" />
<Axis containerMax="max-w-7xl" gutter="px-8" gap="gap-6" />
<Axis containerMax="7xl" gutter="8" gap="6" />Layout modes
// Full-bleed viewport (default)
<Axis layout="full" containerMax={1200} gutter="px-6" gap={24} />
// Parent-contained viewport (no viewport-edge bleed)
<Axis layout="contained" containerMax={1200} gutter="px-6" gap={24} />In contained mode, Axis uses the parent/root width first and still caps the content width with containerMax.
| Option | Type | Default | Applies to | Notes |
| -------- | ---------------------- | -------- | ---------- | ---------------------------------------------------------------------- |
| layout | "full" | "contained" | "full" | Axis | full is viewport-bleed, contained keeps viewport in parent bounds. |
Standard controls
<Axis aria-label="Case studies" containerMax={1200} gutter="px-6" gap={24}>
<AxisContent>
{slides.map((slide) => (
<article key={slide.id} className="w-[360px]">
{slide.title}
</article>
))}
</AxisContent>
<AxisControl variant="paddles" />
<AxisControl variant="dots" />
</Axis>AxisControl supports paddles, dots, left, right, and thumbnails.
External controls
import { useRef } from "react";
import {
Axis,
AxisContent,
AxisControl,
type AxisControlsBinding,
} from "@minusplusmultiply/axis";
import "@minusplusmultiply/axis/styles";
export function ExternalControlsExample() {
const controlsRef = useRef<AxisControlsBinding | null>(null);
return (
<>
<header className="flex justify-end gap-3">
<AxisControl controls={controlsRef} variant="left" />
<AxisControl controls={controlsRef} variant="right" />
<AxisControl controls={controlsRef} variant="dots" />
</header>
<Axis
aria-label="Selected projects"
containerMax={1200}
controlsRef={controlsRef}
gutter="px-6"
gap={24}
>
<AxisContent>
{slides.map((slide) => (
<article key={slide.id} className="w-[320px]">
{slide.title}
</article>
))}
</AxisContent>
</Axis>
</>
);
}Headless composition
import {
Axis,
AxisPagination,
AxisSlide,
AxisTrack,
AxisViewport,
useAxisSlider,
} from "@minusplusmultiply/axis";
import "@minusplusmultiply/axis/styles";
function SliderStatus() {
const slider = useAxisSlider();
return (
<p>
{slider.activeIndex + 1} / {slider.slideCount}
</p>
);
}
export function HeadlessExample() {
return (
<Axis aria-label="Featured work" containerMax={1200} gutter="px-6" gap={24}>
<AxisViewport>
<AxisTrack>
<AxisSlide index={0}>Slide 1</AxisSlide>
<AxisSlide index={1}>Slide 2</AxisSlide>
<AxisSlide index={2}>Slide 3</AxisSlide>
</AxisTrack>
</AxisViewport>
<AxisPagination />
<SliderStatus />
</Axis>
);
}Advanced Patterns
Autoplay
<Axis autoplay />
<Axis autoplay={{ interval: 4000, mode: "loop" }} />
<Axis autoplay={{ interval: 4000, mode: "bounce", pauseOnHover: true }} />Supported autoplay modes are stop, loop, and bounce.
Autoplay is opt-in. When enabled, Axis pauses on focus and hover by default and exposes play, pause, and toggleAutoplay through the reactive and imperative control APIs so you can render an explicit pause/play button.
| Option | Type | Default | Applies to | Notes |
| ------------------------- | --------- | ------------------------------ | --------------- | ---------------------------------------------------------------------- |
| autoplay | boolean | AxisAutoplayOptions | false | Axis |
| enabled | boolean | true when object is provided | Axis autoplay | Explicit on/off flag for object form. |
| interval | number | 5000 | Axis autoplay | Delay between autoplay navigation steps in milliseconds. |
| mode | "stop" | "loop" | "bounce" | "stop" |
| pauseOnHover | boolean | true | Axis autoplay | Pauses while the pointer is over the root. |
| pauseOnFocus | boolean | true | Axis autoplay | Pauses while focus is inside the root. |
| pauseOnInteraction | boolean | true | Axis autoplay | Pauses autoplay during user navigation or direct scroll interaction. |
| autoResumeOnInteraction | boolean | true | Axis autoplay | When true, autoplay resumes automatically after interaction settles. |
| interactionResumeDelay | number | 150 | Axis autoplay | Delay in milliseconds before auto-resume after interaction. |
| pauseWhenHidden | boolean | true | Axis autoplay | Pauses when the document becomes hidden. |
| startOnMount | boolean | true | Axis autoplay | Starts autoplay immediately after mount when enabled. |
Thumbnail controls
<AxisControl
variant="thumbnails"
thumbnailItems={[
{ content: <img src="/thumb-1.jpg" alt="" />, ariaLabel: "Show slide 1" },
{ content: <img src="/thumb-2.jpg" alt="" />, ariaLabel: "Show slide 2" },
{ content: <img src="/thumb-3.jpg" alt="" />, ariaLabel: "Show slide 3" },
]}
/>| Option | Type | Default | Applies to | Notes |
| ---------------------------- | --------------------------------------------------------------------------- | ----------------------------- | ------------- | -------------------------------------------------------------- |
| variant | "thumbnails" | none | AxisControl | Enables thumbnail-button rendering. |
| thumbnailItems | Array<{ key?: React.Key; content: React.ReactNode; ariaLabel?: string; }> | Generated numeric items | AxisControl | Supply custom thumbnail content and labels per slide. |
| thumbnailClassName | string | "" | AxisControl | Applied to every thumbnail button. |
| activeThumbnailClassName | string | "" | AxisControl | Added when the thumbnail matches the current pagination index. |
| inactiveThumbnailClassName | string | "" | AxisControl | Added to non-active thumbnail buttons. |
| renderThumbnail | (props) => React.ReactNode | none | AxisControl | Full custom renderer for each thumbnail button. |
| controls | AxisControlsBinding | RefObject<AxisControlsBinding | null> | null |
Virtualized slide content
<AxisContent
virtualize={{ overscan: 1, estimateSize: () => 320 }}
slideCount={100}
renderSlide={({ index }) => <Card index={index} />}
/>Virtualization preserves all slide wrappers and only limits mounted slide content.
renderSlide receives additive state metadata:
isActiveisVisibleisMeasuredestimatedWidthmeasuredWidth
| Option | Type | Default | Applies to | Notes |
| ------------------- | ---------------------------------------------------- | ---------------------- | ------------------------ | -------------------------------------------------------------------------------- |
| virtualize | boolean | AxisVirtualizeOptions | false | AxisContent |
| overscan | number | 1 | AxisContent virtualize | Renders extra slides before and after the visible range. |
| estimateSize | (index: number) => number | string | () => "auto" | AxisContent virtualize |
| keepMountedActive | boolean | true | AxisContent virtualize | Keeps the active slide content mounted even if it falls outside the window. |
| slideCount | number | none | AxisContent | Required for virtualized rendering. |
| renderSlide | (params: AxisRenderSlideParams) => React.ReactNode | none | AxisContent | Required for virtualized rendering; receives logical and physical slide indexes. |
Unknown-size content and measurement
Axis treats async or unknown-width content as a geometry problem rather than a separate lazy-slide mode.
- non-virtualized rails tolerate unknown widths best
- virtualized rails should provide estimates when possible
- infinite rails work best with deterministic estimates
- strict infinite mode can defer rebasing until geometry is trustworthy
<AxisContent
measurement={{
estimate: (index) => widths[index] ?? 320,
cache: "memory",
strategy: "strict",
strictInfinite: true,
}}
virtualize={{ overscan: 1, estimateSize: () => "auto" }}
slideCount={slides.length}
renderSlide={({ index, isMeasured, measuredWidth }) => (
<Card data-ready={isMeasured} data-width={measuredWidth} index={index} />
)}
/>| Option | Type | Default | Applies to | Notes |
| ---------------- | ------------------------ | -------------------------- | ------------- | ------------------------------------------------------------------ |
| measurement | AxisMeasurementOptions | none | AxisContent | Configures fallback estimation, caching, and loop-safety strategy. |
| estimate | number | ((index: number) => number | "auto") | virtualizer estimate |
| cache | "memory" | false | "memory" | measurement |
| strategy | "tolerant" | "strict" | "tolerant" | measurement |
| strictInfinite | boolean | true in infinite mode | measurement | Prevents silent loop rebasing until strict geometry checks pass. |
Root callbacks and readiness
Axis exposes additive callbacks so consumers can react to geometry and autoplay state without inferring from DOM timing:
| Option | Type | Applies to | Notes |
| ----------------------- | ---------------------------------- | ---------- | --------------------------------------------------------------------- |
| aria-labelledby | string | Axis | Alternative to aria-label for heading-linked carousel labelling. |
| onMeasurementChange | (measurementByIndex) => void | Axis | Fires when logical measurement state changes. |
| onGeometryStabilized | (measurementByIndex) => void | Axis | Fires once all logical slides are at least estimated for the session. |
| onAutoplayStateChange | (isAutoplaying: boolean) => void | Axis | Fires when autoplay enters or leaves its active state. |
Infinite slider
<AxisContent
infinite
virtualize={{ overscan: 1, estimateSize: (index) => widths[index] }}
slideCount={slides.length}
renderSlide={({ index }) => <Card index={index} />}
/>Infinite mode is designed for virtualized slide rendering and works best when slide widths can be estimated accurately. If geometry remains low-confidence and measurement.strategy is "strict", Axis exposes canSafelyLoop = false and defers silent rebasing.
| Option | Type | Default | Applies to | Notes |
| ------------------- | ---------------------------------------------------- | ---------------------- | ---------------------- | ------------------------------------------------------------------------ |
| infinite | boolean | AxisInfiniteOptions | false | AxisContent |
| copiesPerSide | number | 2 | AxisContent infinite | Number of repeated copy bands rendered on each side of the logical set. |
| recenterThreshold | number | "viewport" | "viewport" | AxisContent infinite |
| virtualize | boolean | AxisVirtualizeOptions | required in practice | AxisContent |
| slideCount | number | none | AxisContent | Required because the logical slide count must be known. |
| renderSlide | (params: AxisRenderSlideParams) => React.ReactNode | none | AxisContent | Required for infinite mode; receives logical and physical slide indexes. |
