@zinsani/carousel-react
v0.3.0
Published
A headless, accessible React carousel built with native CSS scroll-snap — zero styling opinion, zero runtime dependencies beyond React.
Maintainers
Readme
carousel-react
A headless, accessible React carousel. No drag/gesture library, no CSS to import, no styling opinion — sliding is powered entirely by native CSS scroll-snap, and the responsive mobile/desktop switch is a small matchMedia hook. Style it however you like with className/style.
Install
npm install @zinsani/carousel-reactreact and react-dom (^18 || ^19) are peer dependencies — nothing else. There's no companion stylesheet to import.
Usage
The carousel is a compound component — Carousel.Root provides shared state via context, and the rest are composable, unstyled parts. Every part accepts className/style, so bring your own CSS:
import { Carousel } from '@zinsani/carousel-react'
function Example() {
return (
<Carousel.Root cardsToShow={4} gap={16}>
<Carousel.ItemGroup aria-label="Featured cards">
{items.map((item) => (
<Carousel.Item key={item.id}>{/* your card content */}</Carousel.Item>
))}
</Carousel.ItemGroup>
<Carousel.Control>
<Carousel.PrevTrigger />
<Carousel.NextTrigger />
</Carousel.Control>
<Carousel.IndicatorGroup />
</Carousel.Root>
)
}Behavior
Desktop (≥
breakpoint, default 576px): showscardsToShowcards edge-to-edge, auto-sized to fill the container. Arrow buttons slide by exactly one container-width (i.e.cardsToShowcards) per click, and native scroll clamping means the last click on an uneven card count slides only as far as needed to land the final card flush against the edge — no special-casing required.Mobile (below
breakpoint): showsmobileCardsToShowcards per page (default 1) withmobilePeekof room for the next card to peek through, andmobileInsetkept as a gutter so the first and last cards rest inset rather than jammed against the container's edges. Cards still scroll through that gutter — it offsets where they settle, it doesn't clip them. Swipe left/right to slide — native touch scrolling, no gesture library. Arrow buttons render nothing at all (not just visually hidden) below the breakpoint.With the default
mobileCardsToShow={1}the active card is centred, so a sliver of both neighbours shows and the first/last cards settle against the gutter. Any higher value aligns pages to the start edge instead — a page of several cards has no single card to centre — so only the next card peeks, on the trailing side.
Styling
The library ships zero visual or positioning opinion — no colors, no shadows, no button layout. What it does apply inline, because the carousel can't function without it: the scroll container setup (overflow-x, scroll-snap-type), computed item widths, and the CSS custom properties (--carousel-gap, --carousel-cards-to-show, --carousel-mobile-peek) those widths depend on. Everything else is yours:
- Every part takes
classNameandstyle, applied on top of (never overriding) the library's own functional inline styles. Carousel.PrevTrigger/NextTriggerset the nativedisabledattribute anddata-disabledat the start/end of scroll range, so you can target either:disabledor[data-disabled]in your own CSS.Carousel.Rootisposition: relativeinternally, so absolutely-positioned children (e.g. arrows straddling the edge of the container) work out of the box.
playground/App.tsx is a complete worked example — it styles the arrows (bled over the container edge, colors, hover, disabled state) and the mobile peek entirely via className, using PandaCSS, with zero changes to the library itself. Any styling approach works the same way (CSS Modules, Tailwind, plain CSS, styled-components, etc.) since the library doesn't care.
Parts & props
| Component | Props |
|---|---|
| Carousel.Root | cardsToShow (number, required) · mobileCardsToShow (number, default 1) · gap (px, default 16) · breakpoint (px, default 576) · mobilePeek (px, default 32) · mobileInset (px, default 16 — mobile only) · className · style |
| Carousel.ItemGroup | aria-label · className · style |
| Carousel.Item | Any native <div> prop (className, style, onClick, …) |
| Carousel.Control | Wrapper for the arrow buttons; renders null below breakpoint. className · style |
| Carousel.PrevTrigger / Carousel.NextTrigger | Any native <button> prop (className, style, aria-label, onClick is already wired, etc.) |
| Carousel.IndicatorGroup | Page dots. Renders one Carousel.Indicator per page automatically — pass indicatorClassName to style them. className · style · aria-label. For full control, pass a render function as children: it receives { pages, pageCount, activePage }. |
| Carousel.Indicator | A single dot. index (required) · any native <button> prop. Clicking scrolls to that page. |
Page indicators
Page count adapts to the layout — ceil(items / cardsToShow), using whichever count applies at the current width (so 7 cards at 2-per-view gives 4 dots, and 7 dots at the default 1-per-view on mobile). The active dot tracks whatever moved the carousel — arrow clicks, indicator clicks, or a plain swipe.
The active indicator gets data-active and aria-current, which is all you need to style it:
<Carousel.IndicatorGroup className={dotRow} indicatorClassName={dot} />.dot { width: 8px; height: 8px; border-radius: 9999px; background: #d1d5db; }
.dot[data-active] { width: 24px; background: black; } /* capsule */useCarousel()
Read carousel state and drive it imperatively from anywhere inside Carousel.Root. Use it for your own controls, or to react to clicks on card content — the library deliberately binds no handlers of its own there, so it can never fight with something interactive inside a card:
import { Carousel, useCarousel } from '@zinsani/carousel-react'
function Slides() {
const { isDesktop, activePage, scrollToItem } = useCarousel()
return items.map((item, index) => (
<Carousel.Item
key={item.id}
onClick={(event) => {
// your call: skip links/buttons, only on mobile, only when not active
if ((event.target as HTMLElement).closest('a, button')) return
if (!isDesktop && index !== activePage) scrollToItem(index)
}}
>
{/* … */}
</Carousel.Item>
))
}It must be called inside Carousel.Root (it throws otherwise), so put it in a child component like Slides above rather than alongside <Carousel.Root> itself.
| Returns | |
|---|---|
| activePage · pageCount | current page and total, for the width in play |
| isDesktop | true at/above breakpoint |
| canScrollPrev · canScrollNext | whether there's room to move |
| scrollPrev() · scrollNext() | move one page |
| scrollToPage(index) | jump to a page |
| scrollToItem(index) | jump to the page containing that item — no index / cardsToShow maths on your side |
Local development
This repo also contains a playground/ — a small Vite app (using PandaCSS) that consumes the library from source (../src) and demonstrates styling it. It's not part of the published package.
npm install
npm run dev # playground dev server
npm run build # library build → dist/ (what gets published)
npm run build:playground # playground demo build
npm run typechecknpm install runs panda codegen automatically (via prepare) to generate styled-system/ for the playground — it's gitignored, so if styled-system/* imports ever go missing, re-run npm run panda:codegen.
How it works
- No drag library: native
overflow-x: auto+scroll-snap-type: x mandatoryhandle touch/trackpad gestures for free. Arrow clicks callelement.scrollBy({ left: viewportWidth, behavior: 'smooth' })— the browser's own scroll clamping produces the "last card lands flush" behavior on uneven card counts. - No CSS-in-JS at runtime: all styling is either a plain inline
styleobject (the functional bits) or left to the consumer (className). - Responsive switch: a
useSyncExternalStore-backedmatchMediahook (src/carousel/use-media-query.ts) drivesisDesktopthrough context — no CSS media query inside the library at all.
