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-listNeeds the @vibra registry in your components.json — set it up once.
Examples
import { RankList, type RankListItem } from "@/components/ui/rank-list"
const REFERRERS: RankListItem[] = [
{ label: "Search engines", value: 4820 },
{ label: "Social networks", value: 2410 },
{ label: "News aggregators", value: 1180 },
{ label: "Launch directories", value: 640 },
{ label: "Forums", value: 412 },
]
export default function RankListDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-4 panel p-6">
<div className="text-sm font-medium">Top referrers</div>
<RankList items={REFERRERS} />
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | RankListItem[] | — | One row each: label, value, and optionally icon and href. |
| max | number | the largest value | What the bars are measured against, for holding the scale steady across lists. |
| format | (n: number) => string | whole units with separators | Formats the value beside each label. |
| showRank | boolean | true | Numbers 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
Registry
Source
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 }