Carousel
MD3 carousel.
Walkthrough
1. 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"/> 2. 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> 3. 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"/> 4. 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]"/> 5. 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> 6. 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"/>