Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

DisplayReact

Carousel

Slides in a row that scrolls, with buttons to the previous and next page, a tab per page and an optional rotation.

Rendered by React 19.3.1

Loading React…
import { Carousel } from '@hamur/react/carousel'

const slides = [
  { title: 'Dunes', text: 'Wind sets the shape of the sand.' },
  { title: 'Glacier', text: 'Ice that moves a few metres a year.' },
  { title: 'Harbor', text: 'Boats in at dusk, out before dawn.' },
  { title: 'Canyon', text: 'A river cut it over millions of years.' },
  { title: 'Forest', text: 'Rain on moss and old cedar.' },
]

export default function CarouselExample() {
  return (
    <Carousel.Root className="carousel" aria-label="Places">
      <div className="carousel-bar">
        <Carousel.Value className="carousel-value" />
        <Carousel.Previous
          className="button button-square carousel-arrow carousel-prev"
        />
        <Carousel.Next
          className="button button-square carousel-arrow carousel-next"
        />
      </div>
      <Carousel.Viewport className="carousel-viewport" tabIndex={0}>
        {slides.map((slide) => (
          <Carousel.Slide key={slide.title} className="carousel-slide">
            <p className="carousel-slide-title">{slide.title}</p>
            <p className="muted">{slide.text}</p>
          </Carousel.Slide>
        ))}
      </Carousel.Viewport>
      <Carousel.TabList className="carousel-tabs">
        {(page) => <Carousel.Tab page={page.index} className="carousel-tab" />}
      </Carousel.TabList>
    </Carousel.Root>
  )
}
Not measured yetTests passing in React
Not measured yetSize
Not measured yetMemory
Not measured yetSpeed
[N] commits

Installation

npm i @hamur/react

Not on npm yet. The package goes out under the chosen name.

Anatomy

Import the component and assemble its parts:

<Carousel.Root>
  <Carousel.Value />
  <Carousel.Previous />
  <Carousel.Next />
  <Carousel.Viewport>
    <Carousel.Slide />
  </Carousel.Viewport>
  <Carousel.TabList>
    <Carousel.Tab />
  </Carousel.TabList>
</Carousel.Root>

// Not in this example: Carousel.AutoplayControl

Examples

Eight slides three to a view with the fourth peeking: Previous and Next move by three, and there are three tabs, the last one "Slides 7 to 8".

CarouselMultiple.tsx
import { Carousel } from '@hamur/react/carousel'

const items = [
  'Linen shirt',
  'Wool scarf',
  'Canvas tote',
  'Leather belt',
  'Cotton socks',
  'Rain jacket',
  'Knit beanie',
  'Denim cap',
]

export default function CarouselMultipleExample() {
  return (
    <Carousel.Root className="carousel carousel-multiple" aria-label="New arrivals">
      <div className="carousel-bar">
        <Carousel.Value className="carousel-value" />
        <Carousel.Previous className="button button-square carousel-arrow carousel-prev" />
        <Carousel.Next className="button button-square carousel-arrow carousel-next" />
      </div>
      <Carousel.Viewport className="carousel-viewport" tabIndex={0}>
        {items.map((item) => (
          <Carousel.Slide key={item} className="carousel-slide">
            <p className="carousel-slide-title">{item}</p>
          </Carousel.Slide>
        ))}
      </Carousel.Viewport>
      <Carousel.TabList className="carousel-tabs">
        {(page) => <Carousel.Tab page={page.index} className="carousel-tab" />}
      </Carousel.TabList>
    </Carousel.Root>
  )
}

autoplay with loop, the AutoplayControl first in the bar, and a note that the rotation starts stopped under reduced motion.

CarouselAutoplay.tsx
import { Carousel } from '@hamur/react/carousel'

const slides = [
  { title: 'Dunes', text: 'Wind sets the shape of the sand.' },
  { title: 'Glacier', text: 'Ice that moves a few metres a year.' },
  { title: 'Harbor', text: 'Boats in at dusk, out before dawn.' },
  { title: 'Canyon', text: 'A river cut it over millions of years.' },
  { title: 'Forest', text: 'Rain on moss and old cedar.' },
]

