Popover

A small non-modal panel anchored to a trigger for content that needs a moment of attention without leaving the page — a date picker, a color picker, a short form, help text with a link. Tooltip's interactive sibling.

Basic usage

Install the package:

pnpm add @design-schema/react @design-schema/tokens

Import the stylesheet once for the app, then the component:

import '@design-schema/tokens/calm-precise/css';
import '@design-schema/react/index.css';
import { Popover } from '@design-schema/react';

Examples

import { useState, type ReactElement } from 'react';
import { Popover, type PopoverCloseReason, type PopoverProps, Button, Form, Stack, Switch } from '@design-schema/react';

const filterForm = (
  <Form label="Filters" actions={<Button label="Apply" type="submit" size="sm" />}>
    <Stack gap="normal">
      <Switch label="Only open items" name="openOnly" />
      <Switch label="Assigned to me" name="mine" />
    </Stack>
  </Form>
);

function OpenHarness(props: PopoverProps): ReactElement {
  const [open, setOpen] = useState(props.open ?? true);
  const handleOpenChange = (next: boolean, reason: PopoverCloseReason): void => {
    setOpen(next);
    props.onOpenChange?.(next, reason);
  };
  return <Popover {...props} open={open} onOpenChange={handleOpenChange} />;
}

<OpenHarness
  trigger={<Button label="Filters" variant="secondary" />}
  heading="Filters"
  placement="bottom-start"
  open
>{filterForm}</OpenHarness>

Props

10 rows
PropTypeDefaultDescriptionPlatforms
trigger requiredcontent—

Exactly one focusable element — usually a Button — that opens the popover; typed as a single element, since it is cloned with aria-expanded/aria-controls (Button's `expanded` prop on native) and the toggle handler. On web and React Native it is typed `React.ReactElement`, not any ReactNode. A type cannot enforce the cardinality everywhere, so every platform warns in development when it is not exactly one element — a fragment, a bare string or an empty `trigger` slot on Lit — because such a trigger silently never opens the panel.

web, lit, rn, swiftui
children requiredcontent—

The panel content. May contain controls, links and a short Form; keep it to what fits without scrolling.

web, lit, rn, swiftui
headingstring—

Optional heading at the top of the panel, also the accessible name. Without it, the panel is named by the trigger (how each platform reads the trigger's name is in its notes).

web, lit, rn, swiftui
headingLevel2 | 3 | 43

Heading level of the panel heading, so it fits the page outline (a popover usually sits under a level-2 section). No effect in the React Native phone presentation, where BottomSheet's heading is always level 2. In the React Native tablet and react-native-web presentation the level is passed to Heading, which uses it only for typography (native has no heading levels). With no `heading` there is no Heading to receive it and the prop is simply unused: no warning, and the narrower 2–4 enum is passed through to Heading unchanged.

web, lit, rn, swiftui
openboolean—

Controlled open state. Omit for uncontrolled (the trigger toggles it); the uncontrolled popover starts closed and there is no defaultOpen.

web, lit, rn, swiftui
placementbottom-start | bottom | bottom-end | top-start | top | top-end | start | endbottom

Preferred side and alignment; flips and shifts to stay in the viewport. All eight values are logical: `start`/`end` and the `-start`/`-end` alignments mirror in right-to-left writing (the same rule as Tooltip and Menu).

web, lit, rn, swiftui
modalbooleanfalse

False (default): the page stays interactive; clicking outside closes; focus moves in but is not trapped, and Tab out closes. True: behaves as a small Dialog anchored to the trigger — focus trapped, background inert — for content that must be finished (a required form); Popover adds no Tab handler there, the wrap is FocusScope's, and with no tabbable child focus simply stays where it is. A modal popover locks page scroll the way Dialog does — a class on the root element with `overflow: hidden` and `scrollbar-gutter: stable`, so locking does not shift the page by the scrollbar width — but does not dim it: there is no scrim on any platform (the native <dialog> ::backdrop is transparent), and pressing outside does nothing.

web, lit, rn, swiftui
showArrowbooleanfalse

A small pointer toward the trigger. Off by default; Calm & precise prefers a plain edge.

web, lit, rn, swiftui
dismissiblebooleantrue

Show the close button. Escape and outside click work regardless (non-modal), so this is a visibility switch, not Dialog's "must be answered" rule: with it false there is simply no close button.

web, lit, rn, swiftui
initialFocusfirst | nonefirst

Where focus goes on open, after Dialog's prop of the same name. `first` (default) is the first control, in the order Behavior gives. `none` moves no focus: a composing component that opens the popover on content it owns focuses its own element once the panel is shown (DatePicker focuses the selected day, or today), and until it does focus stays on the trigger — with `modal` that is outside the trap, so a composer that passes `none` must move focus in. Everything else is unchanged: Tab and Shift+Tab out, Escape and focus restore behave as with `first`. No effect in the React Native phone presentation, where the BottomSheet always takes focus.

Accessibility: Focus must land inside an open popover; with `none` the composer, not the popover, owns that move.

web, lit, rn, swiftui

Events

1 row
EventTypeDescriptionPlatforms
onOpenChangeonOpenChange / open-change

Fired when the popover opens or closes, with the new state and a reason: `trigger`, `escape`, `outside`, `close-button`, `tab-out`. The reason type keeps its existing exported name, `PopoverCloseReason`, although the union includes `trigger`, which also opens; it is not renamed to `PopoverOpenChangeReason`.

web, lit, rn, swiftui

Accessibility

Decisions this component leaves to you

Supply an accessible name. This component has no text of its own to fall back on, so without one a screen reader announces its role and nothing else.

Implicit ARIA role: dialog.

Anatomy

triggerpanelfocusScopeheadingbodycloseButtonarrow

What generation guarantees

Accessible name — Every instance carries a programmatic name. Where the component has no text of its own to use, the name is a prop the consumer supplies.

Expanded state — Open and closed are exposed programmatically (`aria-expanded`), not only visually.

Escape dismisses — Escape closes it from anywhere inside.

Focus restore — On close, focus returns to whatever opened it.

Keyboard operable — Everything the pointer can do here, the keyboard can do — see the keyboard section.

Visible focus — Keyboard focus is always drawn, from the theme’s focus tokens, and is never removed.

Contrast — Text and meaningful non-text contrast meets WCAG AA; the pairs are listed below.

Reduced motion — Under `prefers-reduced-motion: reduce` the transitions are dropped rather than shortened, and the component still reaches every state.

Target size — Interactive targets are at least 24×24 CSS pixels (WCAG 2.2 Target Size, minimum).

Popover keyboard support

4 rows
KeyAction
Enter or SpaceToggles the popover from the trigger. (focus on trigger)
EscapeCloses and returns focus to the trigger. (open)
TabNon-modal: after the last element in the panel, closes and moves focus to the element after the trigger. Modal: wraps within the panel. (open)
Shift+TabNon-modal: from the first element in the panel, returns focus to the trigger and closes (reason `tab-out`). (open)

Contrast pairs

3 rows
ForegroundBackgroundLevel
color.foregroundcolor.overlay.surfaceAA
color.foreground.mutedcolor.overlay.surfaceAA
color.linkcolor.overlay.surfaceAA

Under `prefers-reduced-motion: reduce` the transitions are dropped rather than shortened, and the component still reaches every state.