Skip to contentVibraUI
Data display

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-list

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
dividedbooleanfalseDraws hairlines between items; the list never draws its own frame.
titleReact.ReactNode—The row's name, truncated when it runs out of room unless wrapTitle says otherwise.
wrapTitlebooleanfalseLets the title run onto a second line rather than clipping it, for a row whose title is the whole point.
descriptionReact.ReactNode—A quieter second line under the title.
metaReact.ReactNode—Trails the title block — a timestamp, a version, a count.
actionsReact.ReactNode—Buttons on the far right; leave href and onClick unset when you use it.
leadingReact.ReactNode—Sits before the title — an avatar, a status dot, an icon.
hrefstring—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.
selectedbooleanfalseDraws 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.
classNamestring—Merged onto the item root, which is the link, the button, or a plain row.

Dependencies

Registry

Source

components/ui/data-list.tsx
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 }