Containment
Carousel
MD3 carousel.
import { Carousel } from 'omaris' 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.
<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
herolayout, orcenteredwhen 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 anitemWidth. - A highlights banner that runs itself.
variant="full",autoplayandloop. It pauses under the pointer, on focus, and off screen. - A story: full-bleed slides on a timer, tapped through.
variant="story"withautoplay,indicatorsfor the segmented bar, andheader/footerfor 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
ImageViewerItemand 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 anitemLabelwhen "3 of 6" is not a useful name for an item. - Pin a caption with
w-(--carousel-card)and fade it withopacity-(--carousel-reveal), so it is clipped instead of re-wrapped as the card folds into a strip. - Fill the card: images
object-coveratsize-full. Size the viewport with theheightprop, or useheight="auto"plus anaspect-*onclasses.viewportto size by ratio. - Add
controlswhere a mouse is likely, like a dashboard or a desktop shelf. A finger swipes without them. - Hold a story open with
bind:pausedwhile something else has the screen: a menu, a dialog, a buffering video. Useoncompleteto move to the next story or close the screen. - Put only controls in a story's
headerandfooter. 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
-
autoplaycontent 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
herocarousel. Nobody pages that far; useuncontainedor 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-56inclassto size it. The base sets its own height; theheightprop replaces it.
Quick reference
variant hero(default)centeredmulti-browsefullstoryuncontained
API
Carousel
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
transformat 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 herocenteredmulti-browsefullstory- One slide, edge to edge, on a clock. See the notes above.
uncontained
index bindableDefaults 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 bindableDefaults 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