export default function CarouselAutoplayExample() {
  return (
    <div className="stack">
      <Carousel.Root className="carousel" aria-label="Places" autoplay loop>
        <div className="carousel-bar">
          <Carousel.AutoplayControl className="button button-square carousel-play" />
          <Carousel.Value className="carousel-value" />
          <Carousel.Previous className="button button-square carousel-arrow carousel-prev" />
          <Carousel.Next className="button button-square carousel-arrow carousel-next" />
        </div>
        <Carousel.Viewport className="carousel-viewport" tabIndex={0}>
          {slides.map((slide) => (
            <Carousel.Slide key={slide.title} className="carousel-slide">
              <p className="carousel-slide-title">{slide.title}</p>
              <p className="muted">{slide.text}</p>
            </Carousel.Slide>
          ))}
        </Carousel.Viewport>
        <Carousel.TabList className="carousel-tabs">
          {(page) => <Carousel.Tab page={page.index} className="carousel-tab" />}
        </Carousel.TabList>
      </Carousel.Root>
      <p className="muted">
        A new slide every five seconds. It waits while the pointer is over it, stops when the
        keyboard moves into it, and starts stopped when your system asks for reduced motion.
      </p>
    </div>
  )
}

A Tab per slide with a picture and the slide's title, which names it.

CarouselThumbnails.tsx
import { Carousel } from '@hamur/react/carousel'

const slides = [
  { title: 'Dunes', text: 'Wind sets the shape of the sand.' },
  { title: 'Glacier', text: 'Ice that moves a few metres a year.' },
  { title: 'Harbor', text: 'Boats in at dusk, out before dawn.' },
  { title: 'Canyon', text: 'A river cut it over millions of years.' },
  { title: 'Forest', text: 'Rain on moss and old cedar.' },
]

export default function CarouselThumbnailsExample() {
  return (
    <Carousel.Root className="carousel" aria-label="Places">
      <div className="carousel-bar">
        <Carousel.Value className="carousel-value" />
        <Carousel.Previous className="button button-square carousel-arrow carousel-prev" />
        <Carousel.Next className="button button-square carousel-arrow carousel-next" />
      </div>
      <Carousel.Viewport className="carousel-viewport" tabIndex={0}>
        {slides.map((slide) => (
          <Carousel.Slide key={slide.title} className="carousel-slide">
            <p className="carousel-slide-title">{slide.title}</p>
            <p className="muted">{slide.text}</p>
          </Carousel.Slide>
        ))}
      </Carousel.Viewport>
      <Carousel.TabList className="carousel-thumbs">
        {slides.map((slide, index) => (
          <Carousel.Tab key={slide.title} page={index} className="carousel-thumb">
            {slide.title}
          </Carousel.Tab>
        ))}
      </Carousel.TabList>
    </Carousel.Root>
  )
}

orientation vertical: the slides scroll up and down, and the tabs sit between Previous and Next in a column.

CarouselVertical.tsx
import { Carousel } from '@hamur/react/carousel'

const slides = [
  { title: 'Dunes', text: 'Wind sets the shape of the sand.' },
  { title: 'Glacier', text: 'Ice that moves a few metres a year.' },
  { title: 'Harbor', text: 'Boats in at dusk, out before dawn.' },
  { title: 'Canyon', text: 'A river cut it over millions of years.' },
]

export default function CarouselVerticalExample() {
  return (
    <Carousel.Root
      className="carousel carousel-vertical"
      aria-label="Places"
      orientation="vertical"
    >
      <div className="carousel-side">
        <Carousel.Previous className="button button-square carousel-arrow carousel-prev" />
        <Carousel.TabList className="carousel-tabs">
          {(page) => <Carousel.Tab page={page.index} className="carousel-tab" />}
        </Carousel.TabList>
        <Carousel.Next className="button button-square carousel-arrow carousel-next" />
      </div>
      <Carousel.Viewport className="carousel-viewport" tabIndex={0}>
        {slides.map((slide) => (
          <Carousel.Slide key={slide.title} className="carousel-slide">
            <p className="carousel-slide-title">{slide.title}</p>
            <p className="muted">{slide.text}</p>
          </Carousel.Slide>
        ))}
      </Carousel.Viewport>
    </Carousel.Root>
  )
}

loop on three slides: Previous on the first goes to the last and Next on the last to the first.

CarouselLoop.tsx
import { Carousel } from '@hamur/react/carousel'

const slides = [
  { title: 'Dunes', text: 'Wind sets the shape of the sand.' },
  { title: 'Glacier', text: 'Ice that moves a few metres a year.' },
  { title: 'Harbor', text: 'Boats in at dusk, out before dawn.' },
]

