@present-day/carousel
v0.0.1
Published
Headless React carousel built on native CSS scroll snap
Maintainers
Readme
@present-day/carousel
Headless React carousel built on native CSS scroll snap. The browser owns the physics; the library owns state, navigation, and accessibility.
Why this and not Swiper/Embla?
- Native physics, zero JS in the scroll path. Swiper/Embla reimplement momentum/snapping in JavaScript; this library lets CSS scroll snap do it, so swipes feel exactly like the platform because they ARE the platform.
- Built on the 2026 primitives. Active-slide state comes from the browser's own
scrollsnapchanging/scrollsnapchangeevents (Chrome/Edge 129+, Safari 18.2+) with a tiny fallback — not from reverse-engineered scroll math as the primary path. - Tiny. Zero runtime dependencies; a thin state/a11y layer over what the browser already does.
- Headless. No bundled CSS, no theme to fight — style everything via
classNames, including fully custom markers viarenderMarker. - Accessible by default. WAI-ARIA carousel pattern: labeled slides, marker tablist with roving tabindex and keyboard nav, honest
prefers-reduced-motionhandling.
No infinite looping or autoplay. Looping requires scroll-teleport hacks to fake an infinite track; autoplay just needs a timer, but it's out of scope to keep this library tiny and headless. If you need either, use Embla.
Install
bun add @present-day/carousel
# or
npm i @present-day/carouselPeer dependencies: react, react-dom (v18 or v19).
Basic usage
import { useState } from 'react'
import { Carousel } from '@present-day/carousel'
const [current, setCurrent] = useState(0)
<Carousel aria-label="Featured products" onSlideChange={setCurrent}>
<Carousel.Slide className="slide">1</Carousel.Slide>
<Carousel.Slide className="slide">2</Carousel.Slide>
<Carousel.Slide className="slide">3</Carousel.Slide>
<Carousel.Markers />
<Carousel.Prev>←</Carousel.Prev>
<Carousel.Next>→</Carousel.Next>
</Carousel>Carousel.Markers, Carousel.Prev, and Carousel.Next are optional — mix in whichever controls you need, in any order, as siblings of the slides.
Slide sizing
Slides size themselves via CSS — the library never sets widths. width: 100% on your slide class gives one slide per page; width: 50% gives two.
.slide { width: 100%; } /* one per page */
.slide-half { width: 50%; } /* two per page */Props
Carousel
| Prop | Type | Default | Description |
| ---- | ---- | ------- | ----------- |
| children | ReactNode | — | Carousel.Slide children plus any controls (Carousel.Markers, etc.) |
| defaultSlide | number | 0 | Initial slide when uncontrolled |
| slide | number | — | Controlled active slide index |
| onSlideChange | (index: number) => void | — | Fires when the settled slide changes (after snap completes) |
| orientation | 'horizontal' \| 'vertical' | 'horizontal' | Scroll axis |
| align | 'start' \| 'center' | 'start' | scroll-snap-align of slides |
| classNames | { root?, track?, slide? } | — | Class names for the root, track, and slides |
| aria-label | string | — | Accessible name for the carousel region |
| className | string | — | Added to the root element |
| style | CSSProperties | — | Added to the root element |
Carousel.Slide
| Prop | Type | Default | Description |
| ---- | ---- | ------- | ----------- |
| children | ReactNode | — | Slide content |
| className | string | — | Added to the slide element |
| style | CSSProperties | — | Added to the slide element |
Carousel.Markers
| Prop | Type | Default | Description |
| ---- | ---- | ------- | ----------- |
| classNames | { list?, marker?, markerActive? } | — | Class names for the tablist, each marker, and the active marker |
| renderMarker | (index: number, active: boolean) => ReactNode | — | Fully custom marker content |
| aria-label | string | 'Slides' | Accessible name for the marker tablist |
Renders nothing when there's one slide or fewer.
Carousel.Prev / Carousel.Next
| Prop | Type | Default | Description |
| ---- | ---- | ------- | ----------- |
| children | ReactNode | — | Button content |
| className | string | — | Added to the button |
| style | CSSProperties | — | Added to the button |
| aria-label | string | 'Previous slide' / 'Next slide' | Accessible name |
Both buttons disabled at their respective end of the carousel.
Vertical orientation
<Carousel
aria-label="Story"
orientation="vertical"
classNames={{ track: 'vertical-track' }}
>
<Carousel.Slide className="slide">1</Carousel.Slide>
<Carousel.Slide className="slide">2</Carousel.Slide>
</Carousel>.vertical-track { height: 240px; } /* required: constrains the scroll axis */classNames.track must constrain the track's height, or there's nothing to scroll within. The track also sets overscroll-behavior on its scroll axis so a vertical carousel doesn't fight the page for scroll.
How it works
Three tiers of progressive enhancement, each falling back gracefully:
| Tier | Mechanism | Support |
| ---- | --------- | ------- |
| Physics | CSS scroll snap (scroll-snap-type, scroll-snap-align) | All browsers |
| State | scrollsnapchanging / scrollsnapchange events | Chrome/Edge 129+, Safari 18.2+ — rAF scroll-math fallback elsewhere (Firefox, older Safari) |
| Navigation | scrollTo({ behavior: 'smooth' }) | All browsers — instant under prefers-reduced-motion |
Constraints
Carousel.Slide must be a direct child of Carousel. Mapping an array of slides is fine; wrapping slides in your own components or fragments is not supported — the root partitions children by child.type === Slide.
Zero-JS dots
Supporting browsers can skip Carousel.Markers entirely and render dots with pure CSS:
/* Chrome/Edge 135+ — no React markers needed. `::scroll-marker` support is
still landing across browsers; check current support before relying on it
as your only navigation (caniuse: css-scroll-marker). */
.my-track {
scroll-marker-group: after;
}
.my-track::scroll-marker-group {
display: flex;
gap: 8px;
justify-content: center;
}
.my-slide::scroll-marker {
content: '';
width: 10px;
height: 10px;
border-radius: 50%;
background: #ccc;
}
.my-slide::scroll-marker:target-current {
background: #333;
}Styling hooks
The root element carries data-active-slide={index} at all times, so you can target the active state from CSS without touching React:
[data-active-slide='0'] .progress { width: 20%; }License
MIT
