Browse docs
Browse docs
A clickable floating panel that attaches to a trigger element. Focus traps inside while open; dismisses on outside-click or Escape. Use for action menus, color pickers, settings panels — anything richer than a hover tooltip.
import { Popover, Button } from '@dashforge/tw';
<Popover content={<QuickActions />}>
<Button>Open</Button>
</Popover>Three floating primitives, three different jobs. Pick the right one:
| If you need... | Use | Why |
|---|---|---|
| A read-only hint on hover / focus | <Tooltip> | role="tooltip" + aria-describedby; not focusable |
| A clickable panel with interactive content (forms, buttons, pickers) | <Popover> | Focus trap; supports any React tree inside |
| A list of actions / commands from a trigger button | <Menu> | Full WAI-ARIA menu pattern; arrow-key nav, type-ahead |
Rough mental model: Tooltip < Popover < Menu in terms of interaction richness.
import { Popover, Button, Stack, Typography } from '@dashforge/tw';
<Popover
content={
<Stack gap={2}>
<Typography variant="subtitle2">Quick actions</Typography>
<Stack gap={1}>
<Button variant="ghost" size="sm">Duplicate</Button>
<Button variant="ghost" size="sm">Share</Button>
<Button variant="ghost" size="sm" color="danger">Delete</Button>
</Stack>
</Stack>
}
>
<Button variant="outline">Open popover</Button>
</Popover>The single child of <Popover> is the trigger — Radix's asChild pattern paints the popover semantics onto it. Wrap any interactive element (<Button>, <IconButton>, <Chip>, or a plain <button>).
import { Popover, Button, Stack, Typography } from '@dashforge/tw';
<Popover
content={
<Stack gap={2}>
<Typography variant="subtitle2">Quick actions</Typography>
<Stack gap={1}>
<Button variant="ghost" size="sm">Duplicate</Button>
<Button variant="ghost" size="sm">Share</Button>
<Button variant="ghost" size="sm" color="danger">Delete</Button>
</Stack>
</Stack>
}
>
<Button variant="outline">Open popover</Button>
</Popover>side selects which edge of the trigger the popover attaches to. align further tunes the perpendicular axis ('start' | 'center' | 'end'). showArrow renders a small pointer for extra clarity.
import { Popover, Button } from '@dashforge/tw';
<Popover side="top" showArrow content="Popover on top"> <Button>top</Button></Popover>
<Popover side="right" showArrow content="Popover on right"> <Button>right</Button></Popover>
<Popover side="bottom" showArrow content="Popover on bottom"> <Button>bottom</Button></Popover>
<Popover side="left" showArrow content="Popover on left"> <Button>left</Button></Popover><Popover side="top" showArrow content="Popover on top"> <Button>top</Button></Popover>
<Popover side="right" showArrow content="Popover on right"> <Button>right</Button></Popover>
<Popover side="bottom" showArrow content="Popover on bottom"> <Button>bottom</Button></Popover>
<Popover side="left" showArrow content="Popover on left"> <Button>left</Button></Popover>Radix auto-flips the side if the requested edge would clip against the viewport — so side="bottom" near the page bottom silently becomes side="top". The align prop follows the same "auto-nudge to fit" behavior.
Omit open/onOpenChange for the default click-to-toggle behavior. Provide them when the popover should be driven externally (a keyboard shortcut, a state machine transition, a route change):
const [open, setOpen] = useState(false);
<Popover open={open} onOpenChange={setOpen} content={<Panel />}>
<Button>Trigger</Button>
</Popover>
// Later, from a keyboard shortcut handler:
useHotkey('cmd+k', () => setOpen((o) => !o));Pass width as any CSS length to constrain the content — useful for form-shaped popovers where you want the panel to keep its shape regardless of body content:
<Popover
width="320px"
content={
<Stack gap={2}>
<Typography variant="subtitle2">Filter</Typography>
<TextField name="query" label="Search" />
</Stack>
}
>
<Button>Filter</Button>
</Popover>Because the popover captures focus, you can put anything interactive inside — including form fields:
<Popover
content={
<Stack gap={3} sx="min-w-[280px]">
<Typography variant="subtitle2">Add label</Typography>
<TextField name="label" placeholder="e.g. urgent" required />
<Stack direction="row" gap={2} justify="end">
<Button variant="outline" size="sm">Cancel</Button>
<Button color="primary" size="sm">Add</Button>
</Stack>
</Stack>
}
>
<Button variant="outline">+ Label</Button>
</Popover>Tab moves focus across the fields inside the popover; Escape dismisses; outside-click dismisses without submitting.
Configure <Popover> defaults application-wide.
import { patchTheme } from '@dashforge/tw-theme';
patchTheme({
components: {
Popover: {
defaults: { side: 'bottom', align: 'center', sideOffset: 8, showArrow: false },
},
},
});Configurable axes (PopoverVariantProps):
| Axis | Type | Notes |
|---|---|---|
side | 'top' | 'right' | 'bottom' | 'left' | Preferred side. Auto-flips against the viewport. |
align | 'start' | 'center' | 'end' | Perpendicular alignment. |
showArrow | boolean | Render the arrow indicator. |
sideOffset | number | Pixel gap between trigger and content. |
Non-visual axes (children, content, open, onOpenChange, width, slotProps) are per-instance — they carry the popover's identity or per-usage layout.
Precedence chain (lowest → highest):
side='bottom', align='center', showArrow=false, sideOffset=8).theme.components.Popover.defaults (application-wide).<Popover side="top" showArrow />) — wins over theme.slotProps.content — reaches the floating panel for a targeted class override.TypeScript: theme.components.Popover.defaults autocompletes to the four axes above.
Reactivity: useComponentDefaults('Popover') subscribes to the theme store — patchTheme re-renders every mounted instance inheriting the changed axis.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The trigger element (wrapped in Radix's asChild slot). |
content | ReactNode | — | Popover body content. |
align | 'start' | 'center' | 'end' | 'center' | Alignment along the perpendicular axis. |
onOpenChange | (open: boolean) => void | — | Called when open state changes. |
open | boolean | — | Controlled open state. |
showArrow | boolean | false | Show an arrow indicator. |
side | 'top' | 'right' | 'bottom' | 'left' | 'bottom' | Side of the trigger to render on. |
sideOffset | number | 8 | Side offset in pixels. |
slotProps | PopoverSlotProps | — | Per-slot overrides. |
sx | string | — | Shortcut for slotProps.content.className — Tailwind utility classes appended to the floating panel via tailwind-merge (later wins on conflicts). Same ergonomic pattern as <Dialog sx="...">.Use this for one-off overrides ( sx="w-72 shadow-2xl"); reach for slotProps when you also need to target the arrow or want to attach non-class props. |
width | string | — | Width of the popover content. CSS value. |
<Popover> is a compound with 2 named slots. Each accepts a { className?: string } override via slotProps:
| Slot | Purpose |
|---|---|
content | The floating panel — bg + border + shadow + entrance animation. |
arrow | The optional pointer (rendered when showArrow is true). |
<Popover
showArrow
content={<Panel />}
slotProps={{
content: { className: 'w-72 shadow-2xl' },
arrow: { className: 'fill-neutral-900' },
}}
>
<Button>Open</Button>
</Popover>document.body. Parent overflow: hidden and transform won't clip it.content. On close, focus returns to the trigger. Tab is trapped inside while open (matches dialog semantics).role="dialog": Radix marks the panel with role="dialog" semantically because it's an interactive floating region. Screen readers announce it as a nested dialog when opened, which is the right behavior for "click a trigger, work inside a small surface, then close".<Dialog>: if the panel gets big enough that centering it on the viewport is more natural than attaching it to a specific trigger, that's a modal — use <Dialog> instead.<Menu>: if the panel is a list of commands (label + optional icon + optional shortcut, arrow-key nav), <Menu> gives you the full WAI-ARIA menu pattern for free. Popovers are for freeform content, not command lists.