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-monoreponpx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13Then 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.13Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/popoverIts 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.13import { 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
Tooltipinstead for a short label on hover or focus,HoverCardfor a preview of a link, andDialogwhen 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" popoverstays beside the trigger at every width.autois a popover from thesmbreakpoint (640px) up and a bottom sheet below it, for a popover with controls that would be fiddly on a phone.sheetis 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 ownonClickcan toggleopen. onOpenChange(required)- Type:
(open: boolean) => void - Called with
falsewhen 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 withopen. Removeopen,onOpenChangeandanchorto 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 withtrigger. The popover sits beside its trigger.onOpenChange: Not withtrigger. Removetriggerto control the popover withopen,onOpenChangeandanchor.open: Not withtrigger. Removetriggerto control the popover withopen,onOpenChangeandanchor.
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 usesopenwithonOpenChange:<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 intosideandalign:<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
- layout-classes: test:registry/ouro/components/ouro/popover/popover.test.tsx::Popover > is 18rem wide by default, and className sets layout
verify:testExists(registry/ouro/components/ouro/popover/popover.test.tsx::Popover > is 18rem wide by default, and className sets layout)
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 norenderit is the screen-reader close buttonPopoveruses: 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
PopoverTitlefirst: it names the popover. PopoverPortal- What it is: Moves the popover to the end of
<body>, in its own stacking context at the--ouro-layerz-index (50 by default, from ouro-base.css), like Dialog and Sheet. Raise it in a:rootrule 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
Popovercomponent instead unless its props cannot express your layout;Popoveralso becomes a bottom sheet on phones withpresentation="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.