export default function CarouselLoopExample() {
  return (
    <Carousel.Root className="carousel" aria-label="Places" loop>
      <div className="carousel-bar">
        <Carousel.Value className="carousel-value" />
        <Carousel.Previous className="button button-square carousel-arrow carousel-prev" />
        <Carousel.Next className="button button-square carousel-arrow carousel-next" />
      </div>
      <Carousel.Viewport className="carousel-viewport" tabIndex={0}>
        {slides.map((slide) => (
          <Carousel.Slide key={slide.title} className="carousel-slide">
            <p className="carousel-slide-title">{slide.title}</p>
            <p className="muted">{slide.text}</p>
          </Carousel.Slide>
        ))}
      </Carousel.Viewport>
    </Carousel.Root>
  )
}

value in the example's state: onValueChange writes it and its reason, and First and Last outside the carousel set it.

CarouselControlled.tsx
import * as React from 'react'
import { Carousel } from '@hamur/react/carousel'

const slides = [
  { title: 'Dunes', text: 'Wind sets the shape of the sand.' },
  { title: 'Glacier', text: 'Ice that moves a few metres a year.' },
  { title: 'Harbor', text: 'Boats in at dusk, out before dawn.' },
  { title: 'Canyon', text: 'A river cut it over millions of years.' },
  { title: 'Forest', text: 'Rain on moss and old cedar.' },
]

export default function CarouselControlledExample() {
  const [value, setValue] = React.useState(0)
  const [reason, setReason] = React.useState('none yet')
  return (
    <div className="stack">
      <Carousel.Root
        className="carousel"
        aria-label="Places"
        value={value}
        onValueChange={(next, details) => {
          setValue(next)
          setReason(details.reason)
        }}
      >
        <div className="carousel-bar">
          <Carousel.Value className="carousel-value" />
          <Carousel.Previous className="button button-square carousel-arrow carousel-prev" />
          <Carousel.Next className="button button-square carousel-arrow carousel-next" />
        </div>
        <Carousel.Viewport className="carousel-viewport" tabIndex={0}>
          {slides.map((slide) => (
            <Carousel.Slide key={slide.title} className="carousel-slide">
              <p className="carousel-slide-title">{slide.title}</p>
              <p className="muted">{slide.text}</p>
            </Carousel.Slide>
          ))}
        </Carousel.Viewport>
      </Carousel.Root>
      <div className="carousel-bar">
        <button type="button" className="button" onClick={() => setValue(0)}>
          First
        </button>
        <button type="button" className="button" onClick={() => setValue(slides.length - 1)}>
          Last
        </button>
      </div>
      <p className="muted">
        Value {value} · {reason}
      </p>
    </div>
  )
}

Six linked cards two to a view with offscreenSlides="interactive" and mouseDrag.

CarouselCards.tsx
import { Carousel } from '@hamur/react/carousel'

const articles = [
  { id: 'scroll-snap', title: 'Scroll snap in practice', text: '6 min read' },
  { id: 'focus', title: 'Where focus should go', text: '4 min read' },
  { id: 'motion', title: 'Motion people can stop', text: '5 min read' },
  { id: 'targets', title: 'Targets big enough to hit', text: '3 min read' },
  { id: 'names', title: 'Names a screen reader says', text: '7 min read' },
  { id: 'tabs', title: 'Tabs that pick a slide', text: '4 min read' },
]

export default function CarouselCardsExample() {
  return (
    <Carousel.Root
      className="carousel carousel-cards"
      aria-label="Articles"
      offscreenSlides="interactive"
      mouseDrag
    >
      <div className="carousel-bar">
        <Carousel.Value className="carousel-value" />
        <Carousel.Previous className="button button-square carousel-arrow carousel-prev" />
        <Carousel.Next className="button button-square carousel-arrow carousel-next" />
      </div>
      <Carousel.Viewport className="carousel-viewport">
        {articles.map((article) => (
          <Carousel.Slide key={article.id} className="carousel-slide">
            <a className="carousel-card" href={`#${article.id}`}>
              <span className="carousel-slide-title">{article.title}</span>
              <span className="muted">{article.text}</span>
            </a>
          </Carousel.Slide>
        ))}
      </Carousel.Viewport>
    </Carousel.Root>
  )
}

API reference

34 props across 9 parts, in React syntax. className, style, render are listed once, under Shared props.

AutoplayControlCarousel.AutoplayControl

