Skip to contentVibraUI
Metrics

Stat list

A compact list of metrics, each with its value and its change.

Server-compatible: no hooks, no client boundary. Rows are driven by the items array rather than by children, so a whole panel of numbers is one prop. An item with an href becomes a link with a hover and a focus ring; the rest stay plain rows.

Install

npx shadcn@latest add @vibra/stat-list

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

Examples

Props

PropTypeDefaultDescription
itemsStatListItem[]—One row each: label, value, and optionally delta, deltaFormat, positiveIsGood, icon, and href.
size"sm" | "default""default"sm tightens the rows and shrinks the deltas to match.
dividedbooleanfalseSeparates the rows with hairlines.

Dependencies

Source

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

import { cn } from "@/lib/utils"
import { MetricDelta, type MetricDeltaProps } from "@/components/ui/metric-delta"

export type StatListItem = {
  label: React.ReactNode
  /** Already formatted — pair it with the format lib. */
  value: React.ReactNode
  /** Change since the previous period; rendered as a <MetricDelta>. */
  delta?: number
  deltaFormat?: MetricDeltaProps["format"]
  /** False for metrics where down is the win — churn, latency, cost. */
  positiveIsGood?: boolean
  /** Decorative icon before the label. Sized to 4 unless it sets its own size. */
  icon?: React.ReactNode
  /** Turns the row into a link to the metric's own view. */
  href?: string
}

export type StatListProps = React.ComponentProps<"div"> & {
  items: StatListItem[]
  size?: "sm" | "default"
  /** Separates the rows with hairlines. */
  divided?: boolean
}

function StatList({
  className,
  items,
  size = "default",
  divided = false,
  ...props
}: StatListProps) {
  return (
    <div
      data-slot="stat-list"
      data-size={size}
      data-divided={divided || undefined}
      className={cn("flex w-full flex-col", divided && "divide-y", className)}
      {...props}
    >
      {items.map((item, index) => {
        const rowClassName = cn(
          "flex items-center gap-3",
          size === "sm" ? "py-2 text-xs" : "py-2.5 text-sm",
          item.href &&
            "rounded-md transition-colors hover:bg-muted/50 focus-ring"
        )

        const row = (
          <>
            {item.icon ? (
              <span
                data-slot="stat-list-icon"
                className="shrink-0 text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
              >
                {item.icon}
              </span>
            ) : null}
            <span
              data-slot="stat-list-label"
              className="min-w-0 flex-1 truncate text-muted-foreground"
            >
              {item.label}
            </span>
            <span data-slot="stat-list-value" className="shrink-0 font-medium tabular-nums">
              {item.value}
            </span>
            {item.delta !== undefined ? (
              <MetricDelta
                value={item.delta}
                format={item.deltaFormat}
                positiveIsGood={item.positiveIsGood}
                size={size}
                className="min-w-14 justify-end"
              />
            ) : null}
          </>
        )

        // Two tags rather than a computed one, so the href stays typed as an
        // anchor's own prop.
        return item.href ? (
          <a key={index} data-slot="stat-list-item" href={item.href} className={rowClassName}>
            {row}
          </a>
        ) : (
          <div key={index} data-slot="stat-list-item" className={rowClassName}>
            {row}
          </div>
        )
      })}
    </div>
  )
}

export { StatList }