Charts
Bar List
A horizontal bar chart for ranked categorical data — each row's translucent brand-teal bar encodes its value relative to the largest entry, styled with the quebi design system.
chartbarrankinglistdata
Default
Each bar's brand-teal width is scaled to the largest value.
/home
/pricing
/docs
/blog
/changelog
8420
5310
4180
2240
980
Formatted values
Pass valueFormatter to render the value column however you like.
/home
/pricing
/docs
/blog
/changelog
8,420
5,310
4,180
2,240
980
Clickable rows
Provide onValueChange to make rows interactive — the bar brightens on hover.
8,420
5,310
4,180
2,240
Linked names
Rows with an href render the name as a sibling Link that opens in a new tab.
Ascending order
/changelog
/blog
/docs
/pricing
/home
980
2,240
4,180
5,310
8,420
Source
Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import { useMemo } from "react"
import { Button } from "react-aria-components"
import { Link } from "@/components/link"
import { cn } from "@/lib/utils"
/**
* BarList — quebi design system
*
* A horizontal bar chart for ranked categorical data. Each row is a
* translucent brand-teal bar whose width encodes its value relative to the
* largest entry, with the value rendered in a fixed column on the right.
* Rows become clickable Buttons when `onValueChange` is provided, and the
* bar brightens on hover. Names with an `href` render as a sibling Link.
*/
type Bar<T> = T & {
key?: string
href?: string
value: number
name: string
}
interface BarListProps<T = unknown> extends React.ComponentProps<"div"> {
data: Bar<T>[]
valueFormatter?: (value: number) => string
onValueChange?: (payload: Bar<T>) => void
sortOrder?: "ascending" | "descending" | "none"
}
export function BarList<T>({
data = [],
valueFormatter = (value) => value.toString(),
onValueChange,
sortOrder = "descending",
className,
ref,
...props
}: BarListProps<T>) {
const Component = onValueChange ? Button : "div"
const sortedData = useMemo(() => {
if (sortOrder === "none") {
return data
}
return [...data].sort((a, b) => {
return sortOrder === "ascending" ? a.value - b.value : b.value - a.value
})
}, [data, sortOrder])
const widths = useMemo(() => {
const maxValue = Math.max(...sortedData.map((item) => item.value), 0)
return sortedData.map((item) =>
item.value === 0 ? 0 : Math.max((item.value / maxValue) * 100, 2),
)
}, [sortedData])
const rowHeight = "h-8"
return (
<div
ref={ref}
data-slot="bar-list"
className={cn("flex justify-between gap-x-6", className)}
{...props}
>
<div className="relative w-full space-y-1.5">
{sortedData.map((item, index) => (
<Component
key={item.key ?? item.name}
onClick={() => {
onValueChange?.(item)
}}
className={cn(
"group w-full rounded-quebi-sm outline-none",
"focus-visible:ring-2 focus-visible:ring-quebi-brand/50 focus-visible:ring-offset-2 focus-visible:ring-offset-quebi-bg",
onValueChange &&
"m-0! cursor-pointer transition-colors duration-150 hover:bg-cyan-500/5",
)}
>
<div
className={cn(
"flex items-center rounded-quebi-sm bg-quebi-brand/15 transition-colors duration-150",
rowHeight,
onValueChange && "group-hover:bg-quebi-brand/25",
index === sortedData.length - 1 && "mb-0",
)}
style={{ width: `${widths[index]}%` }}
>
<div className="absolute start-2 flex max-w-full pe-3 sm:pe-2">
{item.href ? (
<Link
href={item.href}
className="truncate whitespace-nowrap rounded-quebi-sm font-normal text-sm/6 text-white no-underline hover:text-quebi-brand-hover hover:underline hover:underline-offset-2"
target="_blank"
rel="noreferrer"
onClick={(event) => event.stopPropagation()}
>
{item.name}
</Link>
) : (
<p className="truncate whitespace-nowrap text-sm/6 text-white">{item.name}</p>
)}
</div>
</div>
</Component>
))}
</div>
<div>
{sortedData.map((item, index) => (
<div
key={item.key ?? item.name}
className={cn(
"flex items-center justify-end",
rowHeight,
index === sortedData.length - 1 ? "mb-0" : "mb-1.5",
)}
>
<p className="truncate whitespace-nowrap text-quebi-fg-muted text-sm leading-none">
{valueFormatter(item.value)}
</p>
</div>
))}
</div>
</div>
)
}