Primitives
Popover
A small panel that opens next to its trigger and holds a form or a picker.
Primitives
A small panel that opens next to its trigger and holds a form or a picker.
import {
Button,
CopyField,
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from 'ferry-ui'
import { Share2 } from 'lucide-react'
export default function PopoverHero() {
return (
<Popover>
<PopoverTrigger asChild>
<Button icon={<Share2 />}>Share</Button>
</PopoverTrigger>
<PopoverContent aria-label="Share this report" className="flex flex-col gap-3">
<PopoverHeader>
<PopoverTitle>Share this report</PopoverTitle>
<PopoverDescription>Each person with the link can view it.</PopoverDescription>
</PopoverHeader>
<CopyField value="https://example.com/s/8f2k" what="share link" size="sm" aria-label="Share link" />
</PopoverContent>
</Popover>
)
}Import the parts. Put them together in this order.
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from 'ferry-ui'
<Popover>
<PopoverAnchor />
<PopoverTrigger />
<PopoverContent>
<PopoverHeader>
<PopoverTitle />
<PopoverDescription />
</PopoverHeader>
</PopoverContent>
</Popover>| Part | Role |
|---|---|
Popover | Holds the open state. |
PopoverTrigger | Opens and closes the popover on a click. |
PopoverContent | The panel. |
PopoverHeader | Holds the title and the description. |
PopoverTitle | The visible title. |
PopoverDescription | A short text below the title. |
PopoverAnchor | An element that sets the position of the panel, in place of the trigger. |
With asChild, PopoverTrigger gives its behavior to its child element. Use it with a Button.
PopoverContent opens below the trigger by default. The side prop sets a different side. If the side has no room, the panel changes side.
The sideOffset prop sets the gap between the trigger and the panel. The default is 6px.
import { Button, Popover, PopoverContent, PopoverTrigger } from 'ferry-ui'
const SIDES = ['top', 'right', 'bottom', 'left'] as const
export default function PopoverSide() {
return (
<>
{SIDES.map((side) => (
<Popover key={side}>
<PopoverTrigger asChild>
<Button>{side}</Button>
</PopoverTrigger>
<PopoverContent side={side} aria-label={`Side ${side}`} className="w-48 text-[13px] text-foreground-light">
The panel opens on the {side} side of the trigger.
</PopoverContent>
</Popover>
))}
</>
)
}The align prop aligns the panel to the start, the center or the end of the trigger. The default is center.
import { Button, Popover, PopoverContent, PopoverTrigger } from 'ferry-ui'
const ALIGNMENTS = ['start', 'center', 'end'] as const
export default function PopoverAlign() {
return (
<>
{ALIGNMENTS.map((align) => (
<Popover key={align}>
<PopoverTrigger asChild>
<Button>{align}</Button>
</PopoverTrigger>
<PopoverContent align={align} aria-label={`Alignment ${align}`} className="w-56 text-[13px] text-foreground-light">
The panel aligns to the {align} of the trigger.
</PopoverContent>
</Popover>
))}
</>
)
}A popover holds its open state by default. To control the state, pass open and onOpenChange. Use this to close the popover after a submit.
import * as React from 'react'
import { Button, Field, Input, Popover, PopoverContent, PopoverTrigger } from 'ferry-ui'
export default function PopoverControlled() {
const [open, setOpen] = React.useState(false)
const [name, setName] = React.useState('Billing portal')
const [draft, setDraft] = React.useState(name)
function save(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setName(draft.trim() || name)
// The form is done: close the popover from the code.
setOpen(false)
}
return (
<>
<span className="text-sm font-medium text-foreground">{name}</span>
<Popover
open={open}
onOpenChange={(next) => {
// Start each edit from the current name.
if (next) setDraft(name)
setOpen(next)
}}
>
<PopoverTrigger asChild>
<Button variant="ghost" size="tiny">
Rename
</Button>
</PopoverTrigger>
<PopoverContent align="start" aria-label="Rename project">
<form className="flex flex-col gap-3" onSubmit={save}>
<Field label="Project name" size="sm" hint="The URL of the project does not change.">
<Input size="sm" value={draft} onChange={(event) => setDraft(event.target.value)} />
</Field>
<div className="flex justify-end gap-2">
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" variant="primary">
Save
</Button>
</div>
</form>
</PopoverContent>
</Popover>
</>
)
}The panel is 288px wide and has 16px of padding. Pass classes such as w-80 p-0 to PopoverContent to change them.
import { Button, Popover, PopoverContent, PopoverTrigger } from 'ferry-ui'
import { Bell } from 'lucide-react'
const NOTIFICATIONS = [
{ id: 1, text: 'Acme paid the invoice INV-2041.', when: '2 min ago' },
{ id: 2, text: 'Sam Lee joined the workspace.', when: '1 hour ago' },
{ id: 3, text: 'The API key “Analytics export” expires in 3 days.', when: 'Yesterday' },
]
export default function PopoverCustomSize() {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" icon={<Bell />} aria-label="Notifications" />
</PopoverTrigger>
{/* `w-80 p-0`: a wider panel with no padding, for a list that touches the edges. */}
<PopoverContent align="end" className="w-80 p-0" aria-label="Notifications">
<ul className="divide-y">
{NOTIFICATIONS.map((notification) => (
<li key={notification.id} className="flex flex-col gap-0.5 px-4 py-2.5">
<span className="text-[13px] text-foreground">{notification.text}</span>
<span className="text-xs text-foreground-lighter">{notification.when}</span>
</li>
))}
</ul>
<div className="border-t p-2">
<Button variant="ghost" className="w-full">
Mark all as read
</Button>
</div>
</PopoverContent>
</Popover>
)
}By default the panel opens next to the trigger. Wrap a different element in PopoverAnchor to open the panel next to that element.
import * as React from 'react'
import { Button, Input, Popover, PopoverAnchor, PopoverContent, PopoverTitle, PopoverTrigger } from 'ferry-ui'
import { CalendarDays } from 'lucide-react'
const DATES = [
{ label: 'End of the month', value: '2026-03-31' },
{ label: 'End of the quarter', value: '2026-06-30' },
{ label: 'End of the year', value: '2026-12-31' },
]
export default function PopoverWithAnchor() {
const [open, setOpen] = React.useState(false)
const [date, setDate] = React.useState('2026-03-31')
return (
<Popover open={open} onOpenChange={setOpen}>
{/* The panel aligns to the field and the button together, not to the button only. */}
<PopoverAnchor asChild>
<div className="flex w-64 items-center gap-2">
<Input aria-label="Due date" size="sm" mono value={date} onChange={(event) => setDate(event.target.value)} />
<PopoverTrigger asChild>
<Button size="icon" icon={<CalendarDays />} aria-label="Pick a due date" />
</PopoverTrigger>
</div>
</PopoverAnchor>
<PopoverContent align="start" aria-label="Due date" className="flex w-64 flex-col gap-2">
<PopoverTitle>Due date</PopoverTitle>
<div className="flex flex-col gap-1">
{DATES.map((option) => (
<Button
key={option.value}
variant="ghost"
className="justify-between"
onClick={() => {
setDate(option.value)
setOpen(false)
}}
>
{option.label}
<span className="font-mono text-xs text-foreground-lighter">{option.value}</span>
</Button>
))}
</div>
</PopoverContent>
</Popover>
)
}PopoverTitle is a plain <div>. It is not the accessible name of the panel.PopoverContent an aria-label when the panel needs an accessible name.Each part also accepts the props of its Radix UI primitive and the attributes of its element.
| Prop | Type | Default |
|---|---|---|
children | ReactNode | - |
| See the element or the Radix UI primitive this part renders. | ||
open | boolean | - |
| See the element or the Radix UI primitive this part renders. | ||
defaultOpen | boolean | - |
| See the element or the Radix UI primitive this part renders. | ||
onOpenChange | ((open: boolean) => void) | - |
| See the element or the Radix UI primitive this part renders. | ||
modal | boolean | - |
| See the element or the Radix UI primitive this part renders. | ||
| Prop | Type | Default |
|---|---|---|
asChild | boolean | - |
| See the element or the Radix UI primitive this part renders. | ||
In ferry-ui, the default align is center and the default sideOffset is 6.
| Prop | Type | Default |
|---|---|---|
forceMount | true | - |
Used to force mounting when more control is needed. Useful when controlling animation with React animation libraries. | ||
asChild | boolean | - |
| See the element or the Radix UI primitive this part renders. | ||
deferPointerDownOutside | boolean | - |
When | ||
onEscapeKeyDown | ((event: KeyboardEvent) => void) | - |
Event handler called when the escape key is down. Can be prevented. | ||
onPointerDownOutside | ((event: PointerDownOutsideEvent) => void) | - |
Event handler called when the a | ||
onFocusOutside | ((event: FocusOutsideEvent) => void) | - |
Event handler called when the focus moves outside of the | ||
onInteractOutside | ((event: FocusOutsideEvent | PointerDownOutsideEvent) => void) | - |
Event handler called when an interaction happens outside the | ||
onOpenAutoFocus | ((event: Event) => void) | - |
Event handler called when auto-focusing on open. Can be prevented. | ||
onCloseAutoFocus | ((event: Event) => void) | - |
Event handler called when auto-focusing on close. Can be prevented. | ||
side | "top" | "right" | "bottom" | "left" | - |
| See the element or the Radix UI primitive this part renders. | ||
sideOffset | number | 6 |
| See the element or the Radix UI primitive this part renders. | ||
align | "center" | "start" | "end" | center |
| See the element or the Radix UI primitive this part renders. | ||
alignOffset | number | - |
| See the element or the Radix UI primitive this part renders. | ||
arrowPadding | number | - |
| See the element or the Radix UI primitive this part renders. | ||
avoidCollisions | boolean | - |
| See the element or the Radix UI primitive this part renders. | ||
collisionBoundary | Boundary | Boundary[] | - |
| See the element or the Radix UI primitive this part renders. | ||
collisionPadding | number | Partial<Record<"top" | "right" | "bottom" | "left", number>> | - |
| See the element or the Radix UI primitive this part renders. | ||
sticky | "partial" | "always" | - |
| See the element or the Radix UI primitive this part renders. | ||
hideWhenDetached | boolean | - |
| See the element or the Radix UI primitive this part renders. | ||
updatePositionStrategy | "always" | "optimized" | - |
| See the element or the Radix UI primitive this part renders. | ||
PopoverHeader has no props of its own. It accepts the attributes of the element it renders.
PopoverTitle has no props of its own. It accepts the attributes of the element it renders.
PopoverDescription has no props of its own. It accepts the attributes of the element it renders.
| Prop | Type | Default |
|---|---|---|
asChild | boolean | - |
| See the element or the Radix UI primitive this part renders. | ||
virtualRef | RefObject<Measurable | null> | - |
| See the element or the Radix UI primitive this part renders. | ||