shadimage
dashboardcn
uibar-listsource: ok · hit

Bar List

A ranked list with proportional bars, for top pages, referrers, or countries.

npx shadcn add "https://dashboardcn.com/r/bar-list.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Files2 · source cached 10/14/2026, 7:37:20 PM

registry/dashboardcn/ui/bar-list.tsx
import * as React from "react"

import { cn } from "@/lib/utils"
import { formatNumber } from "@/registry/dashboardcn/lib/format"

export interface BarListItem {
  name: string
  value: number
  /** Renders the label as a link. */
  href?: string
  icon?: React.ReactNode
  /** Stable key when names can repeat. */
  key?: string
}

export interface BarListProps extends React.ComponentProps<"div"> {
  data: BarListItem[]
  valueFormatter?: (value: number) => string
  sortOrder?: "descending" | "ascending" | "none"
  /** Any CSS color. Defaults to chart-1. */
  color?: string
  /** Show each row's share of the total next to its value. */
  showPercentage?: boolean
  onItemClick?: (item: BarListItem) => void
}

function BarList({
  data,
  valueFormatter = (value) => formatNumber(value, { format: "compact" }),
  sortOrder = "descending",
  color = "var(--chart-1)",
  showPercentage = false,
  onItemClick,
  className,
  ...props
}: BarListProps) {
  const items = React.useMemo(() => {
    if (sortOrder === "none") return data
    const sorted = [...data].sort((a, b) => a.value - b.value)
    return sortOrder === "descending" ? sorted.reverse() : sorted
  }, [data, sortOrder])
  const max = Math.max(...items.map((item) => item.value), 0)
  const total = items.reduce((sum, item) => sum + item.value, 0)

  return (
    <div
      data-slot="bar-list"
      className={cn("flex flex-col gap-1.5", className)}
      {...props}
    >
      {items.map((item) => {
        const width = max > 0 ? (item.value / max) * 100 : 0
        const share = total > 0 ? item.value / total : 0
        const interactive = Boolean(item.href || onItemClick)
        const Label = item.href ? "a" : onItemClick ? "button" : "div"

        return (
          <div
            key={item.key ?? item.name}
            data-slot="bar-list-row"
            className="flex items-center gap-3 text-sm"
          >
            <div className="relative flex h-8 min-w-0 flex-1 items-center">
              <div
                aria-hidden="true"
                className="absolute inset-y-0 left-0 rounded-md transition-[width] duration-300"
                style={{
                  width: `${width}%`,
                  backgroundColor: `color-mix(in oklab, ${color} 20%, transparent)`,
                }}
              />
              <Label
                {...(item.href ? { href: item.href } : {})}
                {...(onItemClick && !item.href
                  ? { type: "button" as const, onClick: () => onItemClick(item) }
                  : {})}
                className={cn(
                  "relative flex min-w-0 items-center gap-2 px-2 text-left",
                  interactive && "hover:underline"
                )}
              >
                {item.icon ? (
                  <span className="text-muted-foreground shrink-0 [&>svg]:size-4">
                    {item.icon}
                  </span>
                ) : null}
                <span className="truncate">{item.name}</span>
              </Label>
            </div>
            <div className="flex shrink-0 items-center gap-2 tabular-nums">
              <span className="font-medium">{valueFormatter(item.value)}</span>
              {showPercentage ? (
                <span className="text-muted-foreground w-12 text-right text-xs">
                  {formatNumber(share, { format: "percent" })}
                </span>
              ) : null}
            </div>
          </div>
        )
      })}
    </div>
  )
}

export { BarList }
registry/dashboardcn/lib/format.ts
export type NumberFormat = "number" | "compact" | "currency" | "percent"

export interface FormatNumberOptions {
  format?: NumberFormat
  /** ISO 4217 code, used when format is "currency". Defaults to USD. */
  currency?: string
  locale?: string
  maximumFractionDigits?: number
  /** Abbreviate large values, e.g. "$158K" instead of "$158,143". */
  compact?: boolean
}

const formatterCache = new Map<string, Intl.NumberFormat>()

function getFormatter(locale: string, options: Intl.NumberFormatOptions) {
  const key = locale + JSON.stringify(options)
  let formatter = formatterCache.get(key)
  if (!formatter) {
    formatter = new Intl.NumberFormat(locale, options)
    formatterCache.set(key, formatter)
  }
  return formatter
}

/**
 * Format a metric value for display.
 *
 * formatNumber(1234567)                          -> "1,234,567"
 * formatNumber(1234567, { format: "compact" })   -> "1.2M"
 * formatNumber(48.2, { format: "currency" })     -> "$48.20"
 * formatNumber(158143, { format: "currency", compact: true }) -> "$158K"
 * formatNumber(0.124, { format: "percent" })     -> "12.4%"
 */
export function formatNumber(
  value: number,
  {
    format = "number",
    currency = "USD",
    locale = "en-US",
    maximumFractionDigits,
    compact = false,
  }: FormatNumberOptions = {}
): string {
  if (!Number.isFinite(value)) return "—"

  // Compact values keep three significant digits: $158K, $1.23M, 41.2K.
  const compactOptions: Intl.NumberFormatOptions = compact
    ? maximumFractionDigits === undefined
      ? { notation: "compact", maximumSignificantDigits: 3 }
      : { notation: "compact", maximumFractionDigits }
    : {}
  switch (format) {
    case "compact":
      return getFormatter(locale, {
        notation: "compact",
        maximumFractionDigits: maximumFractionDigits ?? 1,
      }).format(value)
    case "currency":
      return getFormatter(locale, {
        style: "currency",
        currency,
        maximumFractionDigits: maximumFractionDigits ?? 2,
        ...compactOptions,
      }).format(value)
    case "percent":
      return getFormatter(locale, {
        style: "percent",
        maximumFractionDigits: maximumFractionDigits ?? 1,
      }).format(value)
    default:
      return getFormatter(locale, {
        maximumFractionDigits: maximumFractionDigits ?? 0,
        ...compactOptions,
      }).format(value)
  }
}

/**
 * Format a fractional change as a signed percentage.
 *
 * formatDelta(0.124)  -> "+12.4%"
 * formatDelta(-0.03)  -> "-3.0%"
 * formatDelta(0)      -> "0.0%"
 */
export function formatDelta(delta: number, locale = "en-US"): string {
  if (!Number.isFinite(delta)) return "—"
  return getFormatter(locale, {
    style: "percent",
    signDisplay: "exceptZero",
    maximumFractionDigits: 1,
    minimumFractionDigits: 1,
  }).format(delta)
}

/**
 * Fractional change between two values. Returns 0 when there is no baseline.
 */
export function computeDelta(current: number, previous: number): number {
  if (!previous) return 0
  return (current - previous) / Math.abs(previous)
}

More from dashboardcn

Browse all components in this library →