Carousel
A carousel puts several pieces of content in one place and shows one — or a few — at a time, in a fixed order. Reach for it when the items are peers that a reader browses in sequence: product photos, testimonials, a row of cards there is not room to lay out at once. If the reader picks a panel by name rather than by position, use Tabs; if the strip only needs to scroll, use ScrollArea. And if the content is important, put it on the page: a carousel is where the second slide goes to be missed.
Import
import { Carousel } from "@forte-ui/react";Carousel is a namespace of ten parts. Carousel.Root owns the active slide; Carousel.Viewport is the box that clips and takes the drag; Carousel.Track is the row of Carousel.Slides that moves inside it. Carousel.Prev and Carousel.Next step through the slides, Carousel.Dots and Carousel.Thumbs (a strip of Carousel.Thumbs) jump to one, and Carousel.PlayPause stops and starts autoplay.
Slides must be direct children of Carousel.Track, and thumbs of Carousel.Thumbs: each reads its index from its position among the children, which is how the active slide is known on the very first render — the server's — rather than after a registry fills in on mount. Render them straight from an array; a wrapper component around several slides counts as one.
Examples
Basic
The demo at the top of the page: five slides, Prev and Next rendered inside the viewport where they float over the slides, and Dots under it. Drag the slides with a mouse, swipe them on touch, or use the buttons. A short swipe moves one slide in its direction; a long, slow drag lands on whichever slide is nearest when it is let go. At either end the track resists rather than stops, so a drag past the last slide says "there is no more" without pretending there is.
Controlled
index and onIndexChange make the carousel controlled, and the index can then be driven from anywhere — here, from a pair of buttons outside it, with draggable={false} so the only way through the steps is the one the form allows. onIndexChange also receives a reason: "drag", "control", "autoplay", or "clamp" when the index was pulled back into range because slides were removed.
Loop
loop wraps the last slide around to the first. The track carries copies of the slides at each end — enough to fill the view, plus one — and a move across the boundary is one step through a copy, not a sweep back across the whole strip. The copies are hidden from assistive technology and made inert, so a screen reader still counts four slides, not eight; but an element id inside a slide will occur more than once in the document.
Autoplay
autoplay advances on a timer — true for every five seconds, or the interval in milliseconds. It pauses while the pointer is over the carousel, while focus is inside it, during a drag and while the tab is hidden, and any move the reader makes resets the clock so the next automatic one is a full interval later. Without loop it wraps from the end to the start.
Slides per view
slidesPerView fits several slides in the viewport; a fraction leaves the next one peeking in at the end, which is the cue that there are more. The active slide sits at the start edge and the rest fill in after it. The last position is the one that still fills the view, so a row never scrolls on to show two slides and a blank, and Dots renders one dot per position rather than one per slide.
gap sets the space between slides: a step on the spacing scale (gap={2} is --forte-space-2, gap={0} is none) or any CSS length as a string. It is the --forte-carousel-gap knob under another name — both the slide size and the track's travel are derived from it, so changing it never puts the track off a slide.
Centered
align="center" puts the active slide in the middle instead, with its neighbours peeking on both sides — the gallery arrangement, where one slide is the subject and the others are context. Every slide can take the middle, so there is a dot per slide again. With loop, as here, the last slides show before the first one; without it the first and last slides sit beside blank track, the way a gallery's ends do. The demo dims the slides that are not active with a Tailwind variant on data-active.
Centering is one number: the view's start edge sits (slidesPerView - 1) / 2 slides before the active one, and that offset is subtracted from the track's position in the same calc(). The window that decides which slides are in view — for inert, lazy and autoHeight — centres on the active slide with it, so the two peeking neighbours are mounted but not reachable with Tab.
Lazy
lazy mounts only the slides near the view: true keeps the slides in view plus one on either side, a number keeps that many on either side. A slide outside the window keeps its box and its size — so drag distances stay right — but renders none of its content. Step through the demo and watch the count.
0 of 12 slides mounted
A slide's content is unmounted when it leaves the window, so anything it holds — a form draft, a video's position — is lost. Keep lazy for slides that are cheap to recreate: images, fetched cards. One on either side covers a swipe; a long, slow drag can reveal a slide the window has not reached yet, which renders empty until the track settles.
Auto height
By default the viewport is as tall as the tallest slide, and a short slide floats in it. autoHeight sizes the viewport to the slides in view instead and animates between heights, measured off the slides themselves — so a lazy slide that has just mounted, or an image that has just loaded, grows the viewport with it. Horizontal carousels only: a vertical one needs a definite height to lay its slides out in.
Vertical
orientation="vertical" stacks the slides and moves them on the block axis. Give Carousel.Viewport a height — the slides are sized from it — and Prev and Next move to the top and bottom edges. Touch claims vertical swipes over the carousel, which is what a vertical carousel is, so keep one short: a tall vertical carousel is a stretch of page a thumb cannot scroll past. Dots and Thumbs follow the root's orientation unless given their own.
Thumbnails
Carousel.Thumbs is a strip of Carousel.Thumbs in the same order as the slides. Each is a button that jumps to its slide, the active one is ringed, and the strip scrolls itself to keep it in view. Put an image with alt, or an aria-label, on each thumb — it is a button, and a button needs a name.
Accessibility
| Key | Behaviour |
|---|---|
| Tab | Moves through the focusable controls in order: anything inside the visible slides, then Prev, Next, the active dot, the thumbnails and PlayPause. Slides out of view are inert, so their links and buttons are skipped. |
| Enter | Activates the focused control. |
| Space | Activates the focused control. |
| ArrowRight | On the dots of a horizontal carousel: moves to the next slide, and focus to its dot. |
| ArrowLeft | On the dots of a horizontal carousel: moves to the previous slide, and focus to its dot. |
| ArrowDown | On the dots of a vertical carousel: moves to the next slide. |
| ArrowUp | On the dots of a vertical carousel: moves to the previous slide. |
| Home | On the dots: moves to the first slide. |
| End | On the dots: moves to the last position. |
The parts carry the APG carousel pattern's roles. Carousel.Root is a region with aria-roledescription="carousel"; each Carousel.Slide is a group with aria-roledescription="slide" and is named "n of total" unless given an aria-label. Carousel.Track is a polite live region while the carousel is under the reader's control, so a move announces the new slide, and switches itself off while autoplay is running — a region that speaks every five seconds is not assistive. The active dot and thumb carry aria-current.
Slides out of view are aria-hidden and inert. That is what keeps a link in the next slide out of the tab order, and it is also why the viewport clips with overflow: clip rather than hidden: a hidden overflow is still a scroll container, and focusing a control in it would scroll the strip under the transform and leave it there.
Prev and Next use aria-disabled rather than disabled at the ends of a non-looping carousel, so reaching the last slide with Next leaves focus on the button instead of dropping it to the page. The dots are a single tab stop with the arrow keys moving between them, which is also what moves the carousel.
Under forced colors the controls keep a system-coloured border in place of their shadow, the active dot is painted Highlight, and the active thumbnail's ring is Highlight while the inactive ones' become invisible — transparent is replaced in that mode, so they are set to Canvas, the page colour, instead.
Motion
The track is moved by one number. Carousel.Track carries --forte-carousel-position — which slide sits at the start edge — and the stylesheet turns it into a translate with a calc() the browser resolves itself:
.track {
--forte-carousel-step: calc((100% + var(--forte-carousel-gap)) / var(--forte-carousel-per-view));
translate: calc(-1 * var(--forte-direction) * var(--forte-carousel-position) * var(--forte-carousel-step)) 0;
transition: translate var(--forte-carousel-duration) var(--forte-carousel-ease);
}The percentage is the track's own width, so the resting positions never need measuring: resize the page, switch density, override the gap, and the track is still parked on the same slide. Only a drag needs pixels, to turn the pointer's travel into slides, and those are measured once at pointerdown. --forte-direction flips the sign in RTL, where the first slide is on the right — try the demo frame's toggle.
The move is a transition, so pressing Next while the track is still travelling retargets from wherever it is. During a drag the transition is off and the same variable is written straight to the DOM every frame; letting go is one state update that puts the transition back and sets the target, and the browser tweens from under the finger to the slide.
Looping never waits for a transition to end. A move that would cross the boundary first jumps the track — instantly, with the transition suppressed for that one write — to the copy of the slide that paints the identical picture, and then animates one step onto the real slide. The track always comes to rest on a real slide, so there is nothing to fix up afterwards and nothing that can go wrong when a background tab's transitions never run.
Under prefers-reduced-motion there is no separate path and no reduced-motion block in the stylesheet. --forte-duration-spring-precise shortens to its floor, so the track cuts to the next slide instead of sliding; the viewport's height change and the dot's stretch shorten the same way; and autoplay does not start.
Theming
Every property below is declared on Carousel.Root, so override them there — through its className or an inline style — rather than on an ancestor, whose value only inherits and loses to the root's own declaration. To move every carousel at once, re-point the global tokens in the Default column instead.
| Property | Controls | Default |
|---|---|---|
--forte-carousel-gap | Space between slides | var(--forte-space-4) |
--forte-carousel-controls-gap | Space between the viewport and the dots or thumbnails under it | var(--forte-space-4) |
--forte-carousel-radius | Corner radius of the viewport, which clips the slides | var(--forte-radius-surface) |
--forte-carousel-duration | How long the track takes to settle on a slide after a button or a drag. Paired with the matching spring easing below: a shorter duration truncates the spring mid-settle | var(--forte-duration-spring-precise) |
--forte-carousel-ease | Easing of that move | var(--forte-ease-spring-precise) |
--forte-carousel-height-duration | How long the viewport takes to grow or shrink to the slides in view under autoHeight | var(--forte-duration-normal) |
--forte-carousel-control-size | Size of the round control buttons | var(--forte-control-h-md) |
--forte-carousel-control-inset | How far in from the viewport's edge Prev and Next sit when rendered inside it | var(--forte-space-3) |
--forte-carousel-control-bg | Background of a control | var(--forte-color-panel) |
--forte-carousel-control-bg-hover | Background of a control on hover | var(--forte-color-panel-hover) |
--forte-carousel-control-color | Icon colour of a control | var(--forte-color-foreground) |
--forte-carousel-control-border-color | Border colour of a control | var(--forte-color-border) |
--forte-carousel-control-radius | Corner radius of a control | var(--forte-radius-pill) |
--forte-carousel-control-shadow | Shadow under a control, which is what lifts it off a photo | var(--forte-shadow-2) |
--forte-carousel-dot-size | Diameter of a dot. The active dot stretches to two and a half of it | var(--forte-space-2) |
--forte-carousel-dot-gap | Space between dots | var(--forte-space-2) |
--forte-carousel-dot-color | Colour of an inactive dot | var(--forte-color-border-strong) |
--forte-carousel-dot-color-hover | Colour of an inactive dot on hover | var(--forte-color-foreground-subtle) |
--forte-carousel-dot-color-active | Colour of the active dot | var(--forte-color-primary) |
--forte-carousel-thumb-gap | Space between thumbnails | var(--forte-space-2) |
--forte-carousel-thumb-radius | Corner radius of a thumbnail | var(--forte-radius-control) |
--forte-carousel-thumb-border-width | Width of the ring drawn around a thumbnail. It is always drawn, in transparent on the inactive ones, so the active ring does not move the strip by its own width | 2px |
--forte-carousel-thumb-border-color-active | Ring colour of the active thumbnail | var(--forte-color-primary) |
--forte-carousel-thumb-opacity | Opacity of an inactive thumbnail | 0.6 |
gap on the root is --forte-carousel-gap as a prop; the two are the same declaration, and the prop wins when both are set because it is written inline.
The parts also expose their state as data attributes — data-orientation, data-align, data-loop, data-dragging, data-auto-height, data-autoplay (playing or paused), data-at-start and data-at-end on the root; data-active, data-in-view and data-clone on a slide; data-disabled on the controls; data-active on a dot or thumb — so a Tailwind arbitrary variant such as data-[dragging]:... can target them without a wrapper.
API reference
Every part forwards the props it does not consume to the element it renders. Carousel.Prev, Carousel.Next and Carousel.PlayPause take render to swap the element for another — render={<Button iconOnly variant="ghost" />} composes them with Button.
Carousel.Root
| Prop | Type | Default | Description |
|---|---|---|---|
align | CarouselAlign | start | Where the active slide sits when more than one fits. `"start"` parks it at the start edge and fills the view with the slides after it — a row of cards. `"center"` puts it in the middle with its neighbours peeking on both sides — a gallery. Centred, every slide can take the middle: with `loop` the last slides show before the first one, without it the first and last sit beside blank track. |
autoHeight | boolean | false | Size the viewport to the slides in view and animate between heights, so a short slide next to a tall one does not leave a gap under it. Horizontal carousels only. |
autoplay | number | boolean | false | Advance on a timer: `true` for every 5 seconds, or the interval in milliseconds. Pauses while the pointer is over the carousel, while focus is inside it, during a drag and while the tab is hidden, and does not start at all under reduced motion — `Carousel.PlayPause` lets the reader start it anyway. A carousel that autoplays should render that button: WCAG 2.2.2 asks for a way to pause anything that moves on its own. |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
defaultIndex | number | 0 | The slide shown first when the carousel is uncontrolled. |
draggable | boolean | true | Whether the slides can be dragged with a pointer or swiped on touch. |
gap | CarouselGap | 4 | Space between slides: a step on the spacing scale (`2` is `--forte-space-2`, `0` is none) or a CSS length (`"2px"`, `"var(--my-gap)"`). Sets the `--forte-carousel-gap` knob, which the slide size and the track's travel are both derived from, so the two cannot disagree. Left unset, the knob's own default applies. |
index | number | The active slide, zero-based. Makes the carousel controlled: pair it with `onIndexChange`. | |
lazy | number | boolean | false | Render only the slides near the view. `true` keeps the slides in view plus one on either side mounted; a number keeps that many on either side. A slide outside the window keeps its box and its size but renders no content, so drag distances stay correct. |
loop | boolean | false | Whether the last slide wraps around to the first. Copies of the slides at each end of the track make the wrap seamless, so an element `id` inside a slide will occur more than once in the document. |
onIndexChange | ((index: number, reason: CarouselChangeReason) => void) | Called with the new index whenever the active slide changes — by a drag, a control, autoplay, or a clamp after slides were removed. `reason` says which. | |
orientation | CarouselOrientation | horizontal | Which way the slides are laid out and travel. A vertical carousel needs a definite height on `Carousel.Viewport` — its slides are sized from it — and ignores `autoHeight`. |
slidesPerView | number | 1 | How many slides fit in the view. A fraction leaves the next slide peeking in at the end: `1.2` shows one slide and a fifth of the next. |
Carousel.Viewport
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Carousel.Track
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Carousel.Slide
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Carousel.Prev and Carousel.Next
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
render | RenderProp<Record<string, unknown>> | Replace the rendered element — compose the control with `Button`, say: `render={<Button iconOnly variant="ghost" />}`. |
Carousel.Dots
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
getLabel | ((index: number) => string) | (i) => `Slide ${i + 1}` | Names a dot for assistive technology, given its zero-based index. |
orientation | CarouselOrientation | Which way the dots run, and which arrow keys move between them. Follows the carousel's own orientation unless set — a vertical carousel with a row of dots under it is fine. |
Carousel.Thumbs
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
orientation | CarouselOrientation | Which way the strip runs. Follows the carousel's own orientation unless set — a vertical carousel with a row of thumbnails under it is fine. |
Carousel.Thumb
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Carousel.PlayPause
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
pauseLabel | string | Pause | Accessible name while autoplay is running — the button's action. |
playLabel | string | Play | Accessible name while autoplay is stopped. |
render | RenderProp<Record<string, unknown>> | Replace the rendered element, to compose the control with `Button`. |