Skip to content
omaris

Containment

Carousel

MD3 carousel.

import { Carousel } from 'omaris'
Learn

Examples

Basic

The default hero layout: one card in front, the next peeking as a strip. Drag it and watch the strip grow into a card — every width is a function of the scroll position, so it tracks your finger instead of snapping when you let go. The caption fades with reveal and keeps the card's resting width, so it never re-wraps mid-drag.

<script lang="ts">	import { Carousel } from 'omaris';​	const PLACES = [		{ title: 'Erbil citadel', tint: 'from-primary to-tertiary' },		{ title: 'Baghdad riverside', tint: 'from-tertiary to-secondary' },		{ title: 'Basra canals', tint: 'from-info to-primary' },		{ title: 'Duhok hills', tint: 'from-success to-info' },		{ title: 'Najaf at dusk', tint: 'from-warning to-destructive' },		{ title: 'Kirkuk in spring', tint: 'from-secondary to-primary' }	];​	let index = $state(0);</script>​{#snippet place(item: (typeof PLACES)[number])}	<div class="size-full bg-linear-to-br {item.tint}" aria-hidden="true"></div>	<!--		Pinned to the front card's resting width and faded by how much of a		card this is, so the title is clipped rather than re-wrapped as the		card narrows, and dissolves instead of popping.	-->	<span		class="absolute start-0 bottom-0 w-(--carousel-card) bg-linear-to-t from-black/55 to-transparent p-4 text-label-lg text-white opacity-(--carousel-reveal)"	>		{item.title}	</span>{/snippet}​<Carousel	items={PLACES}	item={place}	bind:index	label="Places"	height="16rem"	controls	indicators	class="max-w-2xl"/>

Layouts

Five layouts, all Material's own. hero and centered keep one item in front; multi-browse shows large, medium and small together; full is one item edge to edge; uncontained is a plain scroller of equal cards.

<script lang="ts">	import { Carousel, SegmentedButton, type CarouselVariant } from 'omaris';​	const TINTS = [		'from-primary to-tertiary',		'from-tertiary to-secondary',		'from-info to-primary',		'from-success to-info',		'from-warning to-destructive',		'from-secondary to-primary'	];​	let variant = $state<CarouselVariant>('multi-browse');</script>​{#snippet panel(tint: string, ctx: { index: number })}	<div class="size-full bg-linear-to-br {tint}" aria-hidden="true"></div>	<span class="absolute start-3 bottom-3 text-label-lg text-white opacity-(--carousel-reveal)">		{ctx.index + 1}	</span>{/snippet}​<div class="flex w-full max-w-2xl flex-col gap-4">	<SegmentedButton		label="Layout"		size="sm"		mandatory		value={variant}		onchange={(next) => (variant = next as CarouselVariant)}		items={[			{ value: 'hero', label: 'Hero' },			{ value: 'centered', label: 'Centered' },			{ value: 'multi-browse', label: 'Multi-browse' },			{ value: 'full', label: 'Full' },			{ value: 'uncontained', label: 'Uncontained' }		]}	/>​	<Carousel items={TINTS} item={panel} {variant} label="Panels" height="14rem" indicators /></div>

Autoplay and loop

autoplay advances on a timer and loop wraps at the ends. Autoplay pauses under the pointer, while anything inside has focus, and when the carousel scrolls off screen — so it never fights a reader or a keyboard.

<script lang="ts">	import { Carousel } from 'omaris';​	const SLIDES = [		{			title: 'Erbil',			line: 'Six thousand years, still occupied',			tint: 'from-primary to-tertiary'		},		{ title: 'Basra', line: 'Canals, dhows and date palms', tint: 'from-info to-primary' },		{ title: 'Duhok', line: 'Green for exactly one month', tint: 'from-success to-info' }	];</script>​{#snippet slide(item: (typeof SLIDES)[number])}	<div class="size-full bg-linear-to-br {item.tint}" aria-hidden="true"></div>	<div class="absolute inset-0 flex flex-col justify-end p-5">		<p class="text-title-md text-white">{item.title}</p>		<p class="text-body-sm text-white/85">{item.line}</p>	</div>{/snippet}​<Carousel	items={SLIDES}	item={slide}	variant="full"	label="Places"	height="13rem"	autoplay={2600}	loop	indicators	class="max-w-md"/>

Story

variant="story": one slide edge to edge, a segmented timer across the top, and the gestures a story has taught everyone — tap the leading third to go back, the rest to go on, press and hold to freeze it and take the chrome off the picture. The bar and the turn are the same clock, so they can't drift, and a hold carries on from where it stopped. header and footer are scrims over the slide whose controls stay clickable while the space between them still taps through; bind:paused is the caller's own hold — here, for as long as the menu is open.

Omaris omaris 22h
Hold to pause · tap the edges to move
<script lang="ts">	import { Avatar, Carousel, IconButton, Menu, MenuItem, Text } from 'omaris';​	const SLIDES = [		{ caption: 'Left Erbil at six', time: '22h', tint: 'from-primary to-tertiary' },		{ caption: 'Tea at the pass', time: '20h', tint: 'from-tertiary to-secondary' },		{ caption: 'Rain the whole way down', time: '18h', tint: 'from-info to-primary' },		{ caption: 'Home before dark', time: '16h', tint: 'from-success to-info' }	];​	let index = $state(0);	let menuOpen = $state(false);	let finished = $state(false);	let liked = $state(false);</script>​{#snippet slide(item: (typeof SLIDES)[number])}	<div class="size-full bg-linear-to-br {item.tint}" aria-hidden="true"></div>	<div class="absolute inset-0 grid place-items-center p-10 text-center">		<Text variant="headline-sm" class="text-white">{item.caption}</Text>	</div>{/snippet}​{#snippet head(item: (typeof SLIDES)[number])}	<Avatar name="Omaris" size="sm" class="ring-2 ring-white/80" />	<Text variant="label-lg">omaris</Text>	<Text variant="label-sm" class="flex-1 text-white/70">{item.time}</Text>​	<Menu bind:open={menuOpen} label="Story actions" side="bottom" align="end">		{#snippet trigger(props)}			<IconButton size="sm" aria-label="Story actions" class="text-white" {...props}>				<svg viewBox="0 0 24 24" fill="currentColor">					<circle cx="12" cy="5" r="1.75" />					<circle cx="12" cy="12" r="1.75" />					<circle cx="12" cy="19" r="1.75" />				</svg>			</IconButton>		{/snippet}​		<MenuItem>Save photo</MenuItem>		<MenuItem>Share as post…</MenuItem>		<MenuItem>Story settings</MenuItem>		<MenuItem tone="destructive">Delete</MenuItem>	</Menu>{/snippet}​{#snippet actions()}	<Text variant="label-sm" class="flex-1 text-white/70">		{finished ? 'That was the last one' : 'Hold to pause · tap the edges to move'}	</Text>​	{#if finished}		<IconButton			size="sm"			class="text-white"			aria-label="Watch again"			onclick={() => {				finished = false;				index = 0;			}}		>			<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">				<path d="M3 12a9 9 0 1 0 3-6.7M3 4v5h5" stroke-linecap="round" stroke-linejoin="round" />			</svg>		</IconButton>	{:else}		<IconButton			size="sm"			class="text-white"			aria-label={liked ? 'Unlike' : 'Like'}			aria-pressed={liked}			onclick={() => (liked = !liked)}		>			<svg				viewBox="0 0 24 24"				fill={liked ? 'currentColor' : 'none'}				stroke="currentColor"				stroke-width="2"			>				<path					d="M12 20s-7-4.5-7-9.5A4 4 0 0 1 12 8a4 4 0 0 1 7 2.5c0 5-7 9.5-7 9.5Z"					stroke-linejoin="round"				/>			</svg>		</IconButton>	{/if}{/snippet}​<Carousel	variant="story"	items={SLIDES}	item={slide}	header={head}	footer={actions}	bind:index	bind:paused={menuOpen}	autoplay={5000}	indicators	controls	label="Omaris story"	itemLabel={(item, i) => `${i + 1} of ${SLIDES.length}: ${item.caption}`}	oncomplete={() => (finished = true)}	height="30rem"	class="mx-auto max-w-[20rem]"/>

Morph

morph dissolves one slide into the next instead of sliding it: the picture melts, ripples or shears while the next one fades in over it, and a drag holds it mid-melt until you let go. For full and story only.

<script lang="ts">	import { Carousel, SegmentedButton, type CarouselMorph } from 'omaris';​	const PLACES = [		{ title: 'Erbil citadel', tint: 'from-primary to-tertiary' },		{ title: 'Baghdad riverside', tint: 'from-tertiary to-secondary' },		{ title: 'Basra canals', tint: 'from-info to-primary' },		{ title: 'Duhok hills', tint: 'from-success to-info' }	];​	let mode = $state<CarouselMorph>('melt');</script>​{#snippet place(item: (typeof PLACES)[number])}	<div class="size-full bg-linear-to-br {item.tint}" aria-hidden="true"></div>	<div class="absolute inset-x-0 bottom-0 grid grid-cols-6 gap-2 p-4">		{#each Array.from({ length: 6 }, (_, i) => i) as cell (cell)}			<div class="h-6 rounded-shape-sm bg-background/40"></div>		{/each}	</div>	<span class="absolute start-4 top-4 text-title-md text-white">{item.title}</span>{/snippet}​<div class="flex w-full max-w-2xl flex-col items-center gap-4">	<Carousel		items={PLACES}		item={place}		variant="full"		morph={mode}		loop		label="Places"		height="16rem"		controls		indicators		class="w-full"	/>	<SegmentedButton		bind:value={mode}		items={[			{ value: 'melt', label: 'Melt' },			{ value: 'ripple', label: 'Ripple' },			{ value: 'shear', label: 'Shear' }		]}		label="Morph"	/></div>

Overridden

The height is a custom property rather than a class, so it can be replaced rather than fought with: height="auto" clears it and an aspect ratio on classes.viewport takes over. itemWidth sizes the cards in an uncontained row, and classes.item restyles them.

<script lang="ts">	import { Carousel } from 'omaris';​	const TEAM = [		{ name: 'Lina', role: 'Design' },		{ name: 'Omar', role: 'Frontend' },		{ name: 'Sara', role: 'Data' },		{ name: 'Yousif', role: 'Support' },		{ name: 'Dilan', role: 'Ops' }	];</script>​{#snippet member(item: (typeof TEAM)[number])}	<div class="flex size-full flex-col items-center justify-center gap-1">		<span class="text-title-sm text-foreground">{item.name}</span>		<span class="text-body-sm text-muted-foreground">{item.role}</span>	</div>{/snippet}​<Carousel	items={TEAM}	item={member}	variant="uncontained"	label="Team"	height="auto"	itemWidth={128}	gap={12}	classes={{		viewport: 'aspect-[5/1]',		item: 'rounded-shape-md border border-border bg-surface-container-lowest'	}}	class="max-w-xl"/>

When to use it

Use it for

  • A shelf of visual things where one is in front and the rest peek: product photos, places, featured content. The default hero layout, or centered when the front item should sit in the middle.
  • A browsing row of many equal cards: "more like this", a team, a set of templates. variant="uncontained" with an itemWidth.
  • A highlights banner that runs itself. variant="full", autoplay and loop. It pauses under the pointer, on focus, and off screen.
  • A story: full-bleed slides on a timer, tapped through. variant="story" with autoplay, indicators for the segmented bar, and header/footer for the name and the actions over it. Onboarding, a release tour, a daily recap.
  • A front item that something else follows, like a caption, a thumbnail strip or a map pin. bind:index.

Not for

  • Pictures the person wants to see all at once → Masonry or a plain grid. A carousel hides everything but the front.
  • Looking closely at one picture → Image Viewer. Wrap each item in an ImageViewerItem and the carousel becomes the gallery.
  • Steps that are not visual, like a wizard → a morphing Dialog or Tabs.
  • Rows of text → List.

Do

  • Give it a label ("Product photos"), and an itemLabel when "3 of 6" is not a useful name for an item.
  • Pin a caption with w-(--carousel-card) and fade it with opacity-(--carousel-reveal), so it is clipped instead of re-wrapped as the card folds into a strip.
  • Fill the card: images object-cover at size-full. Size the viewport with the height prop, or use height="auto" plus an aspect-* on classes.viewport to size by ratio.
  • Add controls where a mouse is likely, like a dashboard or a desktop shelf. A finger swipes without them.
  • Hold a story open with bind:paused while something else has the screen: a menu, a dialog, a buffering video. Use oncomplete to move to the next story or close the screen.
  • Put only controls in a story's header and footer. Links, buttons and fields there stay clickable; everything else taps through, so a wide label between two buttons does not eat the tap that advances the story.

Don't

  • autoplay content people need to read or act on. It is for ambient highlights, not a form or a list of offers.
  • Put twenty items in a hero carousel. Nobody pages that far; use uncontained or a wall.
  • Put text that has to wrap, or a form, inside an item. Strips fold and clip whatever is in them.
  • Give a story slides that have to be read. A person gets one hold and one tap-back, not a second reading.
  • Write h-56 in class to size it. The base sets its own height; the height prop replaces it.

Quick reference

variant
  • hero (default)
  • centered
  • multi-browse
  • full
  • story
  • uncontained

API

MD3 carousel.

The layouts are Material's own: hero (one large item with the next peeking as a strip), centered (strips either side of the big one), multi-browse (large, medium, small in a row), full (one item edge to edge) and uncontained (a plain scroller of equal items).

One clock, one owner. The strip does not scroll natively. A finger, a mouse, a trackpad, an arrow and the autoplay timer all write the same number — the fractional index the row is resting at — and one animation frame reads it back and places every card with a transform. Nothing is ever a frame behind anything else: the old build let the browser scroll and then chased it with JavaScript, so every card trailed the finger by a frame, froze on iOS while momentum ran, and jolted whenever snapping was switched back on under a moving gesture.

It lands like a thrown thing. Letting go hands the release velocity to a critically damped spring, so the row keeps the speed it had under the finger and decelerates into the nearest item with no kink and no bounce. A press mid-flight stops it dead under the thumb. Arrows, dots and a caller setting index glide on MD3's emphasized-decelerate, the same curve as everything the library animates in CSS. The ends give with a rubber band rather than a wall, and loop really loops — the last item folds straight into the first in either direction.

The keyline morph is continuous: every item's width and offset are a function of that one number, so strips grow into cards and cards fold into strips while you drag, tracking your finger frame for frame. Only the cards on screen are in layout at all, each on its own compositor layer, and geometry is written straight to their styles — the reactive graph only hears about the things the template needs (which item is in front, which are strips).

Captions ride along. An item's box narrows continuously as it folds into a strip, and text inside a narrowing box re-wraps on every frame. So the snippet is told two more things: fullWidth, the front card's resting width, to pin a caption to (w-(--carousel-card)) so it is clipped rather than reflowed; and reveal, 0 for a strip to 1 for the card in front, to fade it by (opacity-(--carousel-reveal)). Both are also set as custom properties.

`morph` melts one slide into the next. For full and story the slides need not slide at all: with morph on, the front slide stays put and the next one dissolves in over it while both are warped by a noise displacement that peaks halfway and settles to nothing — dragged, the warp tracks the finger; released, it lands with the spring. melt drips the picture downward, ripple breaks it into water, shear slices it sideways. It is an SVG filter on the two cards involved and nothing else, so it costs the same whether there are two slides or two hundred.

A story is the same primitive, told by a clock. variant="story" is one full-bleed slide with a segmented timer across the top, tap zones either side, and press-and-hold to freeze it. The bar and the turn are the same clock, so they cannot drift apart, and a hold resumes where it stopped rather than restarting the slide.

import { Carousel } from 'omaris'
<Carousel items={photos} bind:index indicators controls>  {#snippet item(photo)}    <img src={photo.src} alt={photo.alt} class="size-full object-cover" />    <span class="absolute start-0 bottom-0 w-(--carousel-card) p-4 opacity-(--carousel-reveal)">      {photo.title}    </span>  {/snippet}</Carousel>

Parts

Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.

root
No description in the source yet.
frame
Holds the track and the controls floated over it.
viewport
The window onto the row. Horizontal touches are ours, vertical ones stay with the page, and a pinch still zooms.
track
The row the cards are placed on. Fills the viewport; never moves itself.
item
One card, placed by transform at its morphed width. Its own compositor layer, and its layout contained, so a card narrowing costs the browser that card and nothing around it.
strip
A strip is a preview — pressing it brings it forward.
control
Prev/next, floated over the viewport's edges.
indicators
No description in the source yet.
dot
No description in the source yet.
segments
Story: the timer, one segment per slide, floated over the top.
segment
One slide's share of the timer.
segmentFill
How much of that share has run. Its width is the clock.
header
A scrim across the top for a name, a time, an overflow menu. The scrim itself takes no pointer events and the controls inside it do, so a tap between them still reaches the story. Anything interactive that isn't a link, button or field needs its own pointer-events-auto.
footer
The same, across the bottom, for actions.

Props

items required
T[]
item required
Snippet<[T, CarouselItemContext]>

How to draw one item. It fills the card; images should object-cover.

variant

Defaults to 'hero'

CarouselVariant
hero
centered
multi-browse
full
story
One slide, edge to edge, on a clock. See the notes above.
uncontained
index bindable

Defaults to 0

number

The item in front. Bindable.

gap
number

Gap between items, in px. Defaults to 8, and to 0 for a story.

stripWidth

Defaults to 56

number

Width of a peeking strip, in px.

itemWidth

Defaults to 240

number

Item width for uncontained, in px.

loop

Defaults to false

boolean

Wrap around at the ends. With enough items to fill the row it is a true loop — the last folds into the first in either direction; with fewer, the ends still join but by a cut.

autoplay

Defaults to 0

number

Advance every this many ms. Pauses under the pointer, on focus, and offscreen — a story instead pauses while held, while a key has focused it, and whenever paused is set.

controls

Defaults to false

boolean

Draw prev/next buttons.

indicators

Defaults to false

boolean

Draw the dots.

label
string

Accessible name — "Product photos".

itemLabel
(item: T, index: number) => string

Names each item for assistive tech. Defaults to "n of total".

onchange
(index: number) => void
oncomplete
() => void

The last slide's timer ran out with loop off — the story is over.

header
Snippet<[T, CarouselItemContext]>

Drawn across the top of the frame, over the slide: a name, a time, a close button. Direct children of it are clickable; everything around them still taps through to the story.

footer
Snippet<[T, CarouselItemContext]>

The same, across the bottom: actions, a reply box.

paused bindable

Defaults to false

boolean

Held open by the caller — a menu is up, a video is buffering. Bindable. A press-and-hold is not written back here — it is its own state, read from the root's data-held attribute.

height
number | string

How tall the viewport is. A number is px, a string is any CSS length. Defaults to 14rem, and to 30rem for a story — 100dvh for one that fills the screen.

The default lives in a custom property rather than a utility class, so a height in classes.viewport genuinely replaces it. To size by ratio instead, clear the height and let the aspect class govern: height="auto" with classes={{ viewport: 'aspect-4/5' }}.

morph

Defaults to false

boolean | CarouselMorph

Dissolve between slides with a warp instead of sliding them, for full and story. true is melt; see CarouselMorph for the others. Ignored by the morphing layouts, which have their own motion.

morphStrength

Defaults to 48

number

How far the warp displaces at its peak, in px.

class
string
classes
CarouselClasses