Starts and stops the rotation; named by what pressing it does. Not rendered without autoplay on the Root. Renders a <button>.

AutoplayControl props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
AutoplayControl data attributes
Data attributeDescription
data-playingPresent while rotation is on.

NextCarousel.Next

Goes to the next page. It is aria-disabled, not disabled, on the last page without loop, so it keeps focus. Renders a <button>.

Next props
PropTypeDefault
disabledWhether the control should ignore user interaction.booleanfalse
focusableWhenDisabledWhether the control stays focusable when it is disabled, with aria-disabled.booleantrue
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
Next data attributes
Data attributeDescription
data-disabledPresent when the control is disabled.

PreviousCarousel.Previous

Goes to the previous page, or to the start of the current one when a scroll rested past it. It is aria-disabled, not disabled, on the first page without loop, so it keeps focus. Renders a <button>.

Previous props
PropTypeDefault
disabledWhether the control should ignore user interaction.booleanfalse
focusableWhenDisabledWhether the control stays focusable when it is disabled, with aria-disabled.booleantrue
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
Previous data attributes
Data attributeDescription
data-disabledPresent when the control is disabled.

RootCarousel.Root

Groups all parts of the carousel: a region with the role description "carousel". Renders a <div>.

Root props
PropTypeDefault
valueThe index of the current slide, 0-based.number—
defaultValueThe index of the slide shown when the component is initially rendered.number0
onValueChangeEvent handler called when the current slide changes.((value: number, eventDetails: CarouselRootChangeEventDetails) => void)—
onValueChangeCompleteEvent handler called when the scroll that brought the value has settled.((value: number) => void)—
orientationThe axis the slides scroll on.CarouselOrientation'horizontal'
alignWhere a slide snaps in the Viewport.CarouselAlign'start'
slidesPerMoveHow far Previous and Next move: 'auto' by a page (the slides in view), or a number ofCarouselSlidesPerMove'auto'
snapTypeThe CSS scroll snap strictness.CarouselSnapType'mandatory'
loopWhether Previous on the first page goes to the last page and Next on the last to the first.booleanfalse
autoplayWhether the slides rotate automatically.booleanfalse
autoplayIntervalThe time each page is shown while rotating, in milliseconds.number5000
playingWhether rotation runs.boolean—
defaultPlayingWhether rotation runs when the component is initially rendered, with autoplay.booleantrue
onPlayingChangeEvent handler called when rotation starts or stops.((playing: boolean, eventDetails: CarouselRootPlayingChangeEventDetails) => void)—
mouseDragWhether a mouse drag scrolls the Viewport.booleanfalse
offscreenSlidesWhether a slide with no part in view is inert.CarouselOffscreenSlides'inert'
scrollBehaviorHow the component's own scrolls move: always 'instant' underCarouselScrollBehavior'smooth'
labelsThe texts, in English by default.Partial<CarouselLabels>—
actionsRefA ref to imperative actions: scrollTo(index), next(), previous(), play() and stop().React.RefObject<CarouselRootActions | null>—
Root data attributes
Data attributeDescription
data-orientationThe axis the slides scroll on.
data-scrollingPresent while the Viewport is scrolling.
data-draggingPresent while a mouse drag is in progress.
data-playingPresent while rotation is on.
data-pausedPresent while rotation is on but held by hover, a hidden page or a drag.
data-at-startPresent when the Viewport shows the start of its range.
data-at-endPresent when the Viewport shows the end of its range.

SlideCarousel.Slide

A slide: a group named "N of M", or a tab panel when the carousel has a TabList. Its index is its position among the slides. Renders a <div>.

Slide data attributes
Data attributeDescription
data-indexThe slide's index among the slides, 0-based.
data-activePresent when the slide is the current value.
data-in-viewPresent when part of the slide is in view.
data-orientationThe axis the slides scroll on.

TabCarousel.Tab

Selects a page and scrolls to it; selected while its page is the current one. Renders a <button>.

Tab props
PropTypeDefault
pagerequiredThe page the tab selects, 0-based.number—
disabledWhether the tab should ignore user interaction.booleanfalse
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
Tab data attributes
Data attributeDescription
data-indexThe page of the tab, 0-based.
data-activePresent when the tab's page is the current one.
data-disabledPresent when the tab is disabled.

TabListCarousel.TabList

Groups the tabs that pick a page, one per page, as one Tab stop: the arrow keys move focus and, with activateOnFocus, select the page. Renders a <div>.

TabList props
PropTypeDefault
activateOnFocusWhether moving focus with the arrow keys also selects the page.booleantrue
loopFocusWhether the arrow keys move focus from the last tab to the first and back.booleantrue
childrenThe tabs, or a function that renders the tab of each page.React.ReactNode | ((page: CarouselTabListPage, index: number) => React.ReactNode)—
TabList data attributes
Data attributeDescription
data-orientationThe axis the slides scroll on.

ValueCarousel.Value

The current page as text, "2 of 5". Plain text, not a live region: the Viewport is. Renders a <span>.

Value props
PropTypeDefault
childrenThe text, or a function of the current slide, the current page and the page count.React.ReactNode | ((state: CarouselValueState) => React.ReactNode)—

ViewportCarousel.Viewport

The scroll container that holds the slides, and the carousel's live region. Renders a <div>.

Viewport props
PropTypeDefault
renderBeforeHydrationWhether to render an inline script that scrolls to the initial slide before React hydrates,booleanfalse
Viewport data attributes
Data attributeDescription
data-orientationThe axis the slides scroll on.
data-scrollingPresent while the Viewport is scrolling.
data-draggingPresent while a mouse drag is in progress.

Shared props

Every part that renders an element takes these.

Shared props
PropType
classNameCSS class applied to the element, or a function that returns a class based on the component's state.string | ((state: State) => string | undefined)
styleStyle applied to the element, or a function that returns a style object based on the component's state.React.CSSProperties | ((state: State) => React.CSSProperties | undefined)
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, State>

Keyboard and accessibility

Keyboard

Keyboard
KeysDoesPart
EnterSpacePresses Previous or Next; focus stays on the buttonNext
TabMoves from Next into the current slide, never into a slide out of viewSlide
TabReaches the tab of the current page, the list's one Tab stopTab
ArrowLeftArrowRightMove focus to the previous or next tab and show its page, wrapping at both endsTabList
ArrowLeftArrowRightAre mirrored in a right-to-left layoutTabList
ArrowUpArrowDownMove between the tabs when orientation is verticalTabList
HomeEndShow the first or the last pageTabList
EnterSpaceStops or starts the rotation; the button's name switchesAutoplayControl
TabKeyboard focus inside stops the rotation; leaving does not restart itRoot

Accessibility

What the parts set
PartSetsSo that
Rootrole="region" with the role description "carousel", named by aria-label or aria-labelledby (a development warning without one); role="group" from the app replaces itthe carousel is a landmark with its name, said as a carousel
Sliderole="group" with the role description "slide" and the name "3 of 5"; with a TabList, role="tabpanel" without a role description and the same nameeach slide says where it is; the tab panels are not given a second role
Slideinert on a slide with no part in view, decided when a scroll settles and never on a slide that holds focusTab, find and the reading order reach only what is shown
Viewportaria-live="off" while rotating and polite otherwise, aria-busy="true" while scrolling, an id the controls point toa slide the reader brings in is announced once, and rotation is not read out every few seconds
Previousa button named "Previous slide" unless it has text or aria-label, aria-controls the Viewport; on the first page aria-disabled and still focusablefocus is not lost when the button stops working
Nextthe same, named "Next slide", disabled on the last pageas Previous
AutoplayControla button named "Stop automatic slide show" while rotating and "Start automatic slide show" otherwise, with no aria-pressedthe name says what a press does; moving content can be stopped (WCAG 2.2.2)
TabListrole="tablist" named "Choose slide to display", one Tab stop, aria-orientation="vertical" when verticalthe pages are one control, crossed with the arrow keys
Tabrole="tab", aria-selected on the current page, aria-controls the page's slides, named "Slide 2" or "Slides 4 to 6" unless it has texteach tab says which slides it shows
Valuethe text "2 of 5", not a live regionthe position can be shown without being announced twice

Focus order

  1. In document order. The examples follow the pattern's order: the AutoplayControl first, then Previous and Next, then the current slide's content, then the tab of the current page. Nothing takes focus on mount.
  2. Previous and Next keep focus when they scroll, and when they are disabled at either end.
  3. Slides out of view are skipped until they scroll in; with offscreenSlides="interactive" every slide's content is reachable and the browser scrolls a focused one into view.
  4. The TabList is one Tab stop: the tab of the current page, or the tab that last had focus while focus stays in the list.