ComponentsPanel
Panel
A bordered box with a header, content and footer, whose content can fold away.
Import
import { Panel, PanelHeader, PanelTitle, PanelActions, PanelTrigger, PanelContent, PanelFooter } from "@booleanpress/ui/panel"Usage
A panel frames one block of a page under a title: a summary, a connection's settings, a ticket. For a stack of sections that open one at a time, use an accordion; for a box with no header bar, a card.
import { Panel, PanelContent, PanelHeader, PanelTitle, PanelTrigger } from "@booleanpress/ui/panel"
export function Connection() {
return (
<Panel toggleable>
<PanelHeader>
<PanelTitle>SMTP connection</PanelTitle>
<PanelTrigger />
</PanelHeader>
<PanelContent>Host smtp.example.com, port 587.</PanelContent>
</Panel>
)
}Without toggleable the content always shows and PanelTrigger renders nothing. With it, PanelTrigger folds the content away and back: uncontrolled with defaultOpen (true by default), or controlled with open and onOpenChange. The trigger is named "Show or hide" and the title, from the provider's toggleContent string, through aria-labelledby on the title's id, so the name is right from the first render (server HTML included); give PanelTitle your own id there, not on an asChild element. indicator replaces its chevron.
PanelActions holds the header's buttons, such as a dropdown menu trigger, before the toggle. PanelFooter placed after PanelContent stays in view when the content folds; placed inside it, it folds with the content. PanelTitle is a div: give it asChild and a heading element where the page's outline needs one. The content's height animates from Radix's measured --radix-collapsible-content-height.
Examples
Basic
A title over content that always shows.
import { Panel, PanelContent, PanelHeader, PanelTitle } from "@booleanpress/ui/panel"
import { Separator } from "@booleanpress/ui/separator"
const LINES = [
{ label: "Growth plan", amount: "$29.00" },
{ label: "10,000 extra emails", amount: "$8.00" },
{ label: "Dedicated IP", amount: "$5.99" },
]
export default function PanelBasic() {
return (
<Panel className="w-full max-w-xs">
<PanelHeader>
<PanelTitle>October invoice</PanelTitle>
</PanelHeader>
<PanelContent>
<div className="flex flex-col gap-3">
{LINES.map((line) => (
<div key={line.label} className="flex justify-between">
<span className="text-muted-foreground">{line.label}</span>
<span className="font-medium">{line.amount}</span>
</div>
))}
</div>
<Separator className="my-3.5" />
<div className="flex justify-between font-semibold">
<span>Total</span>
<span>$42.99</span>
</div>
</PanelContent>
</Panel>
)
}Toggleable
toggleable and a PanelTrigger: the chevron folds the content away and back.
import { Panel, PanelContent, PanelHeader, PanelTitle, PanelTrigger } from "@booleanpress/ui/panel"
import { Separator } from "@booleanpress/ui/separator"
const LINES = [
{ label: "Growth plan", amount: "$29.00" },
{ label: "10,000 extra emails", amount: "$8.00" },
{ label: "Dedicated IP", amount: "$5.99" },
]
export default function PanelToggleable() {
return (
<Panel toggleable className="w-full max-w-xs">
<PanelHeader>
<PanelTitle>October invoice</PanelTitle>
<PanelTrigger />
</PanelHeader>
<PanelContent>
<div className="flex flex-col gap-3">
{LINES.map((line) => (
<div key={line.label} className="flex justify-between">
<span className="text-muted-foreground">{line.label}</span>
<span className="font-medium">{line.amount}</span>
</div>
))}
</div>
<Separator className="my-3.5" />
<div className="flex justify-between font-semibold">
<span>Total</span>
<span>$42.99</span>
</div>
</PanelContent>
</Panel>
)
}Controlled
open and onOpenChange keep the state outside, so other buttons can open and close it.
import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { Panel, PanelContent, PanelHeader, PanelTitle, PanelTrigger } from "@booleanpress/ui/panel"
const ROWS = [
{ label: "Host", value: "smtp.example.com" },
{ label: "Port", value: "587 (STARTTLS)" },
{ label: "Signed in as", value: "mailer@example.com" },
]
export default function PanelControlled() {
const [open, setOpen] = useState(true)
return (
<div className="flex w-full max-w-xs flex-col gap-4">
<div className="flex justify-center gap-2">
<Button onClick={() => setOpen(true)}>Open</Button>
<Button variant="secondary" onClick={() => setOpen(false)}>
Close
</Button>
</div>
<Panel toggleable open={open} onOpenChange={setOpen}>
<PanelHeader>
<PanelTitle>SMTP connection</PanelTitle>
<PanelTrigger />
</PanelHeader>
<PanelContent>
<dl className="flex flex-col gap-3">
{ROWS.map((row) => (
<div key={row.label} className="flex justify-between gap-4">
<dt className="text-muted-foreground">{row.label}</dt>
<dd className="font-medium">{row.value}</dd>
</div>
))}
</dl>
</PanelContent>
</Panel>
</div>
)
}Custom indicator
indicator replaces the chevron with a minus that becomes a plus when the content folds.
import { MinusIcon, PlusIcon } from "lucide-react"
import { Panel, PanelContent, PanelHeader, PanelTitle, PanelTrigger } from "@booleanpress/ui/panel"
// The trigger is the Tailwind group `panel-trigger`: each icon shows in one state.
const indicator = (
<>
<MinusIcon className="size-3.5 group-data-[state=closed]/panel-trigger:hidden" />
<PlusIcon className="size-3.5 group-data-[state=open]/panel-trigger:hidden" />
</>
)
export default function PanelCustomIndicator() {
return (
<Panel toggleable className="w-full max-w-xs">
<PanelHeader>
<PanelTitle>Retry policy</PanelTitle>
<PanelTrigger indicator={indicator} />
</PanelHeader>
<PanelContent>
<p>
A failed email is retried after 5 minutes, 30 minutes and 2 hours. After the third failure it is marked failed in
the delivery log and an alert goes to ops@example.com.
</p>
</PanelContent>
</Panel>
)
}Header actions
An avatar in the title, and a menu button in PanelActions before the toggle.
import { EllipsisVerticalIcon, PencilIcon, PowerIcon, Trash2Icon } from "lucide-react"
import { Avatar, AvatarFallback } from "@booleanpress/ui/avatar"
import { Button } from "@booleanpress/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@booleanpress/ui/dropdown-menu"
import { Panel, PanelActions, PanelContent, PanelHeader, PanelTitle, PanelTrigger } from "@booleanpress/ui/panel"
export default function PanelHeaderActions() {
return (
<Panel toggleable className="w-full max-w-xs">
<PanelHeader>
<PanelTitle className="flex items-center gap-2">
<Avatar>
<AvatarFallback>MG</AvatarFallback>
</Avatar>
Mailgun
</PanelTitle>
<PanelActions>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" rounded className="text-muted-foreground" aria-label="Mailgun actions">
<EllipsisVerticalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<PencilIcon /> Edit connection
</DropdownMenuItem>
<DropdownMenuItem>
<PowerIcon /> Turn off
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<PanelTrigger />
</PanelActions>
</PanelHeader>
<PanelContent>
<p>
The fallback mailer: when Amazon SES refuses an email, it is sent through Mailgun's EU region instead. 214
emails took this path in October.
</p>
</PanelContent>
</Panel>
)
}Footer
A PanelFooter inside the content, with buttons and a timestamp, folds away with it.
Disabled
disabled on a toggleable panel stops the toggle; the content stays as it is.
import { Panel, PanelContent, PanelHeader, PanelTitle, PanelTrigger } from "@booleanpress/ui/panel"
export default function PanelDisabled() {
return (
<Panel toggleable disabled className="w-full max-w-xs">
<PanelHeader>
<PanelTitle>Billing details</PanelTitle>
<PanelTrigger />
</PanelHeader>
<PanelContent>
<p className="text-muted-foreground">Invoices go to billing@example.com. Your plan is managed by the account owner.</p>
</PanelContent>
</Panel>
)
}Accessibility
- Semantics
- The panel is a plain
div. The toggle is abuttonwitharia-expandedandaria-controlspointing at the content; the closed content is removed from the page. - Labels
- The toggle's name is the provider's
toggleContentstring with the title in the place of{title}: "Show or hide SMTP connection". It is built witharia-labelledby(the words round{title}in hidden spans, then the title's id), so a translation can put the title anywhere and the name never waits for the page to run. Passaria-labeltoPanelTriggerto name it yourself. Name icon buttons inPanelActions. - Focus
- The toggle and the header's buttons are tab stops, in order, then the content's controls. Focus is a 1px
--ringoutline 2px outside the toggle's 24px circle. - Known limits
- The title is a
div: useasChildwith anh2orh3where the panel starts a section of the page. - Closed content is removed from the page, so find-in-page and screen readers do not see it, and fields in it lose what was typed. Use
forceMountonPanelContentto keep it in the page, hidden while folded. - The indicator is decoration (
aria-hidden); the state is inaria-expanded.
- The title is a
Keyboard
| Key | Behaviour |
|---|---|
| EnterorSpace | On the toggle: shows or hides the content. |
API
Panel
Renders a div and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultOpen | boolean | true | Whether the content shows at the start, when the panel controls itself. true by default. |
disabled | boolean | Stops the toggle from working; the content keeps its state. | |
onOpenChange | ((open: boolean) => void) | Called with the new state when the content is shown or hidden. | |
open | boolean | Whether the content shows, when you control it. Pair it with onOpenChange. Needs toggleable. | |
toggleable | boolean | false | Adds a PanelTrigger's toggle: the content folds away and back. false by default. |
PanelHeader
Renders a div and passes it every other prop.
PanelTitle
Renders a div and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Render the child element instead (an h2, an h3), with the title's classes merged onto it. |
PanelActions
Renders a div and passes it every other prop.
PanelTrigger
Renders Radix Collapsible.Trigger and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | Render the child element instead, with the toggle's behaviour merged onto it. | |
indicator | ReactNode | Replaces the chevron, which turns when the content opens. The trigger is the Tailwind group panel-trigger. |
PanelContent
Renders Radix Collapsible.Content and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | ||
forceMount | true | Used to force mounting when more control is needed. Useful when controlling animation with React animation libraries. |
PanelFooter
Renders a div and passes it every other prop.
Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.
Data attributes: data-slot="panel" (Panel), data-slot="panel-header" (PanelHeader), data-slot="panel-title" (PanelTitle), data-slot="panel-actions" (PanelActions), data-slot="panel-trigger" (PanelTrigger), data-slot="panel-content" (PanelContent), data-slot="panel-footer" (PanelFooter).
Provider strings: toggleContent (BooleanUIProvider's strings).
Theming
The panel is --card with a 1px --border edge and a 6px radius; the title is 14px semibold. The toggle's icon is --foreground, with an --accent circle under the pointer; focus is --ring.
The tokens its classes read. Change their values in your theme, and it follows.
| Token | Used for |
|---|---|
--accent | background |
--accent-foreground | text |
--card | background |
--card-foreground | text |
--foreground | text |
--ring | outline |