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">&times;</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

AttributeValues
data-visiblepresent or absent
data-placementtop|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

OptionTypeDefaultDescription
contentstring|HTMLElementrequiredPopover body content (required). A string is shown as text, never parsed as HTML; pass an element for markup.
titlestringundefinedOptional heading inside the popover
placement'top'|'bottom'|'left'|'right''bottom'Preferred placement
defaultOpenbooleanfalseOpen as soon as it is created.
onChange(open: boolean) => voidundefinedCalled whenever it opens or closes, with the new state — by the trigger, Escape, an outside click, or open()/close().
trapFocusbooleanfalseMake 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.
closeOnOutsideClickbooleantrueDismiss when clicking outside
onOpenFunctionundefinedCalled when popover opens
onCloseFunctionundefinedCalled when popover closes

React

Popover — from @corndev/ui-overlays-react.

Props

PropTypeDefaultDescription
childrenReact.ReactNoderequiredThe trigger element (must accept ref)
contentReact.ReactNoderequiredPopover body content
titlestringundefinedOptional popover heading
placement'top'|'bottom'|'left'|'right''bottom'Preferred placement
openbooleanundefinedWhether 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.
defaultOpenbooleanfalseOpen on first render (uncontrolled).
onChange(open: boolean) => voidundefinedCalled with the state the reader asked for.
trapFocusbooleanfalseMake 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.
closeOnOutsideClickbooleantrue
onOpenFunctionundefined
onCloseFunctionundefined

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 autoFocus or data-autofocus field, 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.