Bar List

Ranked animated bar list card.

Ranked bars

An animated ranked list.

Visitors
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.json
npx shadcn@latest add https://boardcn.dev/r/bar-list-card.json

npm 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.

components/charts/bar-list-card.tsx
"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

PropTypeDefaultDescription
classNamestring
colorstringA CSS color shared by rows which do not specify their own color.
defaultTabstring
format(value: number) => ReactNode(value: number) => value.toLocaleString("en-US")
itemsBarListItem[]
limitnumber5
metric"share" | "value"share
metricLabelstringVisitors
monobooleanfalseUse the neutral light/dark ink and ignore per-item colors.
onTabChange(id: string) => void
tabsBarListTab[]
titlestring