Overlays

Popover

A small non-modal panel beside its trigger, for a short form or a few choices: a share link, filters, a quick edit. It is named by its title, closes on Escape and outside presses, returns focus to the trigger, and flips to the other side when there is no room. With presentation="auto" it is a bottom sheet on phones (below 640px).

In Spoonjoy

Spoonjoy, a recipe app, is built with Ouro HX (how).

In its source: app/root.tsx

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/popover#v1.2.13

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/popover

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/popover#v1.2.13 ourostack/ouro-ui/popover-tests#v1.2.13
import { Popover } from "@/components/ouro/popover/popover";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

<Popover title="Share recipe" trigger={<Button>Share</Button>}>
  <TextField label="Link" defaultValue={url} readOnly />
</Popover>
<Popover title="Filters" presentation="auto" open={open} onOpenChange={setOpen} anchor={filtersRef}>
  <FilterForm />
</Popover>

Usage

Use trigger for a popover a button opens, or open with onOpenChange and anchor to control it. To give the trigger a tooltip too, put the Tooltip inside trigger: trigger={<Tooltip content="Share recipe"><Button aria-label="Share">…</Button></Tooltip>}.

It stacks above the page at --ouro-layer, a z-index of 50 by default (from ouro-base.css), like Dialog and Sheet. Raise it in a :root rule of your own after the Ouro tokens, outside any @layer: :root { --ouro-layer: 60; }.

Use instead when

  • Use Tooltip instead for a short label on hover or focus, HoverCard for a preview of a link, and Dialog when the task needs the user's full attention.

Props

The props the modes share. Type: PopoverProps.

align
Type: "start" | "center" | "end"
Default: "center"
How the popover lines up with the trigger along that side.
children (required)
Type: ReactNode
The body. It scrolls when the popover is taller than the space beside the trigger.
className
Type: string
Layout only, applied to the popover panel: width (18rem, w-72, by default) and margins. Your classes win over Ouro UI's. The sheet keeps its full width. Appearance classes warn in development.
closeLabel
Type: string
Default: "Close"
The label of the close button, the last control in the popover. It is hidden until it has keyboard focus, so screen-reader users on phones (who have no Escape key) can always close the popover.
description
Type: ReactNode
A short line under the title, read out when the popover opens.
hideTitle
Type: boolean
Hides the title visually. It still names the popover for screen readers.
presentation
Type: "popover" | "auto" | "sheet"
Default: "popover"
popover stays beside the trigger at every width. auto is a popover from the sm breakpoint (640px) up and a bottom sheet below it, for a popover with controls that would be fiddly on a phone. sheet is always a bottom sheet. The server renders the popover form and corrects it on hydration.
side
Type: "top" | "bottom" | "left" | "right"
Default: "bottom"
The side of the trigger the popover sits on; it flips when there is no room.
title (required)
Type: string
Names the popover for screen readers and shows at its top. Always required; hide it with hideTitle.

Modes

PopoverProps is a union: pass the props of exactly one mode. Mixing them is a type error.

Controlled: PopoverControlledProps

A controlled Popover: you own open, update it in onOpenChange, and say what it points at with anchor.

anchor (required)
Type: RefObject<HTMLElement | null>
The element the popover points at, usually the button that toggles it: anchor={buttonRef}. A press on it never counts as an outside press, so its own onClick can toggle open.
onOpenChange (required)
Type: (open: boolean) => void
Called with false when the user dismisses the popover (Escape, outside press, the close button).
open (required)
Type: boolean
Whether the popover is open. After a touch or pen tap the popover focuses itself rather than its first input, so no software keyboard pops up; after a key press it focuses the first control.

Not in this mode (typed never):

  • trigger: Not with open. Remove open, onOpenChange and anchor to let a trigger open the popover.

With trigger: PopoverWithTriggerProps

An uncontrolled Popover: trigger opens and closes it, and it sits beside the trigger.

trigger (required)
Type: ReactElement
The element that opens and closes the popover, usually a Button: trigger={<Button>Share</Button>}.

Not in this mode (typed never):

  • anchor: Not with trigger. The popover sits beside its trigger.
  • onOpenChange: Not with trigger. Remove trigger to control the popover with open, onOpenChange and anchor.
  • open: Not with trigger. Remove trigger to control the popover with open, onOpenChange and anchor.

Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

  • content: Not a prop. The body is the children: <Popover title="…" trigger={…}>…</Popover>.
  • isOpen: Not a prop. Ouro UI uses open with onOpenChange: <Popover open={open} onOpenChange={setOpen} anchor={buttonRef}>.
  • onClose: Not a prop. onOpenChange(false) fires on close: <Popover open={open} onOpenChange={setOpen} anchor={buttonRef}>.
  • placement: Not a prop. Ouro UI splits placement into side and align: <Popover side="bottom" align="start">.

className is for layout only

Pass margins, width and grid placement (mt-4, w-full, col-span-2). Your classes always win over Ouro UI's: Popover merges className last with cn (tailwind-merge), so a class that conflicts with its own replaces it.layout-classes

Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.

Alignment classes (text-center) warn too: use the align prop.

Parts

When these props cannot express your layout, build it from the parts in @/components/ouro/popover/popover-parts. The nesting they need follows.

Claim sources

import {
  PopoverClose,
  PopoverDescription,
  PopoverPopup,
  PopoverPortal,
  PopoverPositioner,
  PopoverRoot,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ouro/popover/popover-parts";

Required nesting

<PopoverRoot>
  <PopoverTrigger render={<Button>Share</Button>} />
  <PopoverPortal>
    <PopoverPositioner side="bottom" align="start">
      <PopoverPopup>
        <PopoverTitle>Share recipe</PopoverTitle>
        …
      </PopoverPopup>
    </PopoverPositioner>
  </PopoverPortal>
</PopoverRoot>

Each part checks that it sits inside <PopoverRoot>: outside it, a part throws in development with a message that shows this JSX shape.popover-nesting

PopoverClose
What it is: Closes the popover. Pass your button as render={<Button>Done</Button>}. With no render it is the screen-reader close button Popover uses: hidden until it has keyboard focus, so phone screen-reader users, who have no Escape key, can close it.
PopoverDescription
What it is: A line under the title that describes the popover.
PopoverPopup
What it is: The popover panel, 18rem wide (never wider than the screen allows) with 1rem of padding. It grows from the trigger over 200ms on the ease-out token and fades; reduced motion keeps only the fade. Put a PopoverTitle first: it names the popover.
PopoverPortal
What it is: Moves the popover to the end of <body>, in its own stacking context at the --ouro-layer z-index (50 by default, from ouro-base.css), like Dialog and Sheet. Raise it in a :root rule of your own after the Ouro tokens: :root { --ouro-layer: 60; }.
PopoverPositioner
What it is: Places the popover beside its trigger (or anchor), 8px away and at least 8px from the screen edges, and flips it to the other side when there is no room.
PopoverRoot
What it is: The state owner of a popover built from parts. Use the Popover component instead unless its props cannot express your layout; Popover also becomes a bottom sheet on phones with presentation="auto", which parts do not.
PopoverTitle
What it is: The popover's title, which names it. Every popover needs one.
PopoverTrigger
What it is: The button that opens and closes the popover. Pass your button as render={<Button>Open</Button>}. The trigger anchors the popover, which opens on click while the button is pressed, so the press scale is cancelled on it (Button's pressed wash still shows) and the popover lands once.