Bar List
Ranked animated bar list card.
Ranked bars
An animated ranked list.
Visitors
Devices
Browsers
Operating systems
Screen sizes
Desktop61%
Mobile31%
Tablet8%
function BarListDemo() {
return <BarListCard tabs={BAR_LIST_TABS} />;
}function BarListDemo() {
return <BarListCard tabs={BAR_LIST_TABS} />;
}Installation
npx shadcn@latest add https://boardcn.dev/r/bar-list-card.jsonnpx shadcn@latest add https://boardcn.dev/r/bar-list-card.jsonnpm packages
- @remixicon/react
- motion
BoardCN dependencies
The CLI installs these for you — you do not need to add them yourself.
Source
The file the CLI copies into your project.
"use client";
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { RiArrowDownSLine } from "@remixicon/react";
import { AnimatePresence, motion } from "motion/react";
import { Tab, TabList, TabPanel, Tabs } from "@/components/base/tabs/tabs";
import { usePrefersReducedMotion } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";
export type BarListMetric = "share" | "value";
export type BarListItem = {
label: string;
value: number;
icon?: ReactNode;
/** A CSS color used for this row instead of the card tone. */
color?: string;
};
export type BarListTab = {
id: string;
label: string;
items: BarListItem[];
};
type BarListCardBaseProps = {
title?: string;
metricLabel?: string;
metric?: BarListMetric;
format?: (value: number) => ReactNode;
/** A CSS color shared by rows which do not specify their own color. */
color?: string;
/** Use the neutral light/dark ink and ignore per-item colors. */
mono?: boolean;
limit?: number;
defaultTab?: string;
onTabChange?: (id: string) => void;
className?: string;
};
export type BarListCardProps = BarListCardBaseProps &
(
| { tabs: BarListTab[]; items?: never }
| { items: BarListItem[]; tabs?: never }
);
type Tone = {
color: string;
activeColor: string;
};
const CHART_TONES: Tone[] = [2, 6, 5, 3, 8, 7, 4, 1].map((tone) => ({
color: `var(--color-chart-${tone})`,
activeColor: `var(--color-chart-${tone}-active)`,
}));
const MONO_TONE: Tone = {
color:
"light-dark(var(--color-neutral-500), color-mix(in srgb, var(--color-neutral-50) 84%, transparent))",
activeColor: "light-dark(var(--color-neutral-600), var(--color-neutral-50))",
};
const EXPAND_EASING = [0.22, 1, 0.36, 1] as const;
function resolveTone(index: number, color?: string, activeColor?: string): Tone {
if (color) {
return {
color,
activeColor: activeColor ?? `color-mix(in srgb, ${color} 82%, black)`,
};
}
return CHART_TONES[index % CHART_TONES.length];
}
function useMountedBars() {
const reducedMotion = usePrefersReducedMotion();
const [mounted, setMounted] = useState(reducedMotion);
useEffect(() => {
if (reducedMotion) {
setMounted(true);
return;
}
const timeout = window.setTimeout(() => setMounted(true), 60);
return () => window.clearTimeout(timeout);
}, [reducedMotion]);
return mounted;
}
type ScrollFades = { left: boolean; right: boolean };
function useScrollFades() {
const ref = useRef<HTMLDivElement>(null);
const [fades, setFades] = useState<ScrollFades>({ left: false, right: false });
useEffect(() => {
const element = ref.current;
if (!element) return;
const update = () => {
const hiddenWidth = element.scrollWidth - element.clientWidth;
setFades({
left: element.scrollLeft > 1,
right: hiddenWidth - element.scrollLeft > 1,
});
};
update();
element.addEventListener("scroll", update, { passive: true });
const observer = new ResizeObserver(update);
observer.observe(element);
return () => {
element.removeEventListener("scroll", update);
observer.disconnect();
};
}, []);
return { ref, fades };
}
function horizontalMask(fades: ScrollFades, width = 28) {
if (!fades.left && !fades.right) return undefined;
const left = fades.left ? `transparent, black ${width}px` : "black";
const right = fades.right
? `black calc(100% - ${width}px), transparent`
: "black";
return `linear-gradient(to right, ${left}, ${right})`;
}
function formatShare(value: number, total: number) {
if (total <= 0 || value <= 0) return "0%";
const share = (value / total) * 100;
return share < 0.5 ? "<0.5%" : `${Math.round(share)}%`;
}
type BarRowsProps = {
items: BarListItem[];
metric: BarListMetric;
format: (value: number) => ReactNode;
tone: Tone;
mono: boolean;
limit: number;
mounted: boolean;
};
function BarRows({ items, metric, format, tone, mono, limit, mounted }: BarRowsProps) {
const reducedMotion = usePrefersReducedMotion();
const [expanded, setExpanded] = useState(false);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const total = items.reduce((sum, item) => sum + item.value, 0);
const maxValue = Math.max(1, ...items.map((item) => item.value));
const hasOverflow = items.length > limit;
const visibleItems = hasOverflow ? items.slice(0, limit) : items;
const overflowItems = hasOverflow ? items.slice(limit) : [];
const renderRow = (item: BarListItem, index: number) => {
const active = hoveredIndex === index;
const rowTone = item.color && !mono ? resolveTone(index, item.color) : tone;
const backgroundColor = `color-mix(in srgb, ${
active ? rowTone.activeColor : rowTone.color
} ${active ? 26 : 14}%, transparent)`;
return (
<div
key={item.label}
className="relative flex h-9 items-center justify-between gap-3 rounded-lg px-2.5"
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
>
<div
aria-hidden
className="absolute inset-y-0 left-0 rounded-lg transition-[width,background-color] duration-500 ease-out motion-reduce:transition-none"
style={{
width: mounted ? `${(item.value / maxValue) * 100}%` : 0,
backgroundColor,
}}
/>
<span className="relative flex min-w-0 items-center gap-2">
{item.icon && (
<span className="flex size-4 shrink-0 items-center justify-center">{item.icon}</span>
)}
<span className="truncate text-body-regular text-text-primary">{item.label}</span>
</span>
<span className="relative shrink-0 text-body-medium text-text-primary tabular-nums">
{metric === "share" ? formatShare(item.value, total) : format(item.value)}
</span>
</div>
);
};
return (
<div className="relative -mx-2 -mb-1 flex flex-col">
<div
className="flex flex-col gap-1"
style={
hasOverflow && !expanded
? { maskImage: "linear-gradient(to bottom, black calc(100% - 44px), transparent)" }
: undefined
}
>
{visibleItems.map(renderRow)}
</div>
<AnimatePresence initial={false}>
{expanded && hasOverflow && (
<motion.div
key="rest"
initial={reducedMotion ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: reducedMotion ? 0 : 0.3, ease: EXPAND_EASING }}
className="overflow-hidden"
data-testid="bar-list-overflow"
>
<div className="flex flex-col gap-1 pt-1">
{overflowItems.map((item, index) => renderRow(item, limit + index))}
</div>
</motion.div>
)}
</AnimatePresence>
{hasOverflow && (
<button
type="button"
onClick={() => setExpanded((value) => !value)}
aria-label={expanded ? "Show fewer" : `Show ${items.length - limit} more`}
className={cx(
"absolute left-1/2 flex h-5 w-10 -translate-x-1/2 cursor-pointer items-center justify-center rounded-full",
"border border-border-button-default bg-background-primary-default shadow-xs outline-none",
"transition-colors duration-150 hover:bg-background-primary-hover focus-visible:ring-2 focus-visible:ring-border-focus-ring motion-reduce:transition-none",
expanded ? "-bottom-2" : "bottom-1",
)}
>
<RiArrowDownSLine
className={cx(
"size-3.5 text-text-secondary transition-transform duration-200 ease-out motion-reduce:transition-none",
expanded && "rotate-180",
)}
aria-hidden
/>
</button>
)}
</div>
);
}
const defaultFormat = (value: number) => value.toLocaleString("en-US");
export function BarListCard({
tabs,
items,
title,
metricLabel = "Visitors",
metric = "share",
format = defaultFormat,
color,
mono = false,
limit = 5,
defaultTab,
onTabChange,
className,
}: BarListCardProps) {
const mounted = useMountedBars();
const { ref: tabScrollerRef, fades } = useScrollFades();
const tone = mono ? MONO_TONE : resolveTone(1, color);
const lists = tabs ?? [{ id: "list", label: title ?? "Breakdown", items: items ?? [] }];
const [selectedTab, setSelectedTab] = useState(defaultTab ?? lists[0]?.id ?? "list");
const singleList = lists.length === 1 && !tabs;
const metricCaption = (
<span className="shrink-0 pb-2.5 text-caption-1-medium tracking-[0.06em] text-text-tertiary uppercase">
{metricLabel}
</span>
);
return (
<section
className={cx(
"flex w-full min-w-0 flex-col rounded-2xl bg-background-secondary-default px-4 pt-1 pb-3",
className,
)}
>
{singleList ? (
<>
<div className="-mx-4 mb-3 flex items-end justify-between gap-3 border-b border-separator-border px-4">
<span className="px-2.5 py-2 text-body-medium text-text-primary">
{lists[0].label}
</span>
{metricCaption}
</div>
<BarRows
items={lists[0].items}
metric={metric}
format={format}
tone={tone}
mono={mono}
limit={limit}
mounted={mounted}
/>
</>
) : (
<Tabs
selectedKey={selectedTab}
onSelectionChange={(key) => {
const id = String(key);
setSelectedTab(id);
onTabChange?.(id);
}}
className="gap-3"
>
<div className="-mx-4 flex items-end justify-between gap-3 border-b border-separator-border px-4">
<div
ref={tabScrollerRef}
className="min-w-0 flex-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
style={{
maskImage: horizontalMask(fades),
WebkitMaskImage: horizontalMask(fades),
}}
>
<TabList aria-label="Breakdown" className="w-max min-w-full border-b-0">
{lists.map((list) => (
<Tab key={list.id} id={list.id}>
{list.label}
</Tab>
))}
</TabList>
</div>
{metricCaption}
</div>
{lists.map((list) => (
<TabPanel key={list.id} id={list.id}>
<BarRows
items={list.items}
metric={metric}
format={format}
tone={tone}
mono={mono}
limit={limit}
mounted={mounted}
/>
</TabPanel>
))}
</Tabs>
)}
</section>
);
}"use client";
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { RiArrowDownSLine } from "@remixicon/react";
import { AnimatePresence, motion } from "motion/react";
import { Tab, TabList, TabPanel, Tabs } from "@/components/base/tabs/tabs";
import { usePrefersReducedMotion } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";
export type BarListMetric = "share" | "value";
export type BarListItem = {
label: string;
value: number;
icon?: ReactNode;
/** A CSS color used for this row instead of the card tone. */
color?: string;
};
export type BarListTab = {
id: string;
label: string;
items: BarListItem[];
};
type BarListCardBaseProps = {
title?: string;
metricLabel?: string;
metric?: BarListMetric;
format?: (value: number) => ReactNode;
/** A CSS color shared by rows which do not specify their own color. */
color?: string;
/** Use the neutral light/dark ink and ignore per-item colors. */
mono?: boolean;
limit?: number;
defaultTab?: string;
onTabChange?: (id: string) => void;
className?: string;
};
export type BarListCardProps = BarListCardBaseProps &
(
| { tabs: BarListTab[]; items?: never }
| { items: BarListItem[]; tabs?: never }
);
type Tone = {
color: string;
activeColor: string;
};
const CHART_TONES: Tone[] = [2, 6, 5, 3, 8, 7, 4, 1].map((tone) => ({
color: `var(--color-chart-${tone})`,
activeColor: `var(--color-chart-${tone}-active)`,
}));
const MONO_TONE: Tone = {
color:
"light-dark(var(--color-neutral-500), color-mix(in srgb, var(--color-neutral-50) 84%, transparent))",
activeColor: "light-dark(var(--color-neutral-600), var(--color-neutral-50))",
};
const EXPAND_EASING = [0.22, 1, 0.36, 1] as const;
function resolveTone(index: number, color?: string, activeColor?: string): Tone {
if (color) {
return {
color,
activeColor: activeColor ?? `color-mix(in srgb, ${color} 82%, black)`,
};
}
return CHART_TONES[index % CHART_TONES.length];
}
function useMountedBars() {
const reducedMotion = usePrefersReducedMotion();
const [mounted, setMounted] = useState(reducedMotion);
useEffect(() => {
if (reducedMotion) {
setMounted(true);
return;
}
const timeout = window.setTimeout(() => setMounted(true), 60);
return () => window.clearTimeout(timeout);
}, [reducedMotion]);
return mounted;
}
type ScrollFades = { left: boolean; right: boolean };
function useScrollFades() {
const ref = useRef<HTMLDivElement>(null);
const [fades, setFades] = useState<ScrollFades>({ left: false, right: false });
useEffect(() => {
const element = ref.current;
if (!element) return;
const update = () => {
const hiddenWidth = element.scrollWidth - element.clientWidth;
setFades({
left: element.scrollLeft > 1,
right: hiddenWidth - element.scrollLeft > 1,
});
};
update();
element.addEventListener("scroll", update, { passive: true });
const observer = new ResizeObserver(update);
observer.observe(element);
return () => {
element.removeEventListener("scroll", update);
observer.disconnect();
};
}, []);
return { ref, fades };
}
function horizontalMask(fades: ScrollFades, width = 28) {
if (!fades.left && !fades.right) return undefined;
const left = fades.left ? `transparent, black ${width}px` : "black";
const right = fades.right
? `black calc(100% - ${width}px), transparent`
: "black";
return `linear-gradient(to right, ${left}, ${right})`;
}
function formatShare(value: number, total: number) {
if (total <= 0 || value <= 0) return "0%";
const share = (value / total) * 100;
return share < 0.5 ? "<0.5%" : `${Math.round(share)}%`;
}
type BarRowsProps = {
items: BarListItem[];
metric: BarListMetric;
format: (value: number) => ReactNode;
tone: Tone;
mono: boolean;
limit: number;
mounted: boolean;
};
function BarRows({ items, metric, format, tone, mono, limit, mounted }: BarRowsProps) {
const reducedMotion = usePrefersReducedMotion();
const [expanded, setExpanded] = useState(false);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const total = items.reduce((sum, item) => sum + item.value, 0);
const maxValue = Math.max(1, ...items.map((item) => item.value));
const hasOverflow = items.length > limit;
const visibleItems = hasOverflow ? items.slice(0, limit) : items;
const overflowItems = hasOverflow ? items.slice(limit) : [];
const renderRow = (item: BarListItem, index: number) => {
const active = hoveredIndex === index;
const rowTone = item.color && !mono ? resolveTone(index, item.color) : tone;
const backgroundColor = `color-mix(in srgb, ${
active ? rowTone.activeColor : rowTone.color
} ${active ? 26 : 14}%, transparent)`;
return (
<div
key={item.label}
className="relative flex h-9 items-center justify-between gap-3 rounded-lg px-2.5"
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
>
<div
aria-hidden
className="absolute inset-y-0 left-0 rounded-lg transition-[width,background-color] duration-500 ease-out motion-reduce:transition-none"
style={{
width: mounted ? `${(item.value / maxValue) * 100}%` : 0,
backgroundColor,
}}
/>
<span className="relative flex min-w-0 items-center gap-2">
{item.icon && (
<span className="flex size-4 shrink-0 items-center justify-center">{item.icon}</span>
)}
<span className="truncate text-body-regular text-text-primary">{item.label}</span>
</span>
<span className="relative shrink-0 text-body-medium text-text-primary tabular-nums">
{metric === "share" ? formatShare(item.value, total) : format(item.value)}
</span>
</div>
);
};
return (
<div className="relative -mx-2 -mb-1 flex flex-col">
<div
className="flex flex-col gap-1"
style={
hasOverflow && !expanded
? { maskImage: "linear-gradient(to bottom, black calc(100% - 44px), transparent)" }
: undefined
}
>
{visibleItems.map(renderRow)}
</div>
<AnimatePresence initial={false}>
{expanded && hasOverflow && (
<motion.div
key="rest"
initial={reducedMotion ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: reducedMotion ? 0 : 0.3, ease: EXPAND_EASING }}
className="overflow-hidden"
data-testid="bar-list-overflow"
>
<div className="flex flex-col gap-1 pt-1">
{overflowItems.map((item, index) => renderRow(item, limit + index))}
</div>
</motion.div>
)}
</AnimatePresence>
{hasOverflow && (
<button
type="button"
onClick={() => setExpanded((value) => !value)}
aria-label={expanded ? "Show fewer" : `Show ${items.length - limit} more`}
className={cx(
"absolute left-1/2 flex h-5 w-10 -translate-x-1/2 cursor-pointer items-center justify-center rounded-full",
"border border-border-button-default bg-background-primary-default shadow-xs outline-none",
"transition-colors duration-150 hover:bg-background-primary-hover focus-visible:ring-2 focus-visible:ring-border-focus-ring motion-reduce:transition-none",
expanded ? "-bottom-2" : "bottom-1",
)}
>
<RiArrowDownSLine
className={cx(
"size-3.5 text-text-secondary transition-transform duration-200 ease-out motion-reduce:transition-none",
expanded && "rotate-180",
)}
aria-hidden
/>
</button>
)}
</div>
);
}
const defaultFormat = (value: number) => value.toLocaleString("en-US");
export function BarListCard({
tabs,
items,
title,
metricLabel = "Visitors",
metric = "share",
format = defaultFormat,
color,
mono = false,
limit = 5,
defaultTab,
onTabChange,
className,
}: BarListCardProps) {
const mounted = useMountedBars();
const { ref: tabScrollerRef, fades } = useScrollFades();
const tone = mono ? MONO_TONE : resolveTone(1, color);
const lists = tabs ?? [{ id: "list", label: title ?? "Breakdown", items: items ?? [] }];
const [selectedTab, setSelectedTab] = useState(defaultTab ?? lists[0]?.id ?? "list");
const singleList = lists.length === 1 && !tabs;
const metricCaption = (
<span className="shrink-0 pb-2.5 text-caption-1-medium tracking-[0.06em] text-text-tertiary uppercase">
{metricLabel}
</span>
);
return (
<section
className={cx(
"flex w-full min-w-0 flex-col rounded-2xl bg-background-secondary-default px-4 pt-1 pb-3",
className,
)}
>
{singleList ? (
<>
<div className="-mx-4 mb-3 flex items-end justify-between gap-3 border-b border-separator-border px-4">
<span className="px-2.5 py-2 text-body-medium text-text-primary">
{lists[0].label}
</span>
{metricCaption}
</div>
<BarRows
items={lists[0].items}
metric={metric}
format={format}
tone={tone}
mono={mono}
limit={limit}
mounted={mounted}
/>
</>
) : (
<Tabs
selectedKey={selectedTab}
onSelectionChange={(key) => {
const id = String(key);
setSelectedTab(id);
onTabChange?.(id);
}}
className="gap-3"
>
<div className="-mx-4 flex items-end justify-between gap-3 border-b border-separator-border px-4">
<div
ref={tabScrollerRef}
className="min-w-0 flex-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
style={{
maskImage: horizontalMask(fades),
WebkitMaskImage: horizontalMask(fades),
}}
>
<TabList aria-label="Breakdown" className="w-max min-w-full border-b-0">
{lists.map((list) => (
<Tab key={list.id} id={list.id}>
{list.label}
</Tab>
))}
</TabList>
</div>
{metricCaption}
</div>
{lists.map((list) => (
<TabPanel key={list.id} id={list.id}>
<BarRows
items={list.items}
metric={metric}
format={format}
tone={tone}
mono={mono}
limit={limit}
mounted={mounted}
/>
</TabPanel>
))}
</Tabs>
)}
</section>
);
}Props
Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.
BarListCard
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | — |
| color | string | — | A CSS color shared by rows which do not specify their own color. |
| defaultTab | string | — | — |
| format | (value: number) => ReactNode | (value: number) => value.toLocaleString("en-US") | — |
| items | BarListItem[] | — | — |
| limit | number | 5 | — |
| metric | "share" | "value" | share | — |
| metricLabel | string | Visitors | — |
| mono | boolean | false | Use the neutral light/dark ink and ignore per-item colors. |
| onTabChange | (id: string) => void | — | — |
| tabs | BarListTab[] | — | — |
| title | string | — | — |