Duck List View
The admin list with its state machine: a debounced filter, in-memory sorting, skeleton rows in the real column tracks, and two different empty screens.
BlockClient component
Preview
Installation
One command writes the section and every component it renders.
$
pnpm dlx shadcn@latest add @duck/duck-list-viewThe block lands in components/blocks/duck-list-view.tsx. It builds on duck-list-header, duck-list-row, glow-search, sticker-skeleton and empty-pond, which the CLI installs alongside it. Already installed items are skipped.
Usage
import { DuckListView } from "@/components/blocks/duck-list-view"A block is a starting point, not a widget. The props exist so the example renders with real content — once the file is in your project, hard-code what never changes and delete the rest.
Props
Everything not listed here is forwarded to the root element.
| Prop | Type | Default | Description |
|---|---|---|---|
| columns | DuckListColumn[] | - | key, label, width, sortable — DuckList's own column definitions. Widths must size without content, as there. |
| rows | DuckListViewRow[] | - | id, title, description, meta, index, cells, values, href, trailing. values is the row as data: cells are nodes and cannot be sorted or matched. |
| title | React.ReactNode | - | Heading above the list. |
| actions | React.ReactNode | - | Beside the field: a filter, a New button, an export menu. |
| search | boolean | true | Drop the field for a list filtered from elsewhere. |
| onSearch | (query: string) => void | - | Take the debounced query yourself. Passing it also switches the in-memory filter off, for a paged endpoint. |
| sort | DuckListSort | - | Controlled sort — key plus direction. |
| onSortChange | (sort: DuckListSort) => void | - | Take sorting yourself. Passing it also switches the in-memory sort off. |
| loading | boolean | false | Skeleton rows inside the real column tracks. |
| skeletonRows | number | 6 | How many. Match your page size. |
| empty | React.ReactNode | - | Shown when there are no rows at all. Give it an action. |
| noMatchesTitle | string | "Nothing matched" | Heading of the no-results state. |
| showCount | boolean | true | Prints "12 of 48 shown" in a polite live region. |
| render | (row: DuckListViewRow) => React.ReactElement | - | Return the framework's link element for a row. The row is cloned into it through asChild. |
Rules
What keeps this section from turning into noise.
- Empty and no-matches are two different screens. The first is an invitation and wants an action; the second is a dead end and wants the query cleared. Conflating them is how a list with 400 rows tells someone to create their first one.
- The field keeps two states: what the reader typed, and the debounced query everything else reads. Filtering off the first runs six passes for six characters; driving the field off the second makes it lag the caret.
- The count carries the live region, not the list. One announcement per settled query beats one per surviving row.
- Sort against values, never cells. A cell can be a chip or a button, and comparing JSX silently sorts by nothing.
- Skeleton rows go inside the same DuckList, so the tracks are the real ones and the header does not jump when the data lands.
- The in-memory sort copies before sorting. The rows array belongs to the caller, and sorting it in place reorders their state behind their back.
