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-browse
uncontained
uncontained with a per-item aspectRatio
hero
full-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.

Recent paintings

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

PropertyDefault
--np-carousel-item-width12.5rem, the preferred width of a large item
--np-carousel-item-heightauto
--np-carousel-padding1rem (16dp) along the scroll axis
--np-carousel-cross-padding0.5rem (8dp)
--np-carousel-item-spacing0.5rem (8dp)
--np-carousel-small-item-min-width2.5rem (40dp)
--np-carousel-small-item-max-width3.5rem (56dp)
--np-carousel-length100%, the block size of a vertical carousel
--np-carousel-scrollbar-widthnone
--np-carousel-snap-strictnessmandatory
--np-carousel-snap-stopnormal, always for full-screen
--np-carousel-item-container-colortransparent
--np-carousel-item-container-shape--np-shape-corner-extra-large (28dp)
--np-carousel-item-pressed-container-shape--np-shape-corner-medium
--np-carousel-item-elevationnone
--np-carousel-item-hover-elevation--np-elevation-1 (1dp)
--np-carousel-item-outline-color--np-color-outline
--np-carousel-item-outline-width0
--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-color60% --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 movesHow
Item size as it scrollsA 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 layersThe ripple's own tokens
Focus ring--np-motion-expressive-slow-effects
Uncontained items at the edgesA 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 itemscrollIntoView 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.

KeysAction
TabMoves to the next item
Moves between items, or when full-screen
Home EndJumps to the first or last item
Left alone, so they leave the carousel for the rest of the page
Space EnterActivates 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

AttributeTypeDefault
variant'multi-browse' | 'uncontained' | 'hero' | 'full-screen''multi-browse'
alignment'start' | 'center''start'
orientation'horizontal' | 'vertical'vertical for full-screen, horizontal otherwise
snapbooleanfalse for uncontained, true otherwise
labelstring | nullundefined
itemLabel(label: string, position: number, total: number) => string`${label}, ${position} of ${total}`

Bindables

AttributeType
elementHTMLDivElement, the carousel root
scrollerHTMLDivElement, the scroll container

CarouselItem

AttributeTypeDefault
type'text' | 'button' | 'link'required
labelstring | nullundefined
imagestring | nullundefined
aspectRationumber | nullundefined
disabledboolean | nullfalse

Bindables

AttributeType
elementHTMLDivElement | HTMLButtonElement | HTMLAnchorElement