Expandable row list

Free · MIT

Appointment rows that open inline into a raised card with notes and working actions, while neighbouring rows smoothly make room.

MarkdownOpen in v0
Open in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/expandable-row-list.json

The file lands in components/goodui/expandable-row-list.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/expandable-row-list works too.

Usage

import { ExpandableRowList } from "@/components/goodui/expandable-row-list"

type Appointment = { id: string; time: string; client: string; service: string; notes: string }

export function TodaySchedule({ appointments, onCancel }: { appointments: Appointment[]; onCancel: (id: string) => void }) {
  return (
    <ExpandableRowList
      items={appointments}
      getKey={(a) => a.id}
      renderRow={(a, { expanded }) => (
        <div className="flex items-center gap-3">
          <span className="w-12 shrink-0 text-xs font-medium tabular-nums">{a.time}</span>
          <div className="min-w-0 flex-1">
            <p className="truncate text-sm font-medium">{a.client}</p>
            <p className="truncate text-xs text-muted-foreground">{expanded ? "Details below" : a.service}</p>
          </div>
        </div>
      )}
      renderDetails={(a, { collapse }) => (
        <div className="space-y-3 text-sm">
          <p className="text-muted-foreground">{a.notes}</p>
          <div className="flex gap-2">
            <button type="button" onClick={collapse}>Close</button>
            <button type="button" onClick={() => onCancel(a.id)}>Cancel appointment</button>
          </div>
        </div>
      )}
    />
  )
}

When to use

Use it for
  • Lists of records where details and actions only matter for one at a time: appointments, invoices, orders.
  • Mobile-friendly alternative to a details page or side panel: the row opens in place and keeps context.
  • Schedules where the row summary (time, client, status) is enough to scan, and notes or actions sit one tap away.
Reach for something else
  • Plain question-and-answer or settings sections with a title: use accordion-height.
  • Data that needs columns, sorting and comparison across rows: use table-row-presence.
  • A single card that expands into a large view: use expandable-card or morphing-dialog.
If you needUse
Focus on one record at a timetype="single" (default)
Compare details of several rowstype="multiple"
Tighter or looser lift around the open rowgap (default 10, px)

Examples

Controlled, several rows open

const [open, setOpen] = React.useState<string[]>([])

<ExpandableRowList
  type="multiple"
  value={open}
  onValueChange={setOpen}
  items={invoices}
  getKey={(i) => i.id}
  renderRow={(i) => <InvoiceSummary invoice={i} />}
  renderDetails={(i) => <InvoiceLines invoice={i} />}
/>

API reference

PropTypeDefault
items*
Rows in display order. Adding or removing items animates the row height in or out.
T[]—
getKey*
Stable unique key of a row. Any characters are fine: it is escaped before it goes into element ids.
(item: T) => string—
renderRow*
The always-visible summary of a row. Keep it free of buttons: the whole row is the toggle.
(item: T, state: { expanded: boolean }) => React.ReactNode—
renderDetails*
Details shown when the row opens. `collapse` closes it and returns focus to the row.
(item: T, state: { collapse: () => void }) => React.ReactNode—
type
"single" opens one row at a time.
"single" | "multiple""single"
value
Expanded row keys (controlled).
string[]—
defaultValue
Row keys open on first render (uncontrolled).
string[][]
onValueChange
Called with the new list of open row keys when a row opens or closes.
(value: string[]) => void—
gap
Space the open row lifts away from its neighbours, in px.
number10

Also accepts every <ul> attribute.

Accessibility

Enter / SpaceOpens or closes the focused row (native <button>).
ArrowDown / ArrowUpMoves focus to the next or previous row, stopping at the ends.
Home / EndMoves focus to the first or last row.
EscapeWith focus anywhere in an open row, including its details, closes it and returns focus to the row.
  • Each row summary is a native <button> with aria-expanded and aria-controls pointing at its details while they are open (closed details are not in the DOM).
  • Open details have role="region" and aria-labelledby set to the row, and unmount when closed.
  • collapse() from renderDetails closes the row and moves focus back to its button, so keyboard users are not left on a removed element.
  • renderRow renders inside the button: keep it free of buttons and links (nested interactive elements are invalid).

Motion

  • The open row lifts away from its neighbours by gap/2 above and below and becomes a bordered card with a shadow; margins and height move on a spring (bounce 0.12, 0.42 s), the card colors on a 300 ms CSS transition.
  • Details grow to their height on the same spring; their opacity fades in over 0.18 s after 0.06 s, and the content slides down 6px.
  • The chevron turns 180° on a spring (stiffness 420, damping 30).
  • Rows added to or removed from items grow in or collapse out the same way.
  • Reduced motion: every transition has duration 0; rows open, close, lift and appear instantly.

Notes for AI

Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.

  • Put actions (buttons, links) in renderDetails, never in renderRow, which renders inside the toggle button.
  • getKey must be stable and unique, and free of spaces: it is used in element ids for aria-controls.
  • Use the collapse function passed to renderDetails for Close or after an action, so focus returns to the row.
  • In single mode, clicking the open row closes it; there is no prop to keep one row always open.
  • Details unmount when the row closes: keep edited values in a parent if they must survive.

FAQ

How is this different from accordion-height?

accordion-height renders a fixed header (icon, title, description). Here you render the whole row and the details yourself, and the open row lifts into a card, which suits records with actions.

Can I remove a row from inside its details?

Yes. Remove the item from the items array (for example in a Cancel handler); the row collapses out and its neighbours close the gap.

Related