Unfold List

A disclosure list whose panels turn down onto the page from their top edge.

Loading preview

Installation

npx shadcn@latest add @loomui/unfold-list

Usage

import { UnfoldItem, UnfoldList } from "@/components/ui/unfold-list"
<UnfoldList defaultValue="own">
  <UnfoldItem value="own" title="Do I own the code?">
    Every component is copied into your project as a plain file.
  </UnfoldItem>
  <UnfoldItem value="deps" title="What does it install?">
    The file, and the tokens it needs in your stylesheet.
  </UnfoldItem>
</UnfoldList>

A panel opens to whatever height its content happens to be. The row it sits in is a grid track animated from 0fr to 1fr. Nothing has to measure the content first. There is no fixed height to keep in sync when the content changes, the window resizes, or a font finally loads.

On top of that, the panel turns down onto the page from its top edge rather than sliding up from underneath. That is the difference between paper unfolding and a drawer opening. It is why the content tilts very slightly away from you until the fold finishes.

One at a time, or many

<UnfoldList type="multiple" defaultValue={["own", "deps"]}>

single is the default: opening a panel closes the one that was open, which suits an FAQ where the answers are alternatives. multiple lets a reader open as many as they want, which suits documentation where they are steps.

In single mode, collapsible decides whether the open panel can be closed again by clicking its own row. Leave it on unless something below the list depends on a panel always being open.

Controlled

const [open, setOpen] = React.useState<string[]>(["own"])
 
<UnfoldList value={open} onValueChange={setOpen}>

Pass value and the list stops holding its own state. onValueChange is always called with an array, in both modes, so the handler does not change shape when the type does.

Props

UnfoldList

PropTypeDefaultDescription
type"single" | "multiple""single"One panel open at a time, or many.
defaultValuestring | string[]nonePanels open before anyone touches it.
valuestring | string[]noneOpen panels, if you hold the state.
onValueChange(value: string[]) => voidnoneCalled with the open panels.
collapsiblebooleantrueIn single mode, allow closing it again.
durationnumber320Milliseconds one panel takes to unfold.

UnfoldItem

PropTypeDefaultDescription
valuestringnoneIdentifies the panel. Unique in the list.
titleReactNodenoneThe row, which is the button that opens it.
classNamestringnoneMerged onto the item.

Any other <div> prop is forwarded.

Accessibility

Each row is a real button carrying aria-expanded and pointing at its panel with aria-controls; the panel is a labelled region pointing back at its row. A closed panel is marked inert. That takes it out of the tab order and off the accessibility tree while it can still animate closed, which hidden would not allow. Under prefers-reduced-motion panels change state with no fold at all.