Carousels
A carousel puts a scrolling strip of visual items beside each other. Carousel owns
the scroller, CarouselItem is one item, and the items change width as they travel so the
strip always ends on something partly visible.
Items are cropped, never squashed. An item's box keeps one size at every scroll offset and a mask narrows what you see, so a photograph never distorts.
Usage
Give the carousel an accessible name and each item a type. The multi-browse layout is the default: one or more large items, then a medium and a small
one to show there is more to reach.
An item's height comes from its content, so an item that carries only an image has
none of its own. Give the carousel a --np-carousel-item-height in that case, the way 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 layouts the spec draws. multi-browse shows large, medium and small items together and is the M3 default. uncontained holds every item at the size it was given and lets them run past the
edge, cropping only at the two ends of the scrollport; give its items a per-item aspectRatio and you have the spec's multi-aspect layout, one strip of genuinely
different shapes. hero gives one item roughly twice the height in width and peeks the
next. full-screen shows one item at a time and scrolls on the block axis, so its arrow
keys are up and down.
All five, each in the sizing its own layout 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 those two are the only
layouts that run any JavaScript. uncontained crops at the edges from a view() timeline, which needs no measurement because both halves of it are percentages
of the item; full-screen is CSS scroll snapping and nothing else.
An uncontained item can declare its own aspectRatio instead of taking --np-carousel-item-width, which is how one strip holds a landscape clip beside a
portrait one. The ratio resolves against the carousel's cross axis, so that axis has to be
definite; it also sets how far the item crops on its way out, because a wider item can give up
more before what is left reads as a sliver.
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 rather than being left on a small keyline. Google's carousel does the same and puts the reason plainly: the first and last items should never detach from the edges of the container.
A carousel whose items all fit keeps them the size you asked for. There is nothing further to reach, so shrinking the trailing ones would only leave them small for good.
Items and labels
label is a string rather than a snippet, because it is also the basis of the item's
accessible name and a snippet cannot be read into one. It renders along the bottom leading edge
over a gradient scrim so it stays legible on a photograph. Pass image for a
background image, or children for anything richer; both can carry a label alongside.
<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
type is required, because whether an item is interactive decides its semantics, its
place in the tab order and whether it gets a state layer at all. A button item and a link item are focusable and rippled; a text item is neither. A carousel
made only of text items has no keyboard path of its own, so it depends entirely 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 type="link" label="Link" image="/pollock2.avif" href="#interactive-items" />
<CarouselItem type="text" 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>
Activating an item is your business: the component gives the feedback and calls your handler, and what happens next — opening a detail page, a dialog, playing something — is up to you. Focusing or clicking an item also scrolls it to a full-size position, so a narrow one becomes readable before anything else happens.
Nothing is ever hidden from a click: the mask crops hit testing exactly as it crops the picture, so a narrow item's visible sliver is what receives the pointer.
Show all
On a vertically scrolling page a carousel requires a way to see every item without scrolling sideways. This is the one part of the spec the library deliberately renders nothing for: the destination is a route of your own, and the spec forbids putting the control inside or beside the carousel, so it cannot be the component's to place.
Use a Show all text button below the carousel, with 4dp of padding around it. If the
carousel has a header, an arrow IconButton next to that header works instead; it
should be 48dp, and the header should align with the leading edge of the carousel and appear again
on the all-items page. The exemption is full-screen, which does not need one.
Two things the spec explicitly rules out: do not put buttons inside the carousel container or beside it, and do not lay anything over the carousel. That includes the left and right chevrons other carousel libraries ship with.
<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 the carousel properties on Carousel and the item properties on CarouselItem; item properties also inherit if you set them on the carousel. State
layer opacities come from the --np-ripple-* tokens, which
already match the spec.
| 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 depart from the spec's token sheet on purpose. The container colour is transparent rather than surface, because an item is a window onto an
image and a filled default would flash a pale rectangle before every picture decodes; set it
yourself for a text or icon item. The outline width is 0 rather than 1dp, because
every rendered example in the spec is a photograph with no border and a hairline over a photo edge
reads as an artefact; it is forced back on under forced-colors, where it is the only boundary left. And a disabled item dims to 0.38
rather than repainting its container, which over an image would dim it twice.
The spec's deprecated surface tint layer color token is not implemented. Note also
that elevation stops at --np-elevation-3; no carousel state needs more.
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, so the browser runs it off the main thread |
| Shape on press | --np-motion-expressive-fast-effects, held 100ms past the pointer lift |
| 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 offsets by what the crop took |
| Scrolling to a focused item | scrollIntoView with behavior: 'smooth', so the browser's own snap
drives it |
The morph is a CSS scroll-driven animation, so there is no scroll listener and no animation frame anywhere in the component. Each item gets its own keyframes block over the scroller's full scroll range: a single shared block would be smaller, but the focal run shifts at the ends of the strip so that the first and last items can be large, which makes an item's shape depend on where the whole strip is rather than only on its own distance from the focal keyline.
It animates a logical inset and clip-path and never a size, which is what makes a
scroll timeline usable here: neither touches layout, so the animation cannot move the scrollable
area it is timed against. Offsetting on an inset rather than a translate also keeps the offset and the mask in the same paint pass, and mirrors itself
in a right-to-left document without a sign.
JavaScript is left with only the arithmetic CSS cannot do — choosing which keyline arrangement fits the container — and that runs on resize, not on scroll. Where scroll-driven animations are not supported the items simply stay uniform, which is the same layout reduced motion and a page without JavaScript get, so the fallback costs nothing.
Dragging, flinging and snapping are the browser's own; the component adds no pointer handling and never writes the scroll position while you are scrolling.
Snapping is mandatory, so the strip always comes to rest on a keyline and never
leaves an item half collapsed. That is only comfortable because the morph is a scroll-driven
animation: the browser's own snap animation drives the timeline, so the shape follows it smoothly.
The scroller deliberately sets no scroll-behavior of its own — on a snap container it makes
the browser animate every snap correction, and the next wheel notch interrupts that animation, which
is what leaves a strip resting between keylines with a half-masked item.
The morph offsets each item with a logical inset and crops it with clip-path, both in
one animation. That single detail matters more than it looks: a visible edge is the offset plus
the crop, so if the two ever land in separate animations the browser can put one on the compositor
and the other on the main thread, sample them a frame apart, and the item widths and the gaps
between them stop adding up — by an amount that grows with scroll speed.
An item's label tracks the crop rather than the item's box, so a narrowed item keeps the start of its text and simply ellipsises as the room runs out.
Under prefers-reduced-motion the resizing is switched off entirely, as the spec requires:
every item takes one size, and the leading and trailing padding collapses so items reach the edges instead
of being clipped. This is also the layout you get before hydration and with JavaScript switched off,
because it is the plain CSS behaviour rather than a special case.
Accessibility
The carousel is a group with aria-roledescription="carousel" and the
name you give it. Pass role="region" if it really is a top-level page section; a page
with several shelves should not add several landmarks. It is deliberately not a listbox — nothing is selected — and not a list, because a list item cannot also be a button.
The container itself is never focusable. Tab lands on the first item and moves through the items one by one, so this is not a roving tabindex.
| Keys | Action |
|---|---|
| Tab | Moves to the next item |
| ← → | Moves between items, or ↑ ↓ when full-screen |
| Home End | Jumps to the first or last item |
| ↑ ↓ | Left alone, so they leave the carousel for the rest of the page |
| Space Enter | Activates the focused item |
Focus never wraps from the last item back to the first, which would fling the scroll position back to the start. Focusing an item scrolls it to a full-size position, because an item counts as on screen while only a sliver of it shows.
Each item announces its place in the run, so a screen reader reads Sunset over the bay, 3 of 12. The position goes at the end so the visible text stays
the start of the name and voice control still finds it. Give itemLabel to change the
wording, or set your own aria-label on an item to take it over completely.
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>
Two notes on the edges. Server side the total is not yet known, so an item is named Sunset over the bay and hydration adds the position; an incomplete name is fine where
a wrong one would not be. And a cropped item is never given aria-hidden or inert: only the picture is cropped, the item is fully present, and hiding a focusable
element from the accessibility tree would be a real failure rather than a tidy-up.
One known limit, shared with tabs: the arrow keys are physical, so in a right-to-left document left and right are swapped relative to visual order.
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' | 'button' | 'link' | required |
label | string | null | undefined |
image | string | null | undefined |
aspectRatio | number | null | undefined |
disabled | boolean | null | false |
Bindables
| Attribute | Type |
|---|---|
element | HTMLDivElement | HTMLButtonElement | HTMLAnchorElement |