List
Presentational item list for Object Page rows — title, description, extra, and actions.
List is the Ant-class presentational item list: stacked rows with title, description, optional avatar, trailing extra, and row actions. Use it inside Object Page cards, dialogs, and other embedded collections. For a full List Report page with schema, selection, and chrome, use QueryList with view="list". For columnar compare and sort, use Table. Every collection job (Members, assigned keys, picker, index, attachments) is enumerated on List Surfaces.
When To Use
- Embed a row collection on an Object Page, card, or dialog when each item is a title + description (optionally avatar, meta, actions).
- Prefer Table when operators need to compare columns, sort, or scan dense fields side by side.
- Prefer QueryList with
view="list"when the list is the page — schema-driven List Report with filters, selection / batch, and data-list chrome. - Row ops belong on
List.Itemactions. Checkboxes and a batch bar are QueryList / Table, not this primitive — compose a Checkbox intoleadingonly for a picker. - Do not import data-list internals into this primitive; keep List presentational and host-owned for fetch state.
| This job | This component |
|---|---|
| Members / activity inside Related List | List + actions |
| Grouped assigned keys (then Formily Save) | List.Group |
| Dialog picker | List + leading Checkbox |
| Resource index with selection / batch | QueryList view="list" — not this page |
Features
| Area | Behavior |
|---|---|
| Root | ul with data-slot="list"; optional split dividers (default on) and bordered frame |
| Item | li row with optional leading, main content, extra, and actions outside the title |
| Meta | Avatar + title + optional meta chips + description (List.Meta) |
| Group | Nested labelled group (List.Group) with sentence-case heading |
| Data mode | Optional dataSource + renderItem + required rowKey |
| Many row ops | Compose DataListRowActionsCell into actions (maxInline={2} + ⋮). Do not ship an icon wall or a third overflow. |
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/list.jsonnpx shadcn@latest add https://ui.isaacfei.com/r/list.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/r/list.jsonbun x shadcn@latest add https://ui.isaacfei.com/r/list.jsonWith a namespace: npx shadcn@latest add @f-ui/list.
registryDependencies: utils.
Usage
import { List } from "@/components/f-ui/list/list";
<List>
<List.Item>
<List.Meta title="Ada Lovelace" description="Mathematician" />
</List.Item>
</List>Examples
Basic Rows
Two members with title and description — the default split dividers between rows.
- Ada LovelaceMathematician
- Grace HopperRear admiral
"use client";
import { List } from "@/components/f-ui/list/list";
export function ListBasicDemo() {
return (
<List>
<List.Item>
<List.Meta title="Ada Lovelace" description="Mathematician" />
</List.Item>
<List.Item>
<List.Meta title="Grace Hopper" description="Rear admiral" />
</List.Item>
</List>
);
}Grouped
A nested group with a sentence-case heading and labelled nested list for related tickets.
Tickets
- INC-1042Printer offline on floor 3
- INC-1048VPN access for new hire
"use client";
import { List } from "@/components/f-ui/list/list";
export function ListGroupedDemo() {
return (
<List>
<List.Group heading="Tickets">
<List.Item>
<List.Meta title="INC-1042" description="Printer offline on floor 3" />
</List.Item>
<List.Item>
<List.Meta title="INC-1048" description="VPN access for new hire" />
</List.Item>
</List.Group>
</List>
);
}Actions vs Extra
extra holds muted metadata; actions holds a ghost icon Remove control outside the title link area.
- Ada Lovelaceada@example.comOwner
- Grace Hoppergrace@example.comMember
"use client";
import { Trash2Icon } from "lucide-react";
import { List } from "@/components/f-ui/list/list";
import { IconGhostButton } from "@/demos/_shared/icon-ghost-button";
export function ListActionsDemo() {
return (
<List>
<List.Item
extra="Owner"
actions={[
<IconGhostButton key="rm" label="Remove">
<Trash2Icon className="size-4" />
</IconGhostButton>,
]}
>
<List.Meta title="Ada Lovelace" description="ada@example.com" />
</List.Item>
<List.Item
extra="Member"
actions={[
<IconGhostButton key="rm" label="Remove">
<Trash2Icon className="size-4" />
</IconGhostButton>,
]}
>
<List.Meta title="Grace Hopper" description="grace@example.com" />
</List.Item>
</List>
);
}Many Row Actions
Five ghosts steal the title. Keep at most two frequent icons inline, put the rest in ⋮, and leave Remove trailing — the same Row Actions Column rule Table already locked. Compose DataListRowActionsCell into actions; do not invent a third overflow.
Wrong
- Ada Lovelaceada@example.comOwner
- Grace Hoppergrace@example.comMember
Right
- Ada Lovelaceada@example.comOwner
- Grace Hoppergrace@example.comMember
"use client";
import {
CopyIcon,
DownloadIcon,
PencilIcon,
Share2Icon,
Trash2Icon,
} from "lucide-react";
import type { RowAction } from "@/components/f-ui/data-list-internals/row-actions/row-action-types";
import { useDataListViewI18n } from "@/components/f-ui/data-list-view/hooks/use-data-list-view-i18n";
import { DataListRowActionsCell } from "@/components/f-ui/data-list-view/row-actions-cell";
import { List } from "@/components/f-ui/list/list";
import { TooltipProvider } from "@/components/ui/tooltip";
import { DesignCompare } from "@/demos/_design/design-compare";
import { IconGhostButton } from "@/demos/_shared/icon-ghost-button";
type Member = {
id: string;
name: string;
email: string;
extra: string;
};
const MEMBERS: Member[] = [
{
id: "ada",
name: "Ada Lovelace",
email: "ada@example.com",
extra: "Owner",
},
{
id: "grace",
name: "Grace Hopper",
email: "grace@example.com",
extra: "Member",
},
];
const ROW_ACTIONS: RowAction<Member>[] = [
{
id: "edit",
label: "Edit",
icon: <PencilIcon className="size-3.5" />,
onClick: () => {},
},
{
id: "duplicate",
label: "Duplicate",
icon: <CopyIcon className="size-3.5" />,
group: "secondary",
onClick: () => {},
},
{
id: "share",
label: "Share",
icon: <Share2Icon className="size-3.5" />,
group: "secondary",
onClick: () => {},
},
{
id: "download",
label: "Download",
icon: <DownloadIcon className="size-3.5" />,
group: "secondary",
onClick: () => {},
},
{
id: "remove",
label: "Remove",
icon: <Trash2Icon className="size-3.5" />,
variant: "destructive",
placement: "trailing",
confirm: false,
onClick: () => {},
},
];
function IconWallList() {
return (
<List>
{MEMBERS.map((member) => (
<List.Item
key={member.id}
extra={member.extra}
actions={[
<IconGhostButton key="edit" label="Edit">
<PencilIcon className="size-4" />
</IconGhostButton>,
<IconGhostButton key="duplicate" label="Duplicate">
<CopyIcon className="size-4" />
</IconGhostButton>,
<IconGhostButton key="share" label="Share">
<Share2Icon className="size-4" />
</IconGhostButton>,
<IconGhostButton key="download" label="Download">
<DownloadIcon className="size-4" />
</IconGhostButton>,
<IconGhostButton key="remove" label="Remove">
<Trash2Icon className="size-4" />
</IconGhostButton>,
]}
>
<List.Meta title={member.name} description={member.email} />
</List.Item>
))}
</List>
);
}
function OverflowList() {
const { t } = useDataListViewI18n();
return (
<List>
{MEMBERS.map((member) => (
<List.Item
key={member.id}
extra={member.extra}
actions={[
<DataListRowActionsCell
key="ops"
row={member}
actions={ROW_ACTIONS}
display="inline"
inlinePresentation="icon"
maxInline={2}
t={t}
/>,
]}
>
<List.Meta title={member.name} description={member.email} />
</List.Item>
))}
</List>
);
}
/** Many row ops: icon wall vs DataListRowActionsCell (max 2 inline + overflow + trailing Remove). */
export function ListManyActionsDemo() {
return (
<TooltipProvider>
<DesignCompare wrong={<IconWallList />} right={<OverflowList />} />
</TooltipProvider>
);
}<List.Item
extra="Owner"
actions={[
<DataListRowActionsCell
key="ops"
row={member}
actions={rowActions}
display="inline"
inlinePresentation="icon"
maxInline={2}
t={t}
/>,
]}
>
<List.Meta title={member.name} description={member.email} />
</List.Item>Avatar And Meta Chips
Avatar sits in List.Meta. Status chips use meta beside the title — not a second description line.
- Ada LovelaceAdminada@example.comOwner
- Grace HopperViewergrace@example.comMember
"use client";
import { List } from "@/components/f-ui/list/list";
import { StatusTag } from "@/components/f-ui/status-tag";
function Initials({ children }: { children: string }) {
return (
<span
aria-hidden
className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-full text-xs font-medium"
>
{children}
</span>
);
}
export function ListAvatarMetaDemo() {
return (
<List>
<List.Item extra="Owner">
<List.Meta
avatar={<Initials>AL</Initials>}
description="ada@example.com"
meta={<StatusTag tone="info">Admin</StatusTag>}
title="Ada Lovelace"
/>
</List.Item>
<List.Item extra="Member">
<List.Meta
avatar={<Initials>GH</Initials>}
description="grace@example.com"
meta={<StatusTag tone="neutral">Viewer</StatusTag>}
title="Grace Hopper"
/>
</List.Item>
</List>
);
}Picker Checkboxes
A dialog or Transfer source may compose a Checkbox into leading. That is host state. It is not kit rowSelection and must not grow a batch footer. For mass ops on a resource index, use QueryList view="list".
- Ada Lovelaceada@example.com
- Grace Hoppergrace@example.com
- Alan Turingalan@example.com
"use client";
import { useState } from "react";
import { List } from "@/components/f-ui/list/list";
import { Checkbox } from "@/components/ui/checkbox";
const PEOPLE = [
{ id: "ada", name: "Ada Lovelace", email: "ada@example.com" },
{ id: "grace", name: "Grace Hopper", email: "grace@example.com" },
{ id: "alan", name: "Alan Turing", email: "alan@example.com" },
];
/** Dialog / Transfer-style picker: host Checkbox in `leading`. Not a batch bar. */
export function ListLeadingPickerDemo() {
const [picked, setPicked] = useState<string[]>(["ada"]);
return (
<List>
{PEOPLE.map((person) => {
const checked = picked.includes(person.id);
return (
<List.Item
key={person.id}
leading={
<Checkbox
aria-label={`Select ${person.name}`}
checked={checked}
onCheckedChange={(value) => {
const on = value === true;
setPicked((current) =>
on
? [...current, person.id]
: current.filter((id) => id !== person.id),
);
}}
/>
}
>
<List.Meta description={person.email} title={person.name} />
</List.Item>
);
})}
</List>
);
}Title As A Link
title is a ReactNode. Pass a host <a> (or your router Link). List does not own navigation.
- ada@example.comOwner
- grace@example.comMember
"use client";
import { List } from "@/components/f-ui/list/list";
/** Title is a host link. List does not own routing. */
export function ListTitleLinkDemo() {
return (
<List>
<List.Item extra="Owner">
<List.Meta
description="ada@example.com"
title={
<a className="text-primary hover:underline" href="#ada">
Ada Lovelace
</a>
}
/>
</List.Item>
<List.Item extra="Member">
<List.Meta
description="grace@example.com"
title={
<a className="text-primary hover:underline" href="#grace">
Grace Hopper
</a>
}
/>
</List.Item>
</List>
);
}Every Slot On One Row
A picker of people uses checkbox in leading, avatar in List.Meta, title + chip, description, extra, and actions. Do not put a decorative icon in leading when the row already has an avatar — leading is the control, avatar is the identity.
- Ada LovelaceAdminada@example.comOwner
- Grace HopperViewergrace@example.comMember
"use client";
import { useState } from "react";
import { Trash2Icon } from "lucide-react";
import { List } from "@/components/f-ui/list/list";
import { StatusTag } from "@/components/f-ui/status-tag";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
function Initials({ children }: { children: string }) {
return (
<span
aria-hidden
className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-full text-xs font-medium"
>
{children}
</span>
);
}
function RemoveButton({ label }: { label: string }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button aria-label={label} size="icon" type="button" variant="ghost">
<Trash2Icon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
const PEOPLE = [
{
id: "ada",
initials: "AL",
name: "Ada Lovelace",
email: "ada@example.com",
role: "Admin",
extra: "Owner",
},
{
id: "grace",
initials: "GH",
name: "Grace Hopper",
email: "grace@example.com",
role: "Viewer",
extra: "Member",
},
] as const;
/** Checkbox + avatar + title + chip + description + extra + actions — a real picker row. */
export function ListAllSlotsDemo() {
const [picked, setPicked] = useState<string[]>(["ada"]);
return (
<TooltipProvider>
<List>
{PEOPLE.map((person) => {
const checked = picked.includes(person.id);
return (
<List.Item
key={person.id}
actions={[
<RemoveButton key="rm" label={`Remove ${person.name}`} />,
]}
extra={person.extra}
leading={
<Checkbox
aria-label={`Select ${person.name}`}
checked={checked}
onCheckedChange={(value) => {
const on = value === true;
setPicked((current) =>
on
? [...current, person.id]
: current.filter((id) => id !== person.id),
);
}}
/>
}
>
<List.Meta
avatar={<Initials>{person.initials}</Initials>}
description={person.email}
meta={
<StatusTag tone={person.role === "Admin" ? "info" : "neutral"}>
{person.role}
</StatusTag>
}
title={person.name}
/>
</List.Item>
);
})}
</List>
</TooltipProvider>
);
}Data Source Map
dataSource + renderItem + required rowKey maps an array. bordered frames the list when it is not already inside Related List.
- INC-1042Printer offline on floor 3
- INC-1048VPN access for new hire
"use client";
import { List } from "@/components/f-ui/list/list";
const TICKETS = [
{ id: "INC-1042", summary: "Printer offline on floor 3" },
{ id: "INC-1048", summary: "VPN access for new hire" },
];
export function ListDataSourceDemo() {
return (
<List
bordered
dataSource={TICKETS}
renderItem={(row) => (
<List.Item>
<List.Meta description={row.summary} title={row.id} />
</List.Item>
)}
rowKey="id"
/>
);
}No Row Dividers
split={false} drops the default divide-y — use it for a tight activity stack, not for Members.
- Order ORD-1001Changed status to Confirmed2m ago
- Order ORD-1001Uploaded packing-list.xlsx1h ago
- MembersAdded Grace HopperYesterday
"use client";
import { List } from "@/components/f-ui/list/list";
/** Tight activity rows without dividers. */
export function ListNoSplitDemo() {
return (
<List split={false}>
<List.Item extra="2m ago">
<List.Meta description="Changed status to Confirmed" title="Order ORD-1001" />
</List.Item>
<List.Item extra="1h ago">
<List.Meta description="Uploaded packing-list.xlsx" title="Order ORD-1001" />
</List.Item>
<List.Item extra="Yesterday">
<List.Meta description="Added Grace Hopper" title="Members" />
</List.Item>
</List>
);
}Composition
List
├─ List.Item
│ ├─ leading (optional checkbox / icon)
│ ├─ List.Meta (avatar, title, meta, description)
│ ├─ extra
│ └─ actions
└─ List.Group
└─ List.Item …Edge Cases And Errors
Empty Collection
A blank <List /> is not an Empty state. On an Object Page, set Related List status="empty". On a resource index, let QueryList own Empty. Do not render an empty ul and hope operators understand it.
Checkboxes
A Checkbox in leading is host picker state. It is not kit selection: there is no rowSelection, no selectedRowKeys, and no batch footer. For mass ops, use QueryList or Related List + Table.
dataSource Without renderItem / rowKey
Both are required when dataSource is set. Missing either throws. Prefer declarative List.Item children unless you are mapping a host array.
Loading And Error
List is presentational. Do not import data-list internals here. Host Related List status or QueryList for the triad.
API Reference
Props
List
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Declarative rows / groups |
dataSource | readonly T[] | — | Items to map with renderItem |
renderItem | (item: T, index: number) => ReactNode | — | Required when dataSource is set. QueryList view="list" uses the same word with (row, ctx, defaultItem) — handle-aware; still return List.Item. |
rowKey | keyof T | ((item: T) => string) | — | Required when dataSource is set |
split | boolean | true | Row dividers (divide-y) |
bordered | boolean | false | Outer border + radius |
className | string | — | Root ul class |
List.Item
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Usually List.Meta |
leading | ReactNode | — | Leading control (checkbox, icon) outside the title |
extra | ReactNode | — | Trailing muted meta |
actions | ReactNode[] | — | Trailing action controls |
className | string | — | Row li class |
List.Meta
| Prop | Type | Default | Description |
|---|---|---|---|
avatar | ReactNode | — | Leading media |
title | ReactNode | — | Primary label |
meta | ReactNode | — | Inline chips beside the title |
description | ReactNode | — | Secondary text |
className | string | — | Meta root class |
List.Group
| Prop | Type | Default | Description |
|---|---|---|---|
heading | ReactNode | — | Group heading (h3) |
children | ReactNode | — | Nested List.Item rows |
className | string | — | Group li class |