Skip to content
ssntl/ui

List

Rows split by hairlines inset to the text, with a tabular trailing value.

Top customers

  • Ana López

    Studio Norte

    Q 4,820.00

  • Marco Ruiz

    Café Lumen

    Q 1,265.50

  • Sofía García

    sofia@ferreteria.gt

    Q 312.00

  • Diego Castillo

    Casa Barrios

    Q 98.75

Installation

$ pnpm dlx shadcn@latest add @ssntl/list

Usage

tsx
import { List, ListItem, ListItemContent, ListItemMedia, ListItemMeta, ListItemTitle, ListItemValue } from "@/components/ui/list"

<List>
  <ListItem>
    <ListItemMedia aria-hidden>AL</ListItemMedia>
    <ListItemContent>
      <ListItemTitle>Ana López</ListItemTitle>
      <ListItemMeta>Studio Norte</ListItemMeta>
    </ListItemContent>
    <ListItemValue>Q 4,820.00</ListItemValue>
  </ListItem>
</List>
  • Section separators: a 1px border-token line between rows, so a list of similar rows reads as separate items.
  • The line starts where the text does (56px with media), keeping the avatars in one clean column.
  • Values align right with tabular figures and the same precision.
  • Long names truncate on one line; put the full value in the detail view.

Code

components/ui/list.tsx
import * as React from "react"

import { cn } from "@/lib/utils"

// A list of rows split by hairlines. The divider starts where the text does,
// past the media, so rows read as one block and the faces or icons stay in
// one clean column. Dividers are structure, the one place a border belongs.
function List({ className, ...props }: React.ComponentProps<"ul">) {
  return <ul data-slot="list" className={cn("grid", className)} {...props} />
}

function ListItem({ className, ...props }: React.ComponentProps<"li">) {
  return (
    <li
      data-slot="list-item"
      className={cn(
        "relative flex min-h-16 items-center gap-4 py-3",
        // Every row after the first draws a 1px line on top. With media it
        // starts at 56px (40 media + 16 gap), where the text starts.
        "not-first:before:absolute not-first:before:inset-x-0 not-first:before:top-0 not-first:before:h-px not-first:before:bg-border has-data-[slot=list-item-media]:not-first:before:start-14",
        className
      )}
      {...props}
    />
  )
}

/** A 40px avatar or icon well; decorative unless it carries meaning. */
function ListItemMedia({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="list-item-media"
      className={cn(
        "grid size-10 shrink-0 place-items-center overflow-hidden rounded-full bg-secondary text-caption font-medium text-foreground [&_svg]:size-5",
        className
      )}
      {...props}
    />
  )
}

function ListItemContent({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="list-item-content"
      className={cn("grid min-w-0 flex-1 gap-0.5", className)}
      {...props}
    />
  )
}

function ListItemTitle({ className, ...props }: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="list-item-title"
      className={cn("truncate text-label", className)}
      {...props}
    />
  )
}

function ListItemMeta({ className, ...props }: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="list-item-meta"
      className={cn("truncate text-caption text-muted-foreground", className)}
      {...props}
    />
  )
}

/** A trailing number: right-aligned with tabular figures, so values line up. */
function ListItemValue({ className, ...props }: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="list-item-value"
      className={cn("shrink-0 text-end text-label tabular-nums", className)}
      {...props}
    />
  )
}

export {
  List,
  ListItem,
  ListItemContent,
  ListItemMedia,
  ListItemMeta,
  ListItemTitle,
  ListItemValue,
}