Skip to content
omaris

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'
Learn

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. title names it, footer holds Apply and Cancel, and both snippets are handed close.
  • 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 trigger props onto the real control: a Button, an IconButton, a table cell button. They carry aria-expanded and aria-controls.
  • Give it a title when it holds more than a sentence. It becomes the panel's accessible name.
  • Close it from inside with the close you are handed, so Apply and Cancel both return focus to the trigger.
  • Turn arrow on when several triggers sit close together.
  • Use size="auto" plus class when the panel has its own layout, like a coloured header. Use matchWidth under a full-width trigger, and give the wrapper the width with classes={{ 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
  • sm
  • md (default)
  • lg
  • auto

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-full for 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 bindable

Defaults 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
sm
md
lg
auto
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