Containment
Popover
A panel that hangs off a trigger and holds whatever you put in it — a filter form, a colour picker, a profile card, a confirmation.
import { Popover } from 'omaris' Examples
Basic
The trigger snippet gets the props that wire it to the panel, so the ARIA lands on the real button. close comes back through children.
<script lang="ts"> import { Button, Popover, Text } from 'omaris';</script><Popover title="Retention" arrow> {#snippet trigger(props)} <Button variant="outlined" {...props}>What happens on delete?</Button> {/snippet} {#snippet children({ close })} <Text variant="body-sm" tone="muted"> Deleted items sit in the bin for 30 days, then they are purged. Restoring one puts it back where it was. </Text> <Button size="sm" variant="text" class="mt-3" onclick={close}>Got it</Button> {/snippet}</Popover> Form
A panel that holds a form. Focus moves to the first field on open, Tab cycles inside, Escape closes and hands focus back to the button.
<script lang="ts"> import { Button, Checkbox, Input, Popover } from 'omaris'; let contains = $state(''); let archived = $state(false); let mine = $state(true);</script><Popover title="Filters" description="Narrowing the invoice list." size="lg" align="start" arrow> {#snippet trigger(props)} <Button variant="outlined" {...props}>Filters</Button> {/snippet} <div class="flex flex-col gap-3"> <Input label="Contains" placeholder="invoice" bind:value={contains} /> <Checkbox label="Include archived" bind:checked={archived} /> <Checkbox label="Only mine" bind:checked={mine} /> </div> {#snippet footer({ close })} <Button variant="text" size="sm" onclick={close}>Cancel</Button> <Button size="sm" onclick={close}>Apply</Button> {/snippet}</Popover> Placement
side and align are preferences: the panel flips when there isn't room and shifts to stay on screen. The arrow follows the trigger, not the panel's middle.
<script lang="ts"> import { Button, Popover, Text } from 'omaris'; const sides = ['top', 'bottom', 'left', 'right'] as const;</script><div class="grid grid-cols-2 gap-3"> {#each sides as side (side)} <Popover {side} size="sm" arrow title="Placed {side}"> {#snippet trigger(props)} <Button variant="tonal" class="w-full" {...props}>{side}</Button> {/snippet} <Text variant="body-sm" tone="muted">Scroll the page and it keeps up.</Text> </Popover> {/each}</div> Hover card
openOn="hover" is the preview card: it waits before opening, and stays open long enough for the pointer to travel into it. A click still works on touch.
Merged by after review.
<script lang="ts"> import { Avatar, Button, Popover, Text } from 'omaris';</script><Text variant="body-md"> Merged by <Popover openOn="hover" side="bottom" align="start" size="lg" arrow> {#snippet trigger(props)} <Button variant="text" size="sm" {...props}>@omer</Button> {/snippet} <div class="flex items-start gap-3"> <Avatar name="Omer Chetin" /> <div class="flex flex-col gap-0.5"> <Text variant="title-sm">Omer Chetin</Text> <Text variant="body-sm" tone="muted"> Builds design systems and the tools that write them. </Text> </div> </div> </Popover> after review.</Text> Overridden
size="auto" drops the width and the padding, so the panel is exactly what you put in it — and classes reaches every part, including the scrolling content and the arrow.
<script lang="ts"> import { Button, IconButton, Popover, Text } from 'omaris';</script><div class="flex items-center gap-3"> <Popover size="auto" class="w-64 max-w-full overflow-hidden" align="end"> {#snippet trigger(props)} <IconButton aria-label="Help" {...props}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <circle cx="12" cy="12" r="9" /> <path d="M9.5 9.5a2.5 2.5 0 1 1 3 2.45V14" stroke-linecap="round" /> <circle cx="12" cy="17.5" r="0.75" fill="currentColor" stroke="none" /> </svg> </IconButton> {/snippet} {#snippet children({ close })} <div class="bg-primary-container p-4 text-primary-container-foreground"> <Text variant="title-sm">Shortcut</Text> </div> <div class="flex flex-col gap-2 p-4"> <Text variant="body-sm" tone="muted">Press ⌘K anywhere to jump between projects.</Text> <Button size="sm" variant="text" class="self-end" onclick={close}>Close</Button> </div> {/snippet} </Popover> <Popover matchWidth size="auto" classes={{ trigger: 'w-64', content: 'p-3' }}> {#snippet trigger(props)} <Button variant="outlined" class="w-full" {...props}>Matches its trigger</Button> {/snippet} <Text variant="body-sm" tone="muted"> <code>matchWidth</code> pins the panel to the trigger's width. </Text> </Popover></div> When to use it
Use it for
- A small form off a button: filters, a date range, "add member", a rename field.
titlenames it,footerholds Apply and Cancel, and both snippets are handedclose. - A detail card that would clutter the page: a user preview on
@name, a commit summary, what a metric counts.openOn="hover"waits before opening and survives the move from the trigger into the panel. - A confirmation that should not take over the screen: one line and a destructive button, anchored to the row it is about.
- A colour, icon or emoji picker off the control it fills.
size="auto"gives a panel with no width and no padding of its own. - Anything anchored that must escape a scroll container or a table cell. It is portalled, flips when out of room, and shrinks and scrolls instead of hanging off the bottom of the screen.
Not for
- A list of actions → Menu. One value from a list → Select, or Combobox when it is long enough to search.
- A sentence describing a control → Tooltip. A popover is focusable and dismissible, too much for four words.
- A decision the page must wait for → Dialog. A popover is non-modal and light-dismisses, so unsaved input can vanish on an outside click.
- A form long enough to scroll, or one that needs the whole width → Sheet.
- Something that just happened → Toast.
Do
- Spread the
triggerprops onto the real control: aButton, anIconButton, a table cell button. They carryaria-expandedandaria-controls. - Give it a
titlewhen it holds more than a sentence. It becomes the panel's accessible name. - Close it from inside with the
closeyou are handed, so Apply and Cancel both return focus to the trigger. - Turn
arrowon when several triggers sit close together. - Use
size="auto"plusclasswhen the panel has its own layout, like a coloured header. UsematchWidthunder a full-width trigger, and give the wrapper the width withclasses={{ trigger: 'w-full' }}.
Don't
- Nest one inside a Dialog that also traps focus. Pick one owner of focus, or set
trapFocus={false}on the popover. - Put a long form in it. It caps at 28rem and scrolls, which means the content wants a Sheet.
- Rely on
openOn="hover"for anything essential. A touch screen has no hover. - Open one on scroll or on load. Without a gesture, people cannot tell where it came from.
- Set
autofocus={false}on a click popover with fields in it. The panel opens and the keyboard stays behind it.
Quick reference
size smmd(default)lgauto
API
Popover
A panel that hangs off a trigger and holds whatever you put in it — a filter form, a colour picker, a profile card, a confirmation.
The trigger snippet is handed the ARIA and the handlers to spread onto whatever opens it, so they land on the real button rather than a wrapper. The panel is portalled and anchored, so it escapes scroll containers, flips when it runs out of room and shrinks rather than hanging off the bottom of the screen.
openOn decides the gesture: click is the default and takes focus into the panel, hover is a preview card that survives the trip from the trigger, focus opens on tab, manual leaves it to bind:open.
Keyboard: Enter/Space/↓ open it, Tab cycles inside it, Escape closes it and puts focus back on the trigger.
import { Popover } from 'omaris' <Popover title="Filters"> {#snippet trigger(props)} <Button variant="outlined" {...props}>Filters</Button> {/snippet} {#snippet children({ close })} <Checkbox label="Archived" /> <Button onclick={close}>Apply</Button> {/snippet}</Popover> 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.
trigger- The wrapper around whatever opens it —
w-fullfor a full-width trigger. surface- No description in the source yet.
content- The scrolling part. The arrow sits outside it, so it is never clipped.
header- Title and description, when either is set.
title- No description in the source yet.
description- No description in the source yet.
footer- Actions along the bottom.
arrow- The pointer. A rotated square straddling the edge, with the two inner borders dropped so nothing crosses the surface.
Props
open bindableDefaults to false
boolean Bindable.
onOpenChange (open: boolean) => void Called whenever it opens or closes, however that happened.
openOn Defaults to 'click'
PopoverTrigger The gesture that opens it.
side Defaults to 'bottom'
AnchorSide Which side to prefer. Flips when there isn't room.
align Defaults to 'center'
AnchorAlign offset Defaults to 8
number Gap between the trigger and the panel, in px.
size Defaults to 'md'
PopoverSize smmdlgauto- No width and no padding — for a panel that brings its own.
matchWidth Defaults to false
boolean Make the panel at least as wide as its trigger.
arrow Defaults to false
boolean Show the pointer. It tracks the trigger, not the panel's middle.
title string Heading, and the panel's accessible name.
description string A line under the title.
disabled Defaults to false
boolean Opening is a no-op, and an open one closes.
delay Defaults to 120
number Delay before a hover opens it, in ms.
closeDelay Defaults to 140
number Grace period before a hover closes it — the trip to the panel.
closeOnEscape Defaults to true
boolean Escape closes it.
closeOnOutsideClick Defaults to true
boolean A press outside closes it.
trapFocus boolean Keep Tab inside the panel while it is open. Defaults to on for click and manual, off for the pointer-driven modes.
autofocus boolean | string What to focus on open: the first focusable thing (true), the panel itself (false), or a CSS selector for one element inside it. Defaults to true for click and manual, false otherwise.
portalTo PortalTarget Where the panel is portalled. The body, by default.
mobileSheet boolean On a phone, rise from the bottom edge as a sheet — full width, a scrim behind, dragged down to dismiss — instead of hanging off the trigger. On by default for a popover opened by a press; false keeps it anchored on every screen.
class string Merged onto the panel, so class="w-80" resizes it.
classes PopoverClasses Per-part Tailwind overrides. class still covers the panel.
trigger Snippet<[PopoverTriggerProps]> Receives the props to spread onto the opening control.
children Snippet<[PopoverBody]> The panel's contents.
footer Snippet<[PopoverBody]> Buttons along the bottom, separated from the content.
aria-haspopup required 'dialog' aria-expanded required boolean aria-controls required string | undefined onclick required (event: MouseEvent) => void onkeydown required (event: KeyboardEvent) => void