Siteplane UI

Bar List

Rank labeled values with a bar for each, relative to the largest.

Markdown
  • /pricing
    8,420
  • /docs/installation
    5,160
  • /blog/release-notes
    2,380
  • /changelog
    1,240
  • /about
    615
  • /careers
    48
"use client";

import { BarList } from "@/siteplane/components/bar-list";

const pages = [
  { id: "pricing", label: "/pricing", value: 8420 },
  { id: "installation", label: "/docs/installation", value: 5160 },
  { id: "release-notes", label: "/blog/release-notes", value: 2380 },
  { id: "changelog", label: "/changelog", value: 1240 },
  { id: "about", label: "/about", value: 615 },
  { id: "careers", label: "/careers", value: 48 },
];

export default function Example() {
  return (
    <div className="w-full max-w-md">
      <BarList data={pages} label="Top pages by visitors" />
    </div>
  );
}

A bar list ranks a short list of labeled values, such as top pages or traffic sources. Each row writes its label over a bar and its value at the end. The largest value fills the track and every other bar takes its share of it, so the rows compare at a glance. The rows keep the order of data, so sort the data to rank it.

Installation

pnpm dlx shadcn@4.18.0 add @siteplane/bar-list

Complete the one-time setup first. Registry JSON lists source files and dependencies.

For this page’s examples, also install:

pnpm dlx shadcn@4.18.0 add @siteplane/toggle-group

Usage

import { BarList } from "@/siteplane/components/bar-list";

<BarList
  label="Top pages"
  data={[
    { id: "pricing", label: "/pricing", value: 8420 },
    { id: "installation", label: "/docs/installation", value: 5160 },
  ]}
/>;

Formatted values

Values are written as numbers in the format of locale, en-US by default, such as 8,420. Pass formatValue to write them another way, such as a currency or with a unit. A value with digits rolls to its new value when the data changes; text without digits is written as it is.

  • United States
    $48,250
  • Germany
    $21,480
  • United Kingdom
    $17,935
  • Canada
    $9,120
  • Australia
    $6,340
"use client";

import { BarList } from "@/siteplane/components/bar-list";

const revenue = [
  { id: "us", label: "United States", value: 48250 },
  { id: "de", label: "Germany", value: 21480 },
  { id: "gb", label: "United Kingdom", value: 17935 },
  { id: "ca", label: "Canada", value: 9120 },
  { id: "au", label: "Australia", value: 6340 },
];

const dollars = new Intl.NumberFormat("en-US", {
  currency: "USD",
  maximumFractionDigits: 0,
  style: "currency",
});

export default function Example() {
  return (
    <div className="w-full max-w-md">
      <BarList
        data={revenue}
        formatValue={(value) => dollars.format(value)}
        label="Revenue by country"
      />
    </div>
  );
}

Icons

An icon sits before the label of its row. Pass an svg element, such as a lucide icon, to take the icon size of the kit. The icon is decoration and hidden from assistive technology, so the label must name the row on its own.

  • Search
    5,210
  • Direct
    3,480
  • Social
    1,265
  • Email
    840
  • Referral
    415
"use client";

import {
  GlobeIcon,
  LinkIcon,
  MailIcon,
  SearchIcon,
  Share2Icon,
} from "lucide-react";
import { BarList } from "@/siteplane/components/bar-list";

const sources = [
  { icon: <SearchIcon />, id: "search", label: "Search", value: 5210 },
  { icon: <GlobeIcon />, id: "direct", label: "Direct", value: 3480 },
  { icon: <Share2Icon />, id: "social", label: "Social", value: 1265 },
  { icon: <MailIcon />, id: "email", label: "Email", value: 840 },
  { icon: <LinkIcon />, id: "referral", label: "Referral", value: 415 },
];

export default function Example() {
  return (
    <div className="w-full max-w-md">
      <BarList data={sources} label="Traffic sources" />
    </div>
  );
}

Second line

A description adds a quieter second line under the label, such as the path under a page title. It is set in 13 px text in the secondary text color, which stays readable on the bar in both themes, and its row and bar grow to hold it while the value stays on the first line. Like the label, it stays on one line: a text that is too long ends in an ellipsis, and its title attribute keeps the full text, which the browser shows on hover.

  • Pricing/pricing
    8,420
  • Installation/docs/installation
    5,160
  • Release notes for the October update and every change in it/blog/release-notes-for-the-october-update-and-every-change
    2,380
  • Changelog/changelog
    1,240
  • About us/about
    615
"use client";

import { BarList } from "@/siteplane/components/bar-list";

const pages = [
  { description: "/pricing", id: "pricing", label: "Pricing", value: 8420 },
  {
    description: "/docs/installation",
    id: "installation",
    label: "Installation",
    value: 5160,
  },
  {
    description: "/blog/release-notes-for-the-october-update-and-every-change",
    id: "release-notes",
    label: "Release notes for the October update and every change in it",
    value: 2380,
  },
  {
    description: "/changelog",
    id: "changelog",
    label: "Changelog",
    value: 1240,
  },
  { description: "/about", id: "about", label: "About us", value: 615 },
];

export default function Example() {
  return (
    <div className="w-full max-w-md">
      <BarList data={pages} label="Top pages by visitors" />
    </div>
  );
}

Heading

Pass heading to name the two columns in a row above the list: its label stands over the labels and its value over the values, and a Separator runs between the heading and the rows. The heading is text that screen readers read before the list.

SourceVisitors
  • Search
    5,210
  • Direct
    3,480
  • Social
    1,265
  • Email
    840
  • Referral
    415
"use client";

import {
  GlobeIcon,
  LinkIcon,
  MailIcon,
  SearchIcon,
  Share2Icon,
} from "lucide-react";
import { BarList } from "@/siteplane/components/bar-list";

const sources = [
  { icon: <SearchIcon />, id: "search", label: "Search", value: 5210 },
  { icon: <GlobeIcon />, id: "direct", label: "Direct", value: 3480 },
  { icon: <Share2Icon />, id: "social", label: "Social", value: 1265 },
  { icon: <MailIcon />, id: "email", label: "Email", value: 840 },
  { icon: <LinkIcon />, id: "referral", label: "Referral", value: 415 },
];

export default function Example() {
  return (
    <div className="w-full max-w-md">
      <BarList
        data={sources}
        heading={{ label: "Source", value: "Visitors" }}
        label="Traffic sources"
      />
    </div>
  );
}

Changing data

When data changes, the list glides to it. Each bar moves to its new length and each value rolls. A row that is new grows its bar out of nothing, a row that is gone shrinks away and leaves, and the rows that stay move to their new places. Rows are told by their id, so give every row one that is unique in the list and stays the same.

  • /pricing
    8,420
  • /docs/installation
    5,160
  • /blog/release-notes
    2,380
  • /changelog
    1,240
  • /about
    615
  • /careers
    48
"use client";

import { useState } from "react";
import { BarList } from "@/siteplane/components/bar-list";
import {
  ToggleGroup,
  ToggleGroupItem,
} from "@/siteplane/components/toggle-group";

const periods = [
  {
    data: [
      { id: "pricing", label: "/pricing", value: 1920 },
      { id: "installation", label: "/docs/installation", value: 1340 },
      { id: "release-notes", label: "/blog/release-notes", value: 610 },
      { id: "changelog", label: "/changelog", value: 290 },
      { id: "about", label: "/about", value: 140 },
    ],
    label: "7 days",
    value: "7",
  },
  {
    data: [
      { id: "pricing", label: "/pricing", value: 8420 },
      { id: "installation", label: "/docs/installation", value: 5160 },
      { id: "release-notes", label: "/blog/release-notes", value: 2380 },
      { id: "changelog", label: "/changelog", value: 1240 },
      { id: "about", label: "/about", value: 615 },
      { id: "careers", label: "/careers", value: 48 },
    ],
    label: "30 days",
    value: "30",
  },
  {
    data: [
      { id: "installation", label: "/docs/installation", value: 16880 },
      { id: "pricing", label: "/pricing", value: 15930 },
      { id: "launch-week", label: "/blog/launch-week", value: 7210 },
      { id: "release-notes", label: "/blog/release-notes", value: 6040 },
      { id: "changelog", label: "/changelog", value: 3560 },
      { id: "about", label: "/about", value: 1790 },
    ],
    label: "90 days",
    value: "90",
  },
];

export default function Example() {
  const [value, setValue] = useState("30");
  const data = periods.find((period) => period.value === value)?.data ?? [];
  return (
    <div className="grid w-full max-w-md gap-4">
      <ToggleGroup
        aria-label="Period"
        onValueChange={([next]) => {
          if (next) setValue(next);
        }}
        size="sm"
        value={[value]}
        variant="outline"
      >
        {periods.map((period) => (
          <ToggleGroupItem key={period.value} value={period.value}>
            {period.label}
          </ToggleGroupItem>
        ))}
      </ToggleGroup>
      <BarList data={data} label="Top pages by visitors" />
    </div>
  );
}

Right-to-left

In right-to-left text the rows mirror: each bar starts on the right, the icon and the label sit on the right, and the values line up on the left. Set dir="rtl" on the page or on a container. Numbers still read from left to right.

  • Desktop
    6,240
  • Mobile
    4,180
  • Tablet
    720
"use client";

import { MonitorIcon, SmartphoneIcon, TabletIcon } from "lucide-react";
import { BarList } from "@/siteplane/components/bar-list";

const devices = [
  { icon: <MonitorIcon />, id: "desktop", label: "Desktop", value: 6240 },
  { icon: <SmartphoneIcon />, id: "mobile", label: "Mobile", value: 4180 },
  { icon: <TabletIcon />, id: "tablet", label: "Tablet", value: 720 },
];

export default function Example() {
  return (
    <div className="w-full max-w-md" dir="rtl">
      <BarList data={devices} label="Visitors by device" />
    </div>
  );
}

API reference

Component properties
PropTypeDefaultDescription
BarList.datareadonly BarListItem[]requiredThe rows, in the order they are shown. Sort the data to rank it.
BarList.labelstringrequiredAccessible name of the list.
BarList.headingBarListHeadingunsetNames the two columns in a heading row above the rows, over a separator.
BarList.formatValue(value: number) => stringIntl.NumberFormat(locale)Writes the value at the end of each row. Text with digits rolls to its new value when it changes.
BarList.localestringen-USLocale of the number format when formatValue is not given.
BarList.colorstringlightest brand shadeA CSS color or token for the bars.
BarList.emptyLabelstringNo dataShown in place of the rows when data is empty.
BarList.classNamestringunsetAdditional classes for the list, or with a heading for the frame that holds the heading and the list.
BarListItem.idstringrequiredStable key of the row, unique in the list. Its bar glides by it when the data changes.
BarListItem.labelstringrequiredText of the row, written over its bar. A label that does not fit on one line ends in an ellipsis, and its title attribute keeps the full text.
BarListItem.descriptionstringunsetA quieter second line under the label, such as a path. It ends in an ellipsis when it does not fit, with the full text in its title attribute.
BarListItem.valuenumberrequiredSets the length of the bar next to the largest value and is written at the end of the row. A value that is not a finite number counts as 0.
BarListItem.iconReactNodeunsetShown before the label as decoration, hidden from assistive technology. An svg element takes the icon size of the kit.
BarListHeading.labelstringrequiredName of the column of labels, such as Top pages, at the start of the heading row.
BarListHeading.valuestringrequiredName of the column of values, such as Visitors, at the end of the heading row.

BarListProps, BarListItem and BarListHeading export these contracts. The list renders a ul with one li per row; with a heading it renders a div that holds the heading row, the separator and the ul. It takes no native props or ref beyond className.

Read the installed source.

Usage notes

Use a bar list for a short ranked list whose values should be seen next to the largest. When values can be negative or must be read against an axis, use a Bar Chart. For many rows or several values per row, use a Table.

Each bar is as long as its value is large next to the largest value, which fills the track. Any value above zero keeps at least 2% of the track, so a very small value is still a short bar and not a hairline. A value that is zero or negative has no bar, a negative value is still written as it is, and a value that is not a finite number counts as 0. All values share one column as wide as the widest, so every bar has the same track.

The rows are not interactive: they take no click, hover or focus, and they are not links. When a row must open or select something, use a Table, a List Box or a Resource List.

The bars take the lightest shade of the brand color. Each label is written over its bar, so a custom color should stay light enough to keep the labels readable. A label or a second line that does not fit on one line ends in an ellipsis, so every row keeps its height and the values stay in their column; the title attribute keeps the full text, and screen readers read it in full.

The list is named by label and is not a focus stop. Screen readers read each row’s label and value once; a rolling value is not announced while it changes. A row that is leaving is hidden from assistive technology.

The list renders completely on the server: each bar is a share of the track and needs no measuring. New data animates with the Motion tokens: bars and values with --motion-duration-value, rows that come, go or move with --motion-duration-expand, all with --motion-ease-standard. Under reduced motion new data shows at once: the system setting, html with data-motion-reduce="true" or SiteplaneMotionProvider preset="reduced".