npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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-react

react 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): shows cardsToShow cards edge-to-edge, auto-sized to fill the container. Arrow buttons slide by exactly one container-width (i.e. cardsToShow cards) 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): shows mobileCardsToShow cards per page (default 1) with mobilePeek of room for the next card to peek through, and mobileInset kept 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 className and style, applied on top of (never overriding) the library's own functional inline styles.
  • Carousel.PrevTrigger/NextTrigger set the native disabled attribute and data-disabled at the start/end of scroll range, so you can target either :disabled or [data-disabled] in your own CSS.
  • Carousel.Root is position: relative internally, 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 typecheck

npm 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 mandatory handle touch/trackpad gestures for free. Arrow clicks call element.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 style object (the functional bits) or left to the consumer (className).
  • Responsive switch: a useSyncExternalStore-backed matchMedia hook (src/carousel/use-media-query.ts) drives isDesktop through context — no CSS media query inside the library at all.