Data list
A stacked list of rows with a leading slot, a title block, trailing meta, and actions.
Server-compatible until you pass onClick. The shape a table takes when it has to fit a narrow column — a sidebar, a card, a phone. An item with href renders as a link, a bare anchor unless renderLink hands it to a router; an item with only onClick becomes a button role with a tab stop and Enter and Space wired up. Do not give a linked or clickable item actions as well: nesting a button inside a link or another button is invalid, so put the link on the title instead. divided draws hairlines between items and no frame of its own, so a list can sit inside a card that already has one. Item padding reads --density-cell-y — the same variable the table cells read, so a list beside a table keeps its rhythm.
Install
npx shadcn@latest add @vibra/data-listNeeds the @vibra registry in your components.json — set it up once.
Examples
import { GitCommitVerticalIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { DataList, DataListItem } from "@/components/ui/data-list"
import { DateCell } from "@/components/ui/table-cells"
type Deploy = {
id: string
service: string
commit: string
author: string
state: "live" | "building" | "failed"
at: Date
}
const MINUTE = 60_000
// A fixed reference instant, not Date.now(): a statically built page freezes
// the server's copy at build time, so a live clock here would read one thing in
// the HTML and another once the reader's own clock takes over. format-demo.tsx
// pins its instant for the same reason.
const NOW = new Date(2026, 8, 4, 12, 0, 0).getTime()
const STATE: Record<Deploy["state"], { label: string; dot: string }> = {
live: { label: "Live", dot: "bg-success" },
building: { label: "Building", dot: "bg-warning" },
failed: { label: "Failed", dot: "bg-danger" },
}
const deploys: Deploy[] = [
{
id: "dpl_9f21",
service: "api-gateway",
commit: "4c1a7f2",
author: "Olivia Martin",
state: "live",
at: new Date(NOW - 4 * MINUTE),
},
{
id: "dpl_9f20",
service: "billing-worker",
commit: "b83e0d9",
author: "Jackson Lee",
state: "building",
at: new Date(NOW - 26 * MINUTE),
},
{
id: "dpl_9f1e",
service: "search-indexer",
commit: "17ab55c",
author: "Isabella Nguyen",
state: "failed",
at: new Date(NOW - 3 * 60 * MINUTE),
},
{
id: "dpl_9f1c",
service: "web",
commit: "e90f314",
author: "William Kim",
state: "live",
at: new Date(NOW - 19 * 60 * MINUTE),
},
]
export default function DataListDemo() {
return (
<div className="w-full overflow-hidden rounded-lg border">
<DataList divided>
{deploys.map((deploy) => {
const state = STATE[deploy.state]
return (
<DataListItem
key={deploy.id}
href={`#${deploy.id}`}
leading={
<span
aria-hidden="true"
className={cn("size-2 rounded-full", state.dot)}
/>
}
title={deploy.service}
description={
<span className="flex items-center gap-1">
<GitCommitVerticalIcon aria-hidden="true" className="size-3.5" />
<span className="font-mono">{deploy.commit}</span>
<span>· {deploy.author}</span>
</span>
}
meta={
<span className="flex items-center gap-2">
<span>{state.label}</span>
<DateCell date={deploy.at} relative />
</span>
}
/>
)
})}
</DataList>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| divided | boolean | false | Draws hairlines between items; the list never draws its own frame. |
| title | React.ReactNode | — | The row's name, truncated when it runs out of room unless wrapTitle says otherwise. |
| wrapTitle | boolean | false | Lets the title run onto a second line rather than clipping it, for a row whose title is the whole point. |
| description | React.ReactNode | — | A quieter second line under the title. |
| meta | React.ReactNode | — | Trails the title block — a timestamp, a version, a count. |
| actions | React.ReactNode | — | Buttons on the far right; leave href and onClick unset when you use it. |
| leading | React.ReactNode | — | Sits before the title — an avatar, a status dot, an icon. |
| href | string | — | Turns the whole row into a link with a hover wash. |
| renderLink | (href: string, props: React.ComponentProps<"a">) => React.ReactNode | — | Renders that link. Left off, the row is a bare anchor, which reloads the document — pass the router's Link for a destination inside the app. |
| onClick | () => void | — | Turns the row into a button, focusable and operable with Enter or Space. |
| selected | boolean | false | Draws the row as a table's selected row — the --brand-muted plane, 500 weight and a 2px accent rail on the inline start — and marks it aria-current, for the current item in a list. It keeps that fill under the pointer; hover is half the muted plane. |
| className | string | — | Merged onto the item root, which is the link, the button, or a plain row. |
Dependencies
Registry
Source
import * as React from "react"
import { cn } from "@/lib/utils"
export type DataListProps = React.ComponentProps<"div"> & {
/** Hairlines between items. Off by default, so a list can sit inside a card that already has a frame. */
divided?: boolean
}
/** A vertical stack of DataListItems — the shape a table takes when it has to fit a narrow column. */
function DataList({ className, divided = false, ...props }: DataListProps) {
return (
<div
data-slot="data-list"
data-divided={divided || undefined}
className={cn("flex w-full flex-col", divided && "[&>*+*]:border-t", className)}
{...props}
/>
)
}
/** Renders the anchor a row becomes, so a router's own Link can stand in for it. */
export type RenderLink = (href: string, props: React.ComponentProps<"a">) => React.ReactNode
// `title` is content here, not the HTML tooltip attribute, and `onClick` takes
// no event, so both replace their DOM counterparts.
export type DataListItemProps = Omit<React.ComponentProps<"div">, "title" | "onClick"> & {
title: React.ReactNode
description?: React.ReactNode
/** Trails the title block — a timestamp, a version, a count. */
meta?: React.ReactNode
/** Buttons on the far right. Leave `href` and `onClick` unset when you use it: a link or a button must not contain another one. */
actions?: React.ReactNode
/** Sits before the title — an avatar, a status dot, an icon. */
leading?: React.ReactNode
/** Turns the whole row into a link. */
href?: string
/** Renders that link. Left off, the row is a bare `<a>`, which reloads the page — pass the router's Link for an in-app destination. */
renderLink?: RenderLink
/** Lets the title run onto a second line rather than clipping it, for a row whose title is the whole point. */
wrapTitle?: boolean
onClick?: () => void
selected?: boolean
}
/** One row of a DataList: leading slot, title and description, trailing meta, then actions. */
function DataListItem({
className,
title,
description,
meta,
actions,
leading,
href,
renderLink,
wrapTitle = false,
onClick,
selected = false,
...props
}: DataListItemProps) {
const interactive = Boolean(href || onClick)
// A link is already keyboard operable; a click handler on a plain row is not,
// so it gets the role, the tab stop, and the two keys that activate a button.
const asButton = Boolean(onClick) && !href
const rowProps = {
"data-slot": "data-list-item",
"data-interactive": interactive || undefined,
"data-wrap-title": wrapTitle || undefined,
"data-selected": selected || undefined,
"aria-current": selected ? ("true" as const) : undefined,
role: asButton ? "button" : undefined,
tabIndex: asButton ? 0 : undefined,
onClick,
onKeyDown: asButton
? (event: React.KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
onClick?.()
}
: undefined,
className: cn(
// The vertical padding is the density token — the same one the table
// cells read, so a list beside a table keeps its rhythm.
"flex items-center gap-3 px-3 py-[var(--density-cell-y,0.75rem)] text-sm transition-colors",
// Hover is the table row's half-muted plane. Selected is the table's
// selected row: the --brand-muted plane, 500 weight and a 2px accent
// rail on the inline start — three cues, where it used to be the hover
// plane alone. Both are keyed to data-selected, whose (0,2,0) selector
// follows hover's in the sheet, so a chosen row keeps its fill under the
// pointer. A shadow has no logical offset, so a right-to-left row turns
// the rail round.
interactive &&
"cursor-pointer hover:bg-muted/50 focus-visible:bg-muted/50 focus-ring-inset",
"data-[selected=true]:bg-brand-muted data-[selected=true]:font-medium data-[selected=true]:shadow-[inset_2px_0_0_var(--brand)] rtl:data-[selected=true]:shadow-[inset_-2px_0_0_var(--brand)]",
className
),
...props,
}
const content = (
<>
{leading ? (
<span
data-slot="data-list-item-leading"
className="flex shrink-0 items-center text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
{leading}
</span>
) : null}
<span data-slot="data-list-item-content" className="flex min-w-0 flex-1 flex-col gap-0.5">
<span
data-slot="data-list-item-title"
className={cn("font-medium", wrapTitle ? "break-words" : "truncate")}
>
{title}
</span>
{description ? (
<span
data-slot="data-list-item-description"
className="truncate text-xs text-muted-foreground"
>
{description}
</span>
) : null}
</span>
{meta ? (
<span
data-slot="data-list-item-meta"
className="shrink-0 text-xs tabular-nums whitespace-nowrap text-muted-foreground"
>
{meta}
</span>
) : null}
{actions ? (
<span data-slot="data-list-item-actions" className="flex shrink-0 items-center gap-1">
{actions}
</span>
) : null}
</>
)
// A caller's Link gets exactly the props the bare anchor would have had,
// children included, so what it renders is the same row.
if (href) {
// The item's props are typed on `div` — that is the root a row without an
// href takes — so the anchor branch says so once, here, rather than making
// every caller choose an element up front.
const anchorProps = { ...rowProps, href, children: content } as React.ComponentProps<"a">
return <>{renderLink ? renderLink(href, anchorProps) : <a {...anchorProps} />}</>
}
return <div {...rowProps}>{content}</div>
}
export { DataList, DataListItem }