Carousels
A carousel shows visual items side by side in a scrolling strip. Carousel is the
scroller and CarouselItem is one item. Items change width as they scroll, so the strip
always ends on a partly visible item.
Items are cropped, not squashed. An item keeps its size at every scroll position and a mask narrows the visible part, so photos never distort.
Usage
Give the carousel an accessible name. The multi-browse layout is the default: one or more large items, then a medium and a small
one to show that there is more.
An item takes its height from its content, so an item with only an image has no
height of its own. In that case, set --np-carousel-item-height on the carousel, as every
example on this page does.
Opened: nothing yet
<script lang="ts">
import { Carousel, CarouselItem } from 'noph-ui'
const photos = [
{ title: 'Convergence', image: '/pollock.avif' },
{ title: 'Blue Poles', image: '/pollock2.avif' },
{ title: 'Autumn Rhythm', image: '/pollock3.avif' },
{ title: 'Number 1A', image: '/pollock.avif' },
{ title: 'Eyes in the Heat', image: '/pollock2.avif' },
]
let opened = $state<string>()
</script>
<div class="shelf">
<Carousel label="Paintings">
{#each photos as photo (photo.title)}
<CarouselItem
type="button"
label={photo.title}
image={photo.image}
onclick={() => (opened = photo.title)}
/>
{/each}
</Carousel>
<p>Opened: <code>{opened ?? 'nothing yet'}</code></p>
</div>
<style>
.shelf {
inline-size: 36rem;
max-inline-size: 100%;
--np-carousel-item-height: 12rem;
}
</style>
Layouts
Four variant values cover the five M3 layouts. multi-browse shows large,
medium and small items together and is the M3 default. uncontained keeps every item
at its given size and lets items run past the edge. It crops only at the two ends of the
scrollport. Give its items their own aspectRatio to get the multi-aspect layout: one
strip of items with different shapes. hero shows one item about twice as wide as it
is tall, with the next item peeking in. full-screen shows one item at a time and scrolls
on the block axis, so its arrow keys are up and down.
All five layouts, each with the sizing it needs:
multi-browseuncontaineduncontained with a per-item aspectRatioherofull-screen<script lang="ts">
import { Carousel, CarouselItem } from 'noph-ui'
const images = ['/pollock.avif', '/pollock2.avif', '/pollock3.avif']
const plates = Array.from({ length: 7 }, (_, index) => index)
const clips = [
{ title: 'Convergence', image: '/pollock.avif', ratio: 16 / 9 },
{ title: 'Blue Poles', image: '/pollock2.avif', ratio: 1 },
{ title: 'Autumn Rhythm', image: '/pollock3.avif', ratio: 9 / 16 },
{ title: 'Number 1A', image: '/pollock.avif', ratio: 4 / 3 },
{ title: 'Eyes in the Heat', image: '/pollock2.avif', ratio: 1 },
]
</script>
{#snippet run()}
{#each plates as index (index)}
<CarouselItem
type="button"
label={`Plate ${index + 1}`}
image={images[index % images.length]}
/>
{/each}
{/snippet}
<figure class="shelf">
<figcaption><code>multi-browse</code></figcaption>
<Carousel label="Paintings, multi-browse">{@render run()}</Carousel>
</figure>
<figure class="shelf">
<figcaption><code>uncontained</code></figcaption>
<Carousel variant="uncontained" label="Paintings, uncontained">{@render run()}</Carousel>
</figure>
<figure class="shelf aspect">
<figcaption><code>uncontained</code> with a per-item <code>aspectRatio</code></figcaption>
<Carousel variant="uncontained" label="Clips">
{#each clips as clip (clip.title)}
<CarouselItem type="button" label={clip.title} image={clip.image} aspectRatio={clip.ratio} />
{/each}
</Carousel>
</figure>
<figure class="shelf">
<figcaption><code>hero</code></figcaption>
<Carousel variant="hero" label="Paintings, hero">{@render run()}</Carousel>
</figure>
<figure class="viewport">
<figcaption><code>full-screen</code></figcaption>
<Carousel variant="full-screen" label="Paintings, full screen">
{#each images as image, index (image + index)}
<CarouselItem type="button" label={`Plate ${index + 1}`} {image} />
{/each}
</Carousel>
</figure>
<style>
.shelf,
.viewport {
inline-size: 36rem;
max-inline-size: 100%;
margin: 0 0 1.5rem;
--np-carousel-item-height: 10rem;
}
.aspect {
--np-carousel-item-height: 12rem;
}
.viewport {
inline-size: 18rem;
--np-carousel-length: 24rem;
}
figcaption {
margin-block-end: 0.5rem;
}
</style>
Only multi-browse and hero resize their items, so only these two layouts
run JavaScript. uncontained and full-screen use CSS only.
An uncontained item can set its own aspectRatio instead of using --np-carousel-item-width. This lets one strip hold a landscape clip next to a
portrait one. The ratio is based on the carousel's cross axis, so that axis needs a definite size.
The ratio also sets how far the item crops as it leaves: a wider item can crop more before only a
sliver is left.
At the ends of the strip the arrangement shifts, so the first item is full size at the start and the last item is full size at the end.
If all items fit, they keep the size you set.
Items and labels
label is a string, not a snippet, because it also forms the item's accessible name.
It shows at the bottom leading edge over a gradient scrim, so it stays readable on a photo. Pass image for a background image, or children for richer content. Both can have a label too.
<script lang="ts">
import { Carousel, CarouselItem } from 'noph-ui'
</script>
<div class="shelf">
<Carousel label="Label styles">
<CarouselItem type="button" label="A plain label" image="/pollock.avif" />
<CarouselItem type="button" label="With an overlay" image="/pollock2.avif">
<span class="badge">New</span>
</CarouselItem>
<CarouselItem type="button" aria-label="No visible label" image="/pollock3.avif" />
</Carousel>
</div>
<style>
.shelf {
inline-size: 36rem;
max-inline-size: 100%;
--np-carousel-item-height: 10rem;
}
.badge {
position: absolute;
inset-block-start: 0.75rem;
inset-inline-start: 0.75rem;
padding: 0.125rem 0.5rem;
border-radius: var(--np-shape-corner-full);
background-color: var(--np-color-primary);
color: var(--np-color-on-primary);
font-size: 0.75rem;
}
</style>
Interactive items
Whether an item is interactive sets its semantics, its place in the tab order and whether it gets
a state layer. An item with an href renders a link. An item with an onclick or a type renders a button. Both are focusable and show a
ripple. An item with neither is plain content. Use type="text" or type="link" to set the element yourself. A carousel with only plain items has no keyboard access of its own, so
it relies on the Show all route.
Clicked: nothing yet
<script lang="ts">
import { Carousel, CarouselItem } from 'noph-ui'
let clicked = $state<string>()
</script>
<div class="shelf">
<Carousel label="Item types">
<CarouselItem
type="button"
label="Button"
image="/pollock.avif"
onclick={() => (clicked = 'Button')}
/>
<CarouselItem label="Link" image="/pollock2.avif" href="#interactive-items" />
<CarouselItem label="Not interactive" image="/pollock3.avif" />
<CarouselItem type="button" label="Disabled" image="/pollock.avif" disabled />
</Carousel>
</div>
<p>Clicked: <code>{clicked ?? 'nothing yet'}</code></p>
<style>
.shelf {
inline-size: 36rem;
max-inline-size: 100%;
--np-carousel-item-height: 10rem;
}
</style>
Focusing or clicking an item scrolls it to a position where it is full size. The mask also crops hit testing, so the visible part of a narrow item receives the pointer.
Show all
On a vertically scrolling page, a carousel requires a way to see every item without
scrolling sideways. It links to a route of your own. Use a Show all text button below
the carousel, with 4dp of padding around it. If the carousel has a header, you can use an arrow IconButton next to the header instead. Make it 48dp. Align the header with the
leading edge of the carousel and repeat it on the all-items page. A full-screen carousel
does not need this.
The spec rules out two things: buttons inside or beside the carousel container, and anything laid over the carousel.
<script lang="ts">
import { Button, Carousel, CarouselItem, IconButton } from 'noph-ui'
import { ChevronRightIcon } from 'noph-ui/icons'
const images = ['/pollock.avif', '/pollock2.avif', '/pollock3.avif']
const plates = Array.from({ length: 6 }, (_, index) => index)
</script>
<section class="shelf">
<div class="header">
<h3 id="show-all-demo-heading">Recent paintings</h3>
<IconButton aria-label="Show all recent paintings" href="#show-all">
<ChevronRightIcon />
</IconButton>
</div>
<Carousel aria-labelledby="show-all-demo-heading">
{#each plates as index (index)}
<CarouselItem
type="button"
label={`Plate ${index + 1}`}
image={images[index % images.length]}
/>
{/each}
</Carousel>
<div class="below">
<Button variant="text" href="#show-all">Show all</Button>
</div>
</section>
<style>
.shelf {
inline-size: 36rem;
max-inline-size: 100%;
--np-carousel-item-height: 10rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding-inline: 1rem;
}
.header h3 {
margin: 0;
}
.below {
/* The spec puts 4dp of padding around the Show all button. */
padding: 0.25rem;
}
</style>
Theming
Set carousel properties on Carousel and item properties on CarouselItem. Item properties also inherit when you set them on the carousel. State
layer opacities come from the --np-ripple-* tokens.
| Property | Default |
|---|---|
--np-carousel-item-width | 12.5rem, the preferred width of a large item |
--np-carousel-item-height | auto |
--np-carousel-padding | 1rem (16dp) along the scroll axis |
--np-carousel-cross-padding | 0.5rem (8dp) |
--np-carousel-item-spacing | 0.5rem (8dp) |
--np-carousel-small-item-min-width | 2.5rem (40dp) |
--np-carousel-small-item-max-width | 3.5rem (56dp) |
--np-carousel-length | 100%, the block size of a vertical carousel |
--np-carousel-scrollbar-width | none |
--np-carousel-snap-strictness | mandatory |
--np-carousel-snap-stop | normal, always for full-screen |
--np-carousel-item-container-color | transparent |
--np-carousel-item-container-shape | --np-shape-corner-extra-large (28dp) |
--np-carousel-item-pressed-container-shape | --np-shape-corner-medium |
--np-carousel-item-elevation | none |
--np-carousel-item-hover-elevation | --np-elevation-1 (1dp) |
--np-carousel-item-outline-color | --np-color-outline |
--np-carousel-item-outline-width | 0 |
--np-carousel-item-focus-outline-color | --np-color-on-surface |
--np-carousel-item-state-layer-color | --np-color-on-surface |
--np-carousel-item-label-text-color | --np-color-surface in light, --np-color-on-surface in dark |
--np-carousel-item-label-scrim-color | 60% --np-color-scrim |
Three defaults differ from the M3 tokens. The container color is transparent instead of surface. Set it yourself for a text or icon item. The outline width is 0 instead of 1dp. The outline shows again under forced-colors. A
disabled item dims to 0.38 instead of repainting its container.
Example
<script lang="ts">
import { Carousel, CarouselItem } from 'noph-ui'
const images = ['/pollock.avif', '/pollock2.avif', '/pollock3.avif', '/pollock.avif']
</script>
<div class="shelf">
<Carousel label="Themed paintings" --np-carousel-padding="1.5rem">
{#each images as image, index (image + index)}
<CarouselItem
type="button"
label={`Plate ${index + 1}`}
{image}
--np-carousel-item-container-shape="var(--np-shape-corner-medium)"
--np-carousel-item-outline-width="1px"
--np-carousel-item-outline-color="var(--np-color-outline-variant)"
--np-carousel-item-label-scrim-color="color-mix(in srgb, var(--np-color-primary) 70%, transparent)"
/>
{/each}
</Carousel>
</div>
<style>
.shelf {
inline-size: 36rem;
max-inline-size: 100%;
--np-carousel-item-height: 10rem;
}
</style>
Motion and gestures
| What moves | How |
|---|---|
| Item size as it scrolls | A CSS scroll-driven animation |
| Shape on press | --np-motion-expressive-fast-effects, held 100ms after the pointer is released |
| State layers | The ripple's own tokens |
| Focus ring | --np-motion-expressive-slow-effects |
| Uncontained items at the edges | A view() scroll-driven animation. The item crops from the side it is leaving, and
its media shifts by the cropped amount |
| Scrolling to a focused item | scrollIntoView with behavior: 'smooth', using the browser's
snapping |
An item's label follows the crop, not the item's box. A narrowed item keeps the start of its text and shows an ellipsis when space runs out.
Under prefers-reduced-motion, resizing is off. Every item has the same size, and the
leading and trailing padding collapses so items reach the edges. You also get this layout before
hydration, without JavaScript and in browsers without scroll-driven animations. Snapping is mandatory, so the strip always stops on a keyline.
Accessibility
The carousel is a group with aria-roledescription="carousel" and the
name you give it. Pass role="region" only if it is a top-level page section. A page with
several carousels should not add several landmarks.
| Keys | Action |
|---|---|
| Tab | Moves to the next item |
| ← → | Moves between items, or ↑ ↓ when full-screen |
| Home End | Jumps to the first or last item |
| ↑ ↓ | Not handled by the carousel, so the page gets them |
| Space Enter | Activates the focused item |
Focus stops at the first and last item.
Each item announces its position, so a screen reader reads Sunset over the bay, 3 of 12. The position comes last, so the name starts with the
visible text and voice control can still find it. Pass itemLabel to change the
wording, or set aria-label on an item to replace the whole name.
Focused name: nothing focused
<script lang="ts">
import { Carousel, CarouselItem } from 'noph-ui'
const plates = ['Konvergenz', 'Blaue Pfähle', 'Herbstrhythmus']
let name = $state<string>()
</script>
<div class="shelf">
<Carousel
label="Gemälde"
itemLabel={(text, position, total) => `${text}, Bild ${position} von ${total}`}
>
{#each plates as plate, index (plate)}
<CarouselItem
type="button"
label={plate}
image={`/pollock${index === 0 ? '' : index + 1}.avif`}
onfocus={(event) => (name = event.currentTarget.getAttribute('aria-label') ?? undefined)}
/>
{/each}
</Carousel>
</div>
<p>Focused name: <code>{name ?? 'nothing focused'}</code></p>
<style>
.shelf {
inline-size: 36rem;
max-inline-size: 100%;
--np-carousel-item-height: 10rem;
}
</style>
On the server the total is not known yet. An item is named Sunset over the bay until hydration
adds the position.
API
Carousel
| Attribute | Type | Default |
|---|---|---|
variant | 'multi-browse' | 'uncontained' | 'hero' | 'full-screen' | 'multi-browse' |
alignment | 'start' | 'center' | 'start' |
orientation | 'horizontal' | 'vertical' | vertical for full-screen, horizontal otherwise |
snap | boolean | false for uncontained, true otherwise |
label | string | null | undefined |
itemLabel | (label: string, position: number, total: number) => string | `${label}, ${position} of ${total}` |
Bindables
| Attribute | Type |
|---|---|
element | HTMLDivElement, the carousel root |
scroller | HTMLDivElement, the scroll container |
CarouselItem
| Attribute | Type | Default |
|---|---|---|
type | 'text' | 'link' | 'button' | 'submit' | 'reset' | null | undefined |
label | string | null | undefined |
image | string | null | undefined |
imageAlt | string | undefined |
aspectRatio | number | null | undefined |
disabled | boolean | null | false |
Bindables
| Attribute | Type |
|---|---|
element | HTMLDivElement | HTMLButtonElement | HTMLAnchorElement |