Containment
List
MD3 lists — one to three lines per row, and rows you can swipe aside for their actions.
import { List } from 'omaris' Examples
Lines
One, two or three lines — a name, a name and a note, a note that wraps. The row height follows, so a list stays even.
<script lang="ts"> import { Avatar, List, ListItem } from 'omaris';</script><div class="w-full max-w-md"> <List variant="outlined" dividers label="Messages"> <ListItem headline="Amina Yusuf" supportingText="Deploy is green again." lines={2}> {#snippet leading()}<Avatar name="Amina Yusuf" size="sm" colorize />{/snippet} {#snippet trailing()}<span class="text-body-sm text-muted-foreground">3m</span>{/snippet} </ListItem> <ListItem headline="Karwan Ali" supportingText="I've moved the Basra delivery to Thursday because the road is closed." lines={3} > {#snippet leading()}<Avatar name="Karwan Ali" size="sm" colorize />{/snippet} </ListItem> <ListItem headline="Hevi Salih"> {#snippet leading()}<Avatar name="Hevi Salih" size="sm" colorize />{/snippet} </ListItem> </List></div> Interactive
interactive makes rows pressable, with a ripple and a selected state — a picker where the choice stays visible, like the current warehouse.
<script lang="ts"> import { List, ListItem } from 'omaris'; let chosen = $state('erbil');</script><div class="w-full max-w-md"> <List variant="surface" label="Warehouses"> <ListItem interactive selected={chosen === 'erbil'} headline="Erbil" supportingText="2 open orders" lines={2} onclick={() => (chosen = 'erbil')} /> <ListItem interactive selected={chosen === 'baghdad'} headline="Baghdad" supportingText="11 open orders" lines={2} onclick={() => (chosen = 'baghdad')} /> <ListItem interactive selected={chosen === 'basra'} headline="Basra" supportingText="No open orders" lines={2} onclick={() => (chosen = 'basra')} /> </List></div> Swipe actions
Drag a row sideways — a finger, a mouse, or two fingers on a trackpad — to reveal its actions; let go and it springs to rest at the speed it had. Carry it clear across and the first action fires. dismiss collapses the row on the way out, so deleting does not leave a gap that jumps closed.
<script lang="ts"> import { List, ListItem, SwipeItem, toast } from 'omaris'; let rows = $state([ { id: 1, from: 'Amina Yusuf', subject: 'Deploy is green again' }, { id: 2, from: 'Karwan Ali', subject: 'Basra delivery moved to Thursday' } ]);</script><div class="w-full max-w-md"> <List variant="outlined" dividers label="Inbox"> {#each rows as row (row.id)} <SwipeItem fullSwipe startActions={[{ label: 'Pin', tone: 'warning', onaction: () => toast('Pinned') }]} endActions={[ { label: 'Delete', tone: 'destructive', dismiss: true, onaction: () => (rows = rows.filter((r) => r.id !== row.id)) } ]} > <ListItem headline={row.from} supportingText={row.subject} lines={2} /> </SwipeItem> {/each} </List> {#if !rows.length} <p class="p-4 text-body-sm text-muted-foreground">Everything swiped away.</p> {/if}</div> Reorder
Reorder takes the container props List takes, so a list becomes reorderable by swapping the tag and binding the array. Drag a row by its grip, or tab to it and press Space, then the arrow keys. Nothing moves in the DOM while you drag — the rows are measured once and slide by exactly the space the lifted one left, so rows of different heights land where they look like they will.
Press Space to pick a row up, the arrow keys to move it, Space to drop it and Escape to put it back.
Overture · The Long Way Round · Harbour Lights · Closing Time<script lang="ts"> import { ListItem, Reorder, ReorderHandle, Text } from 'omaris'; let tracks = $state([ { id: 'a', name: 'Overture', length: '4:12' }, { id: 'b', name: 'The Long Way Round', length: '3:48' }, { id: 'c', name: 'Harbour Lights', length: '5:02' }, { id: 'd', name: 'Closing Time', length: '2:57' } ]);</script><div class="flex w-full max-w-sm flex-col gap-3"> <Reorder bind:items={tracks} variant="outlined" dividers listLabel="Playlist"> {#snippet children(track, ctx)} <ListItem role="presentation" headline={track.name} supportingText={track.length}> {#snippet leading()}<ReorderHandle />{/snippet} {#snippet trailing()} <Text variant="label-sm" tone="muted" as="span">{ctx.index + 1}</Text> {/snippet} </ListItem> {/snippet} </Reorder> <Text variant="label-sm" tone="muted"> {tracks.map((track) => track.name).join(' · ')} </Text></div> When to use it
Use it for
- Rows of the same shape you read down: messages, contacts, warehouses.
headlinealone is one line;supportingTextmakes it two;lines={3}lets the note wrap once more. - Rows the person picks from, like the current warehouse or the selected conversation.
interactivewithselected;hreforonclickmakes the row a real<a>or<button>with no wrapper. - A settings list: an icon in
leading, aSwitchor a time intrailing. - Rows with actions behind them (pin, archive, delete).
SwipeItemaround aListItem,fullSwipeto fire the first action on a long drag,dismissto collapse the row on the way out. - Rows whose order is the data: a playlist, a checklist, dashboard panels.
Reorderinstead ofList,bind:items, and aReorderHandleinleading. It drags with a pointer and moves with the arrow keys.
Not for
- Several columns you compare or sort → Table.
- The app's main navigation → Navigation Drawer or Navigation Bar.
as="nav"is for a secondary list of links, in a sheet or a footer. - Picking one of a few options in a form → Radio or Select. Actions off a button → Menu.
- Moving rows between several lists or columns → Kanban.
Reordermoves rows within one list. - Rows with a heading, a body and their own actions → Card.
Do
- Put
dividerson theList, not aDividerafter each row. There is none after the last row, and a swipe row keeps its line. - Give the
Listalabel("Messages"). It is the accessible name of therole="list". - Use
variant="surface"oroutlinedwhen the list sits on the page among other things;plaininside a card, a sheet or a menu. - Use
density="compact"in a dashboard panel. Leave it comfortable on a phone, where a row is a finger's target. - Give
Reorderakeywhen the rows have ids, so a row keeps its element and its focus across a move. Show aReorderHandleeven when the whole row drags, or nobody knows the list can be reordered.
Don't
- Put a
Switchor aCheckboxintrailingof aninteractiverow. That is a control inside a button. Either the row press toggles it, or the row is not interactive. - Use
spacedon a long inbox. It is for a handful of rows that are different things. - Rely on the swipe alone for an action a mouse user needs. Offer it in an overflow menu too.
- Wrap rows in
<ul>and<li>, or leave the defaultroleon aListIteminside aReorder. The defaultdivwithrole="list"lets a row be a button; inReorderthe wrapper is the item, so passrole="presentation". - Make the whole row draggable when it is a link or holds a control. Use
handle.
Quick reference
variant Listplain(default)surfaceoutlined
padding Listnone(default)smmd
lines ListItem123
density ListItemcomfortable(default)compact
side SwipeItemstartend
API
List
MD3 list.
A plain container: the rows carry the styling. dividers draws the hairline between rows without every row needing to know whether it's the last one.
import { List } from 'omaris' Props
variant Defaults to 'plain'
ListVariant plain- Rows straight on the page.
surface- Rows on a raised surface with rounded ends.
outlined- Same, with a hairline frame.
dividers Defaults to false
boolean Hairline between rows.
spaced Defaults to false
boolean Round each row and space them apart.
padding Defaults to 'none'
'none' | 'sm' | 'md' nonesmmd
label string Accessible name for the list.
as Defaults to 'div'
'div' | 'nav' The element to render. The default div carries role="list", which lets a row be a <button> or <a> directly — a <ul> would need every row wrapped in an <li>, and a wrapper can't take the divider. Use nav for a navigation list.
class string children Snippet ListItem
MD3 list item — one, two or three lines.
The line count is inferred: a row with supporting text is two lines, and one with lines={3} clamps the supporting text at three. Heights follow MD3's 56 / 72 / 88dp.
Give it href or onclick and it becomes a real target — button or link, with the state layer, ripple and focus ring — rather than a div with a cursor.
import { ListItem } from 'omaris' 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.
leading- No description in the source yet.
body- No description in the source yet.
headline- No description in the source yet.
supporting- The second (and third) line.
trailing- Right-hand meta — a time, a count, a switch.
Props
headline string Main line.
supportingText string Second line. Its presence makes the row two lines tall.
lines ListItemLines Force the line count rather than inferring it.
123
density Defaults to 'comfortable'
ListItemDensity comfortablecompact
interactive boolean Force the interactive treatment.
selected Defaults to false
boolean Chosen state — the current page in a nav list, say.
inset Defaults to false
boolean Indent to line up with rows that have a leading slot.
ripple Defaults to true
boolean class string classes ListItemClasses Per-part Tailwind overrides. class still covers the root.
leading Snippet Leading slot — icon, avatar, checkbox.
trailing Snippet Trailing slot — meta text, a switch, an overflow menu.
children Snippet Rich body, in place of headline / supportingText.
variant ButtonVariant Fill style. MD3's five, plus link for inline navigation.
tone ButtonTone Color role the fill style paints with.
size ButtonSize MD3 Expressive heights: 32 / 40 / 56 / 96 / 136dp.
shape ButtonShape Pill (round) or MD3 rounded rectangle (square).
icon boolean Square, label-less button. Requires an aria-label.
confetti boolean | ConfettiOptions Confetti on press. true for the default burst, or any ConfettiOptions — confetti={{ preset: 'fireworks' }}.
loading boolean Grows a spinner in at the start of the button and blocks interaction. The label stays put; only an icon button swaps its icon out.
toggle boolean Turns the button into a two-state toggle driven by pressed. Ignored when href is set — a link has no pressed state.
pressed bindableboolean Selected state of a toggle button. Bindable.
Reorder
A list whose rows you can drag into a different order.
It takes the container props List takes, so an existing list becomes reorderable by swapping the tag and binding the array:
Four things decide how this feels.
Nothing moves in the DOM while you drag. The rows are measured once, on lift, and everything after that is a translate on rows that never change place or identity — the lifted one follows the pointer, the ones it passes slide by exactly the space it left behind. No re-render, no re-keying, no list that reshuffles under a finger. items is rewritten once, on release, so a cancelled drag costs nothing and the array never passes through an order nobody asked for.
The gap is exact. Taking a row out closes precisely its own height plus the list's gap, so rows of different heights land where they look like they will — the usual "shift everything by one row height" only looks right when every row is the same size.
The drop has nothing to see. On release the row glides into its slot and sheds its lift on the way; only once it has arrived is items rewritten, with every transition held off for that one frame, so rows that swap elements under the new order are already standing where the old order left them. Dividers are drawn by position, not by DOM order, so the hairline is where the row is, and every row is the same height whether it is last or not.
It works from the keyboard. Every row is a tab stop: Space picks it up, the arrows move it, Space drops it, Escape puts it back, and each step is announced. A list that can only be reordered with a mouse is not reorderable.
By default a drag starts anywhere on the row that is not a control. handle narrows that to <ReorderHandle />, which is what a row full of text or a row that is itself a link wants.
import { Reorder } from 'omaris' <Reorder bind:items variant="outlined" dividers> {#snippet children(item)} <ListItem role="presentation" headline={item.name}> {#snippet leading()}<ReorderHandle />{/snippet} </ListItem> {/snippet}</Reorder> 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- The list. It takes
List's own look; what it adds is the behaviour a drag needs — no text selection, no page panning, andisolateso a lifted row's z-index is scoped to this list. row- One row's wrapper — the thing that actually moves.
translateis transitioned so a row that has to make way is seen making way;data-livetakes that transition off the row under the pointer, which must not lag behind the finger holding it.data-droppingis the glide into its slot after release: still above its neighbours, but already shedding the lift.data-snapis the one frame in which the new order is written and nothing may animate. handle- The grip. A target in its own right, not just an icon.
status- What a keyboard drag is doing, for anyone who cannot see it.
Props
items required bindableT[] The rows, in order. Bindable — a drop rewrites it.
children required Snippet<[T, ReorderContext]> How to draw one row.
key (item: T, index: number) => string | number Identity, which is what keeps a row the same element across a reorder. Defaults to the item's id, then its index.
label (item: T) => string Names a row for assistive tech during a keyboard drag.
handle Defaults to false
boolean Only <ReorderHandle /> may start a drag. Leave it off and the whole row is the grip — anything but a control inside it.
listLabel string Accessible name for the list.
variant Defaults to 'plain'
ListVariant Container look — the same three List has.
dividers Defaults to false
boolean spaced Defaults to false
boolean padding Defaults to 'none'
'none' | 'sm' | 'md' disabled Defaults to false
boolean onreorder (move: ReorderMove<T>) => void class string classes ReorderClasses Per-part Tailwind overrides. class still covers the root.
ReorderHandle
The grip on a reorderable row.
Drop it in a row's leading or trailing slot. It marks where a pointer drag may begin when <Reorder handle /> is on, and it tells anyone looking that the row moves at all — a list that can be reordered and doesn't say so is a list nobody reorders.
It is deliberately not focusable. The row itself is the tab stop, and carries the whole keyboard gesture, so a handle in the tab order would only be a second stop that does nothing.
import { ReorderHandle } from 'omaris' Props
class string children Snippet Replaces the six-dot glyph.
SwipeItem
A row you can swipe aside to get at its actions.
Wrap anything — a ListItem, a Card, a table row — and it gains the gesture every mail app has: drag it away from either edge and the actions underneath are revealed, let go halfway and it snaps open, throw it and it opens or closes on its own. With fullSwipe, carrying the row clear across fires the first action without a second press.
Only one row is open at a time, anywhere on the page — opening this one closes the last, which is what keeps a long list from turning into a column of half-open drawers.
An action marked dismiss plays the row out: it slides clear, the height collapses, and only then does the callback run, so the list can drop the record while the space is already closing.
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 settles with no kink and no bounce. A trackpad's two-finger swipe is the same gesture, and a tap on an open row closes it, the way every mail app does.
An action fires on the press coming up rather than on the click the browser makes of it, so a tap that lands straight after the swipe works the first time instead of the second.
It works without a pointer: the row answers the arrow keys (the leading pane on <kbd>ArrowRight</kbd> in a left-to-right document, the trailing one on <kbd>ArrowLeft</kbd>, <kbd>Escape</kbd> to close), focus moves into the pane as it opens, and while the row is closed its actions are inert so they stay out of the tab order.
import { SwipeItem } from 'omaris' 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.
clip- No description in the source yet.
actions- The actions as one block — what the row opens far enough to show.
action- No description in the source yet.
row- The part that actually moves.
Props
startActions Defaults to []
SwipeAction[] Actions behind the leading edge — swiping toward the end reveals them.
endActions Defaults to []
SwipeAction[] Actions behind the trailing edge.
fullSwipe Defaults to false
boolean | SwipeSide Let a swipe clear across the row fire that side's first action with no second press. true arms both edges.
fullSwipeThreshold Defaults to 0.55
number Fraction of the row a full swipe has to cross.
labels Defaults to true
boolean Draw the labels under the icons.
open bindableDefaults to null
SwipeSide | null Which side is showing. Bindable — set it to open or close the row.
disabled Defaults to false
boolean onopen (side: SwipeSide | null) => void Fires when the row settles open or closed.
ondismiss () => void Fires once a dismissing action has played out and the row has collapsed.
class string classes SwipeItemClasses Per-part Tailwind overrides. class still covers the root.
classes.row is the one people reach for: the moving row needs an opaque background or the actions behind it show through, so a list on a tinted surface has to restate it.
start Snippet Custom leading pane, in place of startActions.
end Snippet Custom trailing pane, in place of endActions.
children Snippet