Popover
An anchored panel for a link, a field or an action. It flips and shrinks to the room it has instead of overflowing.
Popover
client - size not measured yetAn anchored panel for a link, a field or an action. It flips and shrinks to the room it has instead of overflowing.
Try it
The code
import { Button } from 'topclassui/button'
import { PopoverDescription, PopoverPopup, PopoverRoot, PopoverTitle, PopoverTrigger } from 'topclassui/popover'
<PopoverRoot>
<PopoverTrigger openOnHover render={<Button variant="outline">Share</Button>} />
<PopoverPopup>
<PopoverTitle>Share this page</PopoverTitle>
<PopoverDescription>Anyone with the link can read it. Only your team can edit.</PopoverDescription>
<Button size="sm" className="self-start">Copy link</Button>
</PopoverPopup>
</PopoverRoot>A tooltip is plain text only: its children are typed as text. Anything with a link or a control is a Popover.
Every state
5 statesImport from the package
import { PopoverRoot, PopoverClose, PopoverDescription, PopoverPopup, PopoverTitle, PopoverTrigger } from 'topclassui/popover'Install once:npm i topclassui@beta zod
Examples
- Six flat parts and no bare
Popover:PopoverRoot,PopoverTrigger,PopoverPopup,PopoverTitle,PopoverDescriptionandPopoverClose. placementonPopoverPopup: twelve values,bottomby default. It opens on the other side when its own has no room, and stays 8 px inside the screen.- The popup is 280 px wide and never taller than the room it has; past that it scrolls inside itself. For another width, pass a class.
- A
PopoverClosewith nothing in it is the x, and you place it. openOnHoveron the trigger lets the pointer open it too, afterdelay(300 ms) and untilcloseDelay(0 ms), both clamped to 0 to 10000.- Put a
forminsidePopoverPopup, neverrender={<form />}on it, and give it anactionor anonSubmit.
import { Button } from 'topclassui/button'
import { PopoverDescription, PopoverPopup, PopoverRoot, PopoverTitle, PopoverTrigger } from 'topclassui/popover'
/** The drawing: a title, a line of text and one action, under the trigger. The label sits
* inside the Button passed to render, so a server render keeps it whole. */
export default function Demo() {
return (
<PopoverRoot>
<PopoverTrigger render={<Button variant="outline">Share</Button>} />
<PopoverPopup>
<PopoverTitle>Share this page</PopoverTitle>
<PopoverDescription>Anyone with the link can read it. Only your team can edit.</PopoverDescription>
<Button size="sm" className="self-start">Copy link</Button>
</PopoverPopup>
</PopoverRoot>
)
}
import { Button } from 'topclassui/button'
import {
PopoverClose, PopoverDescription, PopoverPopup, PopoverRoot, PopoverTitle, PopoverTrigger,
} from 'topclassui/popover'
/** A PopoverClose with nothing in it is the x. The caller places it: here in a row with the
* title, which wraps beside it and never runs under it. A touch screen reader has no Escape
* key, so a popover that holds more than a line of text carries one. */
export default function Demo() {
return (
<PopoverRoot>
<PopoverTrigger render={<Button variant="outline">Notifications</Button>} />
<PopoverPopup>
<div className="flex items-start justify-between gap-2">
<PopoverTitle>Notifications are paused</PopoverTitle>
<PopoverClose />
</div>
<PopoverDescription>You will get one summary tomorrow at nine instead.</PopoverDescription>
</PopoverPopup>
</PopoverRoot>
)
}
import { Button } from 'topclassui/button'
import { PopoverDescription, PopoverPopup, PopoverRoot, PopoverTitle, PopoverTrigger } from 'topclassui/popover'
/** openOnHover: the pointer opens it after delay and it stays closeDelay after the pointer
* leaves, long enough to reach the link inside. A press and the keyboard still open it, and
* a tooltip could not hold this link: anything with a link or a control is a Popover. */
export default function Demo() {
return (
<PopoverRoot>
<PopoverTrigger openOnHover delay={100} closeDelay={200} render={<Button variant="ghost">Billing details</Button>} />
<PopoverPopup placement="bottom-start">
<PopoverTitle>How billing works</PopoverTitle>
<PopoverDescription>Only the seats in use are billed.</PopoverDescription>
<Button variant="link" size="sm" className="self-start" render={<a href="/docs/billing" />}>Read the guide</Button>
</PopoverPopup>
</PopoverRoot>
)
}
'use client'
import { useState, type SubmitEvent } from 'react'
import { Button } from 'topclassui/button'
import { Input } from 'topclassui/input'
import { PopoverClose, PopoverPopup, PopoverRoot, PopoverTitle, PopoverTrigger } from 'topclassui/popover'
/** A field in a popover. The form goes inside the popup, never on it: role="dialog" on a form
* fails axe. A form needs an action or an onSubmit, or the browser reloads the page: here
* onSubmit closes the popover. autoFocus puts the first focus in the field. */
export default function Demo() {
const [open, setOpen] = useState(false)
const save = (event: SubmitEvent<HTMLFormElement>) => {
event.preventDefault()
setOpen(false)
}
return (
<PopoverRoot open={open} onOpenChange={setOpen}>
<PopoverTrigger render={<Button variant="outline">Rename</Button>} />
<PopoverPopup>
<PopoverTitle>Rename this project</PopoverTitle>
<form className="grid gap-3" onSubmit={save}>
<div className="grid gap-1">
<label htmlFor="demo-popover-name" className="font-medium text-body">Name</label>
<Input id="demo-popover-name" name="name" defaultValue="Website redesign" required fullWidth autoFocus />
</div>
<div className="flex flex-wrap justify-end gap-2">
<PopoverClose render={<Button variant="outline" size="sm">Cancel</Button>} />
<Button type="submit" size="sm">Save</Button>
</div>
</form>
</PopoverPopup>
</PopoverRoot>
)
}
import { Button } from 'topclassui/button'
import {
PopoverClose, PopoverDescription, PopoverPopup, PopoverRoot, PopoverTitle, PopoverTrigger,
} from 'topclassui/popover'
/** modal: the page behind takes no press and does not scroll, and no scrim is drawn. Tab stays
* in the popup because a PopoverClose is inside it: without one Base UI does not hold the focus. */
export default function Demo() {
return (
<div className="flex flex-wrap items-center gap-3">
<PopoverRoot modal>
<PopoverTrigger render={<Button variant="outline">Delete draft</Button>} />
<PopoverPopup>
<div className="flex items-start justify-between gap-2">
<PopoverTitle>Delete this draft?</PopoverTitle>
<PopoverClose />
</div>
<PopoverDescription>The draft and its comments are removed. This cannot be undone.</PopoverDescription>
<div className="flex flex-wrap justify-end gap-2">
<PopoverClose render={<Button variant="outline" size="sm">Keep it</Button>} />
<PopoverClose render={<Button tone="danger" size="sm">Delete</Button>} />
</div>
</PopoverPopup>
</PopoverRoot>
<Button variant="ghost">Behind the panel</Button>
</div>
)
}
import { Button } from 'topclassui/button'
import { PopoverDescription, PopoverPopup, PopoverRoot, PopoverTitle, PopoverTrigger } from 'topclassui/popover'
import type { PopoverPlacement } from 'topclassui/popover'
const PLACES: PopoverPlacement[] = [
'top-start', 'top', 'top-end',
'start-top', 'start', 'start-bottom',
'end-top', 'end', 'end-bottom',
'bottom-start', 'bottom', 'bottom-end',
]
/** All twelve placements, one outline Button each. The label names the placement and the
* popup is titled by the same word. start and end are the inline sides, so they mirror. */
export default function Demo() {
return (
<div className="grid max-w-[31.5rem] grid-cols-[repeat(auto-fill,minmax(min(100%,8rem),1fr))] gap-3 p-4 sm:p-12">
{PLACES.map((place) => (
<PopoverRoot key={place}>
<PopoverTrigger render={<Button variant="outline" size="sm">{place}</Button>} />
<PopoverPopup placement={place}>
<PopoverTitle>{place}</PopoverTitle>
<PopoverDescription>It flips to the other side where this one has no room.</PopoverDescription>
</PopoverPopup>
</PopoverRoot>
))}
</div>
)
}
import { Button } from 'topclassui/button'
import { PopoverDescription, PopoverPopup, PopoverRoot, PopoverTitle, PopoverTrigger } from 'topclassui/popover'
const LINES = Array.from({ length: 60 }, (_, index) => index + 1)
/** Sixty lines, taller than any screen. The popup shrinks to the room beside its trigger and
* scrolls inside itself (R-10.5-1). While it scrolls it is a tab stop, so the keyboard reaches
* the last line of a text that holds no control. */
export default function Demo() {
return (
<PopoverRoot>
<PopoverTrigger render={<Button variant="outline">Change log</Button>} />
<PopoverPopup>
<PopoverTitle>Change log</PopoverTitle>
<PopoverDescription>Sixty changes. Scroll to read them all.</PopoverDescription>
<ol className="grid gap-1">
{LINES.map((line) => <li key={line}>Change {line}: a fix that shipped this week.</li>)}
</ol>
</PopoverPopup>
</PopoverRoot>
)
}
import { TopClassProvider } from 'topclassui/provider'
import { Button } from 'topclassui/button'
import {
PopoverClose, PopoverDescription, PopoverPopup, PopoverRoot, PopoverTitle, PopoverTrigger,
} from 'topclassui/popover'
/** Arabic, inside TopClassProvider as the root layout would mount it. The popup is portaled
* into the body, so it takes its direction and its language from the provider: start opens on
* the right, end on the left, bottom-start lines up the right edges, on an English page too. */
export default function Demo() {
return (
<TopClassProvider direction="rtl" locale="ar">
<div dir="rtl" lang="ar" className="flex flex-wrap items-center gap-3">
<PopoverRoot>
<PopoverTrigger render={<Button variant="outline">مشاركة</Button>} />
<PopoverPopup placement="start">
<div className="flex items-start justify-between gap-2">
<PopoverTitle>مشاركة هذه الصفحة</PopoverTitle>
<PopoverClose />
</div>
<PopoverDescription>يمكن لأي شخص لديه الرابط قراءتها. فريقك فقط يستطيع التعديل.</PopoverDescription>
</PopoverPopup>
</PopoverRoot>
<PopoverRoot>
<PopoverTrigger render={<Button variant="outline">التفاصيل</Button>} />
<PopoverPopup placement="end">
<PopoverTitle>كيف تعمل الفوترة</PopoverTitle>
<PopoverDescription>تُحتسب المقاعد المستخدمة فقط.</PopoverDescription>
</PopoverPopup>
</PopoverRoot>
<PopoverRoot>
<PopoverTrigger render={<Button variant="outline">الإعدادات</Button>} />
<PopoverPopup placement="bottom-start">
<PopoverTitle>إعدادات الإشعارات</PopoverTitle>
<PopoverDescription>يصلك ملخص واحد كل صباح.</PopoverDescription>
</PopoverPopup>
</PopoverRoot>
</div>
</TopClassProvider>
)
}
Props
PopoverRoot
PopoverClose
No props of its own: it takes the attributes of the element it renders.
PopoverDescription
No props of its own: it takes the attributes of the element it renders.
PopoverPopup
PopoverTitle
No props of its own: it takes the attributes of the element it renders.
PopoverTrigger
Keyboard and accessibility
PopoverTitlenames the popover andPopoverDescriptiondescribes it. With no title, passaria-labeltoPopoverPopup.- A touch screen reader has no Escape key: give a popover with more than a line of text a
PopoverClose. - While the popup scrolls it is a tab stop, so the keyboard reaches a long text with no control in it.
Right to left
start opens on the right and end on the left, and bottom-start lines up the right edges. The popup takes its direction from the nearest TopClassProvider.
Server and client
A client component: it renders on the server and hydrates in the browser.
Files: index.tsx, client.tsx.
On the chip: client - size not measured yet.