Popup
A floating panel tied to the button that opened it: a short form, a picker, a filter. It closes on Escape or a click outside, and the page stays live around it.
Playground
Press Reject, then change the controls. The page stays live around the popup, so it stays open while you change them. The code under the preview lists only the props you changed.
import { PopoverContent } from '@/ui/popover';<PopoverContent />Default
A short form whose answer belongs to the button: 12px padding, a title and one line, then the content. Focus moves to the first option when it opens. The trigger shows the current fill while it is open.
import { useState } from 'react';
import { Button } from '@/ui/button';
import { Checkbox } from '@/ui/checkbox';
import { Popover, PopoverClose, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger } from '@/ui/popover';
const reasons = ['Salary too high', 'Notice period too long', 'Missing a must-have skill'];
// A short form whose answer belongs to the button. Focus moves to the first option; Escape or a click outside closes
// without saving.
export default function PopupDefault() {
const [picked, setPicked] = useState<string[]>([]);
return (
<Popover onOpenChange={(open) => !open && setPicked([])}>
<PopoverTrigger render={<Button />}>Reject</PopoverTrigger>
<PopoverContent>
<PopoverHeader>
<PopoverTitle>Reject Maya Chen</PopoverTitle>
<PopoverDescription>Flo learns from the reason. Pick any that fit.</PopoverDescription>
</PopoverHeader>
<div className="flex flex-col gap-100">
{reasons.map((reason) => (
<Checkbox
key={reason}
label={reason}
checked={picked.includes(reason)}
onCheckedChange={(on) => setPicked((prev) => (on ? [...prev, reason] : prev.filter((r) => r !== reason)))}
/>
))}
</div>
<div className="flex justify-end gap-100">
<PopoverClose render={<Button />}>Cancel</PopoverClose>
<PopoverClose render={<Button variant="danger" disabled={picked.length === 0} />}>Reject</PopoverClose>
</div>
</PopoverContent>
</Popover>
);
}List
The list variant has 4px padding so its 28px rows line up with every menu and picker, with 12px group labels. Its width follows the longest row. It sits 4px from the trigger, aligned to the trigger's end here, and flips above when there is no room below.
import { useState } from 'react';
import { Button } from '@/ui/button';
import { Checkbox } from '@/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@/ui/popover';
const groups = [
{ label: 'Yours', tags: [{ name: 'Candidates', count: 12, dot: 'bg-information' }, { name: 'Clients', count: 8, dot: 'bg-discovery' }] },
{ label: 'Agency', tags: [{ name: 'Urgent', count: 3, dot: 'bg-danger' }] },
];
// The list variant: 4px padding, so 28px rows line up with every menu and picker. Aligned to the trigger's end.
export default function PopupList() {
const [ticked, setTicked] = useState(['Candidates']);
const toggle = (name: string, on: boolean) => setTicked((prev) => (on ? [...prev, name] : prev.filter((t) => t !== name)));
return (
<Popover>
<PopoverTrigger render={<Button />}>{ticked.length ? `Filter · ${ticked.length}` : 'Filter'}</PopoverTrigger>
<PopoverContent variant="list" size="fit" align="end" aria-label="Filter by tag">
{groups.map((group) => (
<div key={group.label} role="group" aria-label={group.label} className="flex flex-col gap-025">
<div className="flex h-7 items-end px-100 pb-050 text-body-small leading-body-small font-medium text-text-subtlest">{group.label}</div>
{group.tags.map((tag) => (
<Checkbox
key={tag.name}
checked={ticked.includes(tag.name)}
onCheckedChange={(on) => toggle(tag.name, on)}
className="h-7 w-full min-w-60 rounded-medium px-100 hover:bg-background-hovered"
label={
<>
<span aria-hidden className={`size-075 rounded-full ${tag.dot}`} />
<span className="grow">{tag.name}</span>
<span className="text-body-small leading-body-small text-text-subtle">{tag.count}</span>
</>
}
/>
))}
</div>
))}
</PopoverContent>
</Popover>
);
}When things go wrong
A long list is capped to the window and scrolls inside the popup. An empty popup says what is missing and how to get it. A save that fails says what happened inside the popup and leaves it open with what was entered.
import { useState } from 'react';
import { Button } from '@/ui/button';
import { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger } from '@/ui/popover';
const tags = Array.from({ length: 30 }, (_, i) => `Shortlist ${i + 1} · Nguyễn Thị Minh Khai, 王小明, Zoë O’Brien-Smith`);
// A long list is capped to the window and scrolls inside the popup. No tags yet says so and what to do. A save that
// fails says what happened inside the popup, and the popup stays open.
export default function PopupUnhappy() {
const [failed, setFailed] = useState(false);
return (
<div className="flex flex-wrap gap-100">
<Popover>
<PopoverTrigger render={<Button />}>30 shortlists</PopoverTrigger>
<PopoverContent variant="list" size="lg">
{tags.map((tag) => (
<div key={tag} className="flex min-h-7 items-center rounded-medium px-100 py-050">
{tag}
</div>
))}
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger render={<Button />}>No tags</PopoverTrigger>
<PopoverContent size="sm">
<PopoverHeader>
<PopoverTitle>No tags yet</PopoverTitle>
<PopoverDescription>Tag a thread in the Inbox and it shows here.</PopoverDescription>
</PopoverHeader>
</PopoverContent>
</Popover>
<Popover onOpenChange={(open) => !open && setFailed(false)}>
<PopoverTrigger render={<Button />}>Rename tag</PopoverTrigger>
<PopoverContent size="sm">
<PopoverHeader>
<PopoverTitle>Rename “Urgent”</PopoverTitle>
</PopoverHeader>
<p role="alert" className="text-body-small leading-body-small text-danger empty:hidden">
{failed ? 'Couldn’t rename the tag. Check your connection and try again.' : null}
</p>
<div className="flex justify-end">
<Button variant="primary" onClick={() => setFailed(true)}>
Save
</Button>
</div>
</PopoverContent>
</Popover>
</div>
);
}Motion
The popup uses the Popup motion. Press Replay to close and open it again.
import { useState } from 'react';
import { Button } from '@/ui/button';
import { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger } from '@/ui/popover';
// Replay closes the popup, then opens it again, so the 120ms exit and the 160ms entry run back to back.
export default function PopupMotion() {
const [open, setOpen] = useState(false);
const replay = () => {
setOpen(false);
setTimeout(() => setOpen(true), 400);
};
return (
<div className="flex items-start gap-150">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger render={<Button />}>Reject</PopoverTrigger>
<PopoverContent>
<PopoverHeader>
<PopoverTitle>Reject Maya Chen</PopoverTitle>
<PopoverDescription>Flo learns from the reason.</PopoverDescription>
</PopoverHeader>
</PopoverContent>
</Popover>
<Button variant="ghost" onClick={replay}>
Replay
</Button>
</div>
);
}Modal dialog
A short, focused task over the page: create, edit, or one step that needs an answer. The page waits under a 10% black blanket until you finish or cancel.
Record peek
A record opened beside the list it came from: an application, a target pursuit, a meeting. The list stays live; the address carries the record, so the peek can be shared and Back closes it.