Duck List Header
Column labels over a stack of list rows, with a sort control and a column contract the header and the rows share.
NavigationServer safelucide-react
Preview
Role
Installation
The CLI writes the source into your project and pulls in whatever it depends on.
$
pnpm dlx shadcn@latest add @duck/duck-list-headerPulls in @duck/theme, @duck/hud-label, @duck/duck-list-row. Already installed items are skipped.
Usage
import { DuckList, DuckListHeader } from "@/components/ui/duck-list-header"The file lands in components/ui/duck-list-header.tsx and belongs to you from that point on. Edit it in place rather than wrapping it.
Props
Everything not listed here is forwarded to the underlying element.
| Prop | Type | Default | Description |
|---|---|---|---|
| columns | { key: string; label: React.ReactNode; width?: string; sortable?: boolean }[] | - | The contract. key matches sort.key, width is any grid track that sizes without content (1fr, 8rem, 15%) and defaults to an even share, sortable draws the label as a button. |
| sort | { key: string; direction: "ascending" | "descending" } | - | Which column the rows are ordered by. Controlled — sort your own data. |
| onSortChange | (sort: DuckListSort) => void | - | The column clicked and the direction it wants next: ascending on a new column, flipped on the current one. |
| header | boolean | true | On DuckList: set false for a column-aligned list that wants no labels over it. |
Rules
What keeps this component from turning into noise.
- DuckList writes the track list once as --duck-list-cols and every DuckListRow with cells reads it. A width class on a row is the bug this component exists to remove.
- Tracks have to size without looking at content: 1fr, rem, %, minmax(). auto and max-content resolve per row, so the columns stop lining up — which is the whole point of the property.
- A custom property rather than subgrid: subgrid needs every row to be a direct child of the grid, so it breaks the moment a row is wrapped in an <li>, a motion.div or a virtualiser. An inherited property survives any depth of nesting.
- This is not a table. A sortable, column-aligned list is still a list: no role="table", no role="columnheader", and so no aria-sort, which is only defined on a columnheader. Half a table role is worse than plain divs, and the rows are usually anchors — role="row" on an <a> costs you the link.
- Anything that needs row selection, resizable columns or a caption is a real <table>. Use shadcn's; the duck theme already styles it.
- Sort direction is in the sort button's accessible name in words — "last seen, sorted descending" — because a chevron is not an announcement.
- Alignment is deliberately not part of the contract. A right-aligned numeric column would need the same class on the header cell and on every row cell, which is the copying the contract exists to stop. If you need it, you need a table.
