Popover
A floating panel anchored to a trigger element, used for rich inline content.
import { ... } from '@corndev/ui-overlays' HTML
<div class="corn-popover" data-placement="bottom" role="dialog">
<div class="corn-popover-header">
<h3 class="corn-popover-title">Details</h3>
<button class="corn-popover-close" aria-label="Close">×</button>
</div>
<div class="corn-popover-body">…</div>
</div>
Classes
| Class |
|---|
.corn-popover |
.corn-popover-header |
.corn-popover-title |
.corn-popover-close |
.corn-popover-body |
Attributes
| Attribute | Values |
|---|---|
data-visible | present or absent |
data-placement | top|bottom|left|right |
Vanilla JS
Required for behaviour — adds opens against a trigger, flips near a viewport edge, closes on Escape and outside click, and with trapFocus holds focus, makes the page behind inert and returns focus to the trigger.
import { createPopover } from '@corndev/ui-overlays';
import '@corndev/ui/theme';
const triggerEl = document.querySelector('#target');
const instance = createPopover(triggerEl, { /* options */ });
Options
| Option | Type | Default | Description |
|---|---|---|---|
content | string|HTMLElement | required | Popover body content (required). A string is shown as text, never parsed as HTML; pass an element for markup. |
title | string | undefined | Optional heading inside the popover |
placement | 'top'|'bottom'|'left'|'right' | 'bottom' | Preferred placement |
defaultOpen | boolean | false | Open as soon as it is created. |
onChange | (open: boolean) => void | undefined | Called whenever it opens or closes, with the new state — by the trigger, Escape, an outside click, or open()/close(). |
trapFocus | boolean | false | Make it a modal dialog: focus moves in and Tab stays inside, the page behind goes inert, Escape answers only the top layer, and closing hands focus back to the trigger — the same code Modal and Drawer run. Without it the popover is a non-modal panel and the page stays live. |
closeOnOutsideClick | boolean | true | Dismiss when clicking outside |
onOpen | Function | undefined | Called when popover opens |
onClose | Function | undefined | Called when popover closes |
React
Popover — from @corndev/ui-overlays-react.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | required | The trigger element (must accept ref) |
content | React.ReactNode | required | Popover body content |
title | string | undefined | Optional popover heading |
placement | 'top'|'bottom'|'left'|'right' | 'bottom' | Preferred placement |
open | boolean | undefined | Whether it is open (controlled). Pair with onChange: the popover then never opens or closes by itself — the trigger, Escape and an outside click all report through onChange and leave the decision to you. |
defaultOpen | boolean | false | Open on first render (uncontrolled). |
onChange | (open: boolean) => void | undefined | Called with the state the reader asked for. |
trapFocus | boolean | false | Make it a modal dialog: focus moves in and Tab stays inside, the page behind goes inert, Escape answers only the top layer, and closing hands focus back to the trigger — the same code Modal and Drawer run. Without it the popover is a non-modal panel and the page stays live. |
closeOnOutsideClick | boolean | true | |
onOpen | Function | undefined | |
onClose | Function | undefined |
React usage
import { Popover } from '@corndev/ui-overlays-react';
import '@corndev/ui-overlays/styles';
<Popover content="This feature is only available on the Pro plan." title="Info">
<button>What's this?</button>
</Popover>
Rich content
Pass a React node to content for anything beyond plain text.
<Popover
title="Actions"
content={
<ul>
<li><button>Edit</button></li>
<li><button>Delete</button></li>
</ul>
}
>
<button>Actions</button>
</Popover>
Placement
Defaults to bottom. Auto-flips if there isn’t enough space in the viewport.
<Popover content="Above" placement="top"><button>Top</button></Popover>
<Popover content="Below" placement="bottom"><button>Bottom</button></Popover>
<Popover content="Left" placement="left"><button>Left</button></Popover>
<Popover content="Right" placement="right"><button>Right</button></Popover>
Controlled
Pass open and the popover stops opening and closing by itself. The trigger,
Escape and an outside click all call onChange with the state the reader asked
for, and the popover moves only when open does — so an app can close it when a
save completes or a route changes, or open it from somewhere else entirely.
const [open, setOpen] = useState(false);
<Popover open={open} onChange={setOpen} content={<RenameForm onSaved={() => setOpen(false)} />}>
<button>Rename</button>
</Popover>
defaultOpen starts an uncontrolled popover open. The vanilla controller has no
controlled mode — it is driven by open() and close() — but takes defaultOpen
and reports every change through the same onChange.
Trapping focus
A popover holding a form is a dialog in all but name. trapFocus makes it one,
with the same code Modal and Drawer run:
- focus moves into the content — an
autoFocusordata-autofocusfield, else the first control that is not ✕ - Tab stays inside, and the page behind becomes inert
- Escape answers only the top layer, so a popover inside a Modal closes on its own and leaves the Modal open
- closing, by any route, hands focus back to the trigger
- the panel says
aria-modal="true"
<Popover trapFocus title="Rename" content={<input aria-label="Name" data-autofocus />}>
<button>Rename</button>
</Popover>
Without trapFocus none of that happens: it stays a non-modal panel, focus stays
where it was, and the page stays live — right for a hint or a short read-only
note, where a trap would be in the way.