caliper uidocs

Fold panel

Free

An accordion whose sections are hinged to their headers. Opening turns a section down from edge-on to flat while its height opens, so you can see which header it belongs to and where it goes when it closes. It is the standard accordion underneath: header buttons, aria-expanded, regions named by their header.

Installs components/ui/fold-panel.tsxDepends on @radix-ui/react-slot, lucide-reactMotion none in component source
pnpm dlx shadcn@latest add @caliper/fold-panel

Address
2 Rosewood Lane, 1780 Halden
Method
Tracked, 2 days
Cost
$6.00

Card
Visa ending 4821
Billing name
Mira Solheim
Charge date
On dispatch

Address
Same as shipping
VAT number
NO 912 345 678

For the courier
Leave with the neighbour at number 4 if nobody answers

When to use #

Use it for

  • Checkout, settings and order summaries where each section has a one-line summary worth showing while closed.
  • Anywhere an accordion should feel attached to its header instead of sliding in from nowhere.

Skip it when

  • Long article text; a fold per paragraph is a lot of motion.
  • Navigation; use the sidebar or tabs.

Decisions #

Why it is built this way, so you know what to keep when you change your copy.

Behaviour

Depth carries attachment
The hinge is the header's bottom edge. The section is the only thing that turns, so the text is flat the moment it is readable.
One open or several is a prop
type is single or multiple, and value is always the list of open ids, so switching between them never changes how you read state.

Usage

Closed means inert
A closed section is inert, so Tab never lands in it and screen readers skip it. Focus stays on the header you pressed.

Installation #

terminal
pnpm dlx shadcn@latest add @caliper/fold-panel

Adds its 4 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.

Usage #

This is the code behind the preview above.

example.tsx
import { useState } from "react"
import { FoldPanel, type FoldSection } from "@/components/ui/fold-panel"
import { SegmentedControl } from "@/components/ui/segmented-control"

const dl = (rows: readonly (readonly [string, string])[]) => (
  <dl>
    {rows.map(([term, value]) => (
      <div key={term}>
        <dt>{term}</dt>
        <dd>{value}</dd>
      </div>
    ))}
  </dl>
);

const order: FoldSection[] = [
  { id: "shipping", title: "Shipping", summary: "2 Rosewood Lane, Halden", content: dl([["Address", "2 Rosewood Lane, 1780 Halden"], ["Method", "Tracked, 2 days"], ["Cost", "$6.00"]]) },
  { id: "payment", title: "Payment", summary: "Visa ending 4821", content: dl([["Card", "Visa ending 4821"], ["Billing name", "Mira Solheim"], ["Charge date", "On dispatch"]]) },
  { id: "billing", title: "Billing address", summary: "Same as shipping", content: dl([["Address", "Same as shipping"], ["VAT number", "NO 912 345 678"]]) },
  { id: "notes", title: "Notes", summary: "Leave with neighbour", content: dl([["For the courier", "Leave with the neighbour at number 4 if nobody answers"]]) },
];

export function FoldPanelDemo() {
  const [type, setType] = useState<"single" | "multiple">("single");
  const [open, setOpen] = useState<string[]>(["shipping"]);
  return (
    <div className="grid w-full max-w-md justify-items-center gap-3">
      <SegmentedControl label="How many open" value={type} onValueChange={(v) => setType(v === "multiple" ? "multiple" : "single")} options={[{ value: "single", label: "One open" }, { value: "multiple", label: "Several open" }]} />
      <div className="w-full">
        <FoldPanel sections={order} type={type} value={open} onValueChange={setOpen} />
      </div>
    </div>
  );
}

API reference #

FoldPanel

component
PropTypeDefaultDescription
sections *readonly FoldSection[]—
type"multiple" | "single""single"
valuereadonly string[]—Ids of the open sections. Controlled when set.
defaultValuereadonly string[][]
onValueChange((value: string[]) => void)—
headingLevel2 | 3 | 4 | 53The level of each header's heading, so the panel fits the page outline.
statusDepthStatus"ready"
messagesDepthMessages—
onRetry(() => void)—
disabledboolean—
classNamestring—

Accessibility #

It is built on native elements and keeps aria-expanded, aria-controls, aria-hidden and aria-labelledby in step with state.

KeyBehaviour
↓Moves to the next item or value.
↑Moves to the previous item or value.
HomeJumps to the first item or the minimum.
EndJumps to the last item or the maximum.

Motion #

It declares no animation in its component source. Any transition comes from its stylesheet, which uses the duration tokens that collapse to instant under prefers-reduced-motion.