Skip to the content

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&apos;s EU region instead. 214
          emails took this path in October.
        </p>
      </PanelContent>
    </Panel>
  )
}

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 a button with aria-expanded and aria-controls pointing at the content; the closed content is removed from the page.
Labels
The toggle's name is the provider's toggleContent string with the title in the place of {title}: "Show or hide SMTP connection". It is built with aria-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. Pass aria-label to PanelTrigger to name it yourself. Name icon buttons in PanelActions.
Focus
The toggle and the header's buttons are tab stops, in order, then the content's controls. Focus is a 1px --ring outline 2px outside the toggle's 24px circle.
Known limits
  • The title is a div: use asChild with an h2 or h3 where 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 forceMount on PanelContent to keep it in the page, hidden while folded.
  • The indicator is decoration (aria-hidden); the state is in aria-expanded.

Keyboard

Keyboard
KeyBehaviour
EnterorSpaceOn the toggle: shows or hides the content.

API

Panel

Renders a div and passes it every other prop.

Panel props
PropTypeDefaultDescription
defaultOpenbooleantrueWhether the content shows at the start, when the panel controls itself. true by default.
disabledbooleanStops 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.
openbooleanWhether the content shows, when you control it. Pair it with onOpenChange. Needs toggleable.
toggleablebooleanfalseAdds 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.

PanelTitle props
PropTypeDefaultDescription
asChildbooleanfalseRender 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.

PanelTrigger props
PropTypeDefaultDescription
asChildbooleanRender the child element instead, with the toggle's behaviour merged onto it.
indicatorReactNodeReplaces 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.

PanelContent props
PropTypeDefaultDescription
asChildboolean
forceMounttrueUsed 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.

Theme tokens
TokenUsed for
--accentbackground
--accent-foregroundtext
--cardbackground
--card-foregroundtext
--foregroundtext
--ringoutline