Skip to contentVibraUI
Metrics

Rank list

A leaderboard of values, each on a bar scaled against the top entry.

Server-compatible: no hooks, no client boundary. The bars rank one measure rather than categorise, so the whole list takes a single palette token, imported from percentage-bar as CHART_BG and narrowed with isChartToken so an unrecognised colour falls back to the first token instead of painting nothing. Every value is printed beside its bar, so the ranking never depends on comparing lengths by eye.

Install

npx shadcn@latest add @vibra/rank-list

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

Examples

Props

PropTypeDefaultDescription
itemsRankListItem[]—One row each: label, value, and optionally icon and href.
maxnumberthe largest valueWhat the bars are measured against, for holding the scale steady across lists.
format(n: number) => stringwhole units with separatorsFormats the value beside each label.
showRankbooleantrueNumbers the rows down the left.
color"chart-1" | "chart-2" | "chart-3" | "chart-4" | "chart-5" | "chart-6" | "chart-7" | "chart-8""chart-1"The palette token every bar in the list is painted with.
size"sm" | "default""default"sm tightens the rows and thins the bars to h-1.

Dependencies

Source

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

import { cn } from "@/lib/utils"
import { clamp, formatNumber, percentOf } from "@/lib/format"
import { CHART_BG, isChartToken, type ChartToken } from "@/components/ui/percentage-bar"

const DEFAULT_FORMAT = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })

export type RankListItem = {
  label: React.ReactNode
  value: number
  /** Decorative icon before the label — a favicon, a channel glyph. */
  icon?: React.ReactNode
  /** Turns the row into a link to the entry's own view. */
  href?: string
}

export type RankListProps = React.ComponentProps<"div"> & {
  items: RankListItem[]
  /** What the bars are measured against. Defaults to the largest value in the list. */
  max?: number
  format?: (n: number) => string
  showRank?: boolean
  /** One palette token for the whole list — the bars rank a single measure, they do not categorise. */
  color?: ChartToken
  size?: "sm" | "default"
}

function RankList({
  className,
  items,
  max,
  format = DEFAULT_FORMAT,
  showRank = true,
  color = "chart-1",
  size = "default",
  ...props
}: RankListProps) {
  const scale = max ?? Math.max(0, ...items.map((item) => item.value))

  return (
    <div
      data-slot="rank-list"
      data-size={size}
      data-color={color}
      className={cn("flex w-full flex-col", size === "sm" ? "gap-2.5" : "gap-3", className)}
      {...props}
    >
      {items.map((item, index) => {
        const rowClassName = cn(
          "flex items-center gap-3",
          item.href &&
            "rounded-md transition-colors hover:bg-muted/50 focus-ring"
        )

        const row = (
          <>
            {showRank ? (
              <span
                data-slot="rank-list-rank"
                className={cn(
                  "w-4 shrink-0 text-end text-muted-foreground tabular-nums",
                  size === "sm" ? "text-xs" : "text-sm"
                )}
              >
                {index + 1}
              </span>
            ) : null}

            <div className="flex min-w-0 flex-1 flex-col gap-1.5">
              <div
                className={cn(
                  "flex items-baseline justify-between gap-3",
                  size === "sm" ? "text-xs" : "text-sm"
                )}
              >
                <span data-slot="rank-list-label" className="flex min-w-0 items-baseline gap-2">
                  {item.icon ? (
                    <span
                      data-slot="rank-list-icon"
                      className="shrink-0 self-center text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
                    >
                      {item.icon}
                    </span>
                  ) : null}
                  <span className="min-w-0 truncate">{item.label}</span>
                </span>
                <span
                  data-slot="rank-list-value"
                  className="shrink-0 font-medium tabular-nums"
                >
                  {format(item.value)}
                </span>
              </div>

              <div
                data-slot="rank-list-track"
                aria-hidden="true"
                className={cn(
                  "w-full overflow-hidden rounded-full bg-muted",
                  size === "sm" ? "h-1" : "h-1.5"
                )}
              >
                <div
                  data-slot="rank-list-bar"
                  // Guarded rather than indexed straight into the map: a
                  // dynamic or untyped colour that happens to name something on
                  // Object's prototype would otherwise come back with no class
                  // at all and paint an invisible bar.
                  className={cn(
                    "h-full rounded-full",
                    CHART_BG[isChartToken(color) ? color : "chart-1"]
                  )}
                  style={{
                    width: `${Math.round(clamp(percentOf(item.value, scale), 0, 100) * 100) / 100}%`,
                  }}
                />
              </div>
            </div>
          </>
        )

        // 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="rank-list-item" href={item.href} className={rowClassName}>
            {row}
          </a>
        ) : (
          <div key={index} data-slot="rank-list-item" className={rowClassName}>
            {row}
          </div>
        )
      })}
    </div>
  )
}

export { RankList }