RankedList
RankedList renders ordered rows with optional bars, aligned columns, and expandable details. The parent owns row order and display values. Use RankedTable when column headers must sort rows. The two share their chrome.
The component does not sort. The order you pass is the ranking. Bars normalize against the largest magnitude in the category, with a 2% floor so near-zero rows stay visibly ranked.
import RankedList from "@haloid/azucar/ui/ranked-list";import type { RankedListCategory, RankedListItem } from "@haloid/azucar/ui/ranked-list/types";
The types file is declaration-only. Import from it with import type or the build breaks.
Ranking
The plain form is one category and a header. A single category renders no tabs. The header works like the one in Card: header names the card, headerMuted is the quiet clause that scopes it.
<Card className="flex h-90 w-120 flex-col overflow-hidden"> <RankedList ariaLabel="Facilities ranked by stranded power" header="Stranded power" headerMuted="by facility" categories={[ { key: "facilities", label: "Facilities", items: [ { key: "tor5", label: "TOR5", value: "412 kW", magnitude: 412 }, { key: "atl2", label: "ATL2", value: "388 kW", magnitude: 388 }, { key: "dfw1", label: "DFW1", value: "240 kW", magnitude: 240 }, { key: "phx4", label: "PHX4", value: "98 kW", magnitude: 98 }, { key: "sea3", label: "SEA3", value: "41 kW", magnitude: 41 }, ], }, ]} /> </Card>
RankedList sits directly inside Card and fills the height the card gives it. Fix the height on the card, as the demo does, and the rows scroll under fade masks. The label truncates on narrow rows and shows the full text in a tooltip. The value sits right-aligned in tabular numerals. Give each item an href and the row renders as a real link with a hover shade. The demos here skip href because it navigates.
magnitude must be non-negative. For signed data, rank by the absolute value and put the sign in value, as the reconciliation overview does for net power.
target is optional. With it, a faint track runs to the target and the fill stops at the magnitude. A magnitude past its target fills to the seam and renders the excess in the item's tone, green unless set, so a row over its cap reads at a glance. A 1px ticker marks the seam, and the excess never draws narrower than 12px. The scale becomes the largest magnitude or target in the category. RankedTable has the same bar under bar.
Categories
Pass more than one category and CardTabs appears in the header, wired internally. A switch swaps the whole body: the scroll resets, and the bars renormalize against the max of the new category. defaultCategory picks the starting tab by key. It falls back to the first category.
<Card className="flex h-90 w-120 flex-col overflow-hidden"> <RankedList ariaLabel="Stranded power ranking" header="Stranded power" defaultCategory="facilities" categories={[ { key: "facilities", label: "Facilities", items: [ { key: "tor5", label: "TOR5", value: "412 kW", magnitude: 412 }, { key: "atl2", label: "ATL2", value: "388 kW", magnitude: 388 }, { key: "dfw1", label: "DFW1", value: "240 kW", magnitude: 240 }, { key: "phx4", label: "PHX4", value: "98 kW", magnitude: 98 }, { key: "sea3", label: "SEA3", value: "41 kW", magnitude: 41 }, ], }, { key: "customers", label: "Customers", items: [ { key: "att", label: "AT&T Global Services", value: "210 kW", magnitude: 210 }, { key: "cisco", label: "Cisco Systems Canada", value: "187 kW", magnitude: 187 }, { key: "cloudflare", label: "Cloudflare, US Inc.", value: "121 kW", magnitude: 121 }, { key: "centrilogic", label: "Centrilogic Inc.", value: "64 kW", magnitude: 64 }, { key: "alea", label: "Alea Software Ltd", value: "38 kW", magnitude: 38 }, ], }, ]} /> </Card>
The switch is instant on purpose: a new dataset remounts the scroller with zero animation. The card keeps its size because the page fixes it. The list never resizes itself between categories.
Search
search pins a search input in the card header, alongside the tabs when there is more than one category. It filters rows by label, case-insensitive substring. Type in the demo: removed rows fade out while the survivors slide up, and the scroll snaps back to the top. A query with no matches shows a quiet No matches state.
<Card className="flex h-90 w-140 flex-col overflow-hidden"> <RankedList ariaLabel="Facilities ranked by stranded power" search header="Stranded power" headerMuted="across 10 facilities" categories={[ { key: "facilities", label: "Facilities", items: [ { key: "tor5", label: "TOR5", value: "412 kW", magnitude: 412 }, { key: "atl2", label: "ATL2", value: "388 kW", magnitude: 388 }, { key: "dfw1", label: "DFW1", value: "240 kW", magnitude: 240 }, { key: "yyz1", label: "YYZ1", value: "201 kW", magnitude: 201 }, { key: "phx4", label: "PHX4", value: "98 kW", magnitude: 98 }, { key: "ord2", label: "ORD2", value: "87 kW", magnitude: 87 }, { key: "iad6", label: "IAD6", value: "76 kW", magnitude: 76 }, { key: "sjc1", label: "SJC1", value: "55 kW", magnitude: 55 }, { key: "sea3", label: "SEA3", value: "41 kW", magnitude: 41 }, { key: "mia2", label: "MIA2", value: "12 kW", magnitude: 12 }, ], }, ]} /> </Card>
Bar lengths do not change while you filter. Bars normalize against the unfiltered category max, not the visible max. Filter down to SEA3 and its bar stays short, because the ranking you saw before you typed is still the truth. To rescale survivors would inflate small rows into big ones.
The search input renders inside the header row. That row exists only when you pass a header or more than one category, so a single category with search and no header shows no input. Give it a header.
Header as a function
header and headerMuted also accept a function of the list state. The header becomes a readout: it recomputes on every category switch and every keystroke. The state passes back your own category object as given, so extra fields you add, like a plural noun, arrive typed. Search the demo and the qualifier counts down.
<Card className="flex h-90 w-140 flex-col overflow-hidden"> <RankedList ariaLabel="Stranded power ranking" search header="Stranded power" headerMuted={({ category, search }) => search !== null && search.query.trim() !== "" ? search.matches + " of " + category.items.length + " " + category.noun : "across " + category.items.length + " " + category.noun} categories={[ { key: "facilities", label: "Facilities", noun: "facilities", items: [ { key: "tor5", label: "TOR5", value: "412 kW", magnitude: 412 }, { key: "atl2", label: "ATL2", value: "388 kW", magnitude: 388 }, { key: "dfw1", label: "DFW1", value: "240 kW", magnitude: 240 }, { key: "phx4", label: "PHX4", value: "98 kW", magnitude: 98 }, { key: "sea3", label: "SEA3", value: "41 kW", magnitude: 41 }, ], }, ]} /> </Card>
Expandable rows
Set details on an item to make its row a disclosure button. Each row opens independently.
The row has a muted background. Expanded details are transparent and use the enclosing card's surface.
Expansion follows item keys through filtering and reordering. Categories keep separate expansion state.
Use stable keys within each category. A row can navigate with href or expand with details.
Place navigation links inside details when both actions are needed.
Use sizing="content" inside a timeline. The card grows as rows open, and the page owns scrolling.
The default sizing="fill" keeps the existing fixed-height behavior.
Omit magnitude for a plain row without a proportional bar. valueTone colors the simple trailing value.
<Card className="flex w-140 flex-col"> <RankedList header="Steps" search sizing="content" defaultExpandedKeys={["a"]} ariaLabel="Expandable steps" categories={[{ key: "steps", label: "Steps", items: [ { key: "a", label: "Step A", value: "12", details: <Text variant="secondary">Details for Step A.</Text> }, { key: "b", label: "Step B", value: "8", details: <Text variant="secondary">Details for Step B.</Text> }, ], }]} /> </Card>
Full-width details
Set detailsLayout="full-bleed" on an item in a content-sized list to extend its details to the card edges.
Use it for charts or media. Add padding to text inside the details. The default "inset" keeps all content aligned with the row label.
<Card className="flex w-full flex-col"> <RankedList header="History" sizing="content" defaultExpandedKeys={["a"]} ariaLabel="History with full-width details" categories={[{ key: "history", label: "History", items: [{ key: "a", label: "Step A", value: "72", detailsLayout: "full-bleed", details: <div className="flex flex-col gap-4"> <div className="h-24 bg-bg-3" /> <Text className="px-4" variant="secondary">Text keeps its own padding below full-width content.</Text> </div>, }], }]} /> </Card>
Aligned columns
Set columns on a category to align its header and row values. Headers display labels without sorting controls.
The first column uses each item's label. Remaining columns read cells by column key and default to 5rem width.
The first column fills the remaining space. The disclosure chevron has its own reserved column.
Missing cells show a dash. Column cells must contain display content; put interactive controls inside details.
<Card className="flex w-160 flex-col"> <RankedList header="Steps" sizing="content" ariaLabel="Steps with aligned values" categories={[{ key: "steps", label: "Steps", columns: [{ key: "label", header: "Step" }, { key: "value", header: "Value" }, { key: "limit", header: "Limit" }, { key: "state", header: "State", width: "6rem" }], items: [ { key: "a", label: "Step A", magnitude: 72, target: 60, tone: "bad", cells: { value: "72", limit: "60", state: <Badge tone="bad">Active</Badge> }, details: <Text variant="secondary">Details for Step A.</Text> }, { key: "b", label: "Step B", magnitude: 48, target: 60, cells: { value: "48", limit: "60", state: <Badge>Complete</Badge> }, details: <Text variant="secondary">Details for Step B.</Text> }, ], }]} /> </Card>
API
interface RankedListItemContent {key: string;label: string; // plain string: search and the truncation tooltip need textvalue?: React.ReactNode; // simple trailing valuemagnitude?: number; // omit to draw no proportional bartarget?: number; // optional cap; the track ends here and excess renders in the tonetone?: "bad" | "warn" | "good" | "info" | "neutral"; // color of the excess; "good" (green) by defaultvalueTone?: "bad" | "warn" | "good" | "info" | "neutral";cells?: Record<string, React.ReactNode>;}type RankedListItem = RankedListItemContent & ({ href: string; details?: never; detailsLayout?: never } | {href?: never;details?: React.ReactNode;detailsLayout?: "inset" | "full-bleed"; // default inset});interface RankedListColumn {key: string;header: string;width?: string;}interface RankedListCategory {key: string;label: React.ReactNode; // the tab labelitems: RankedListItem[]; // pre-ranked; this order is the rankingcolumns?: RankedListColumn[];}interface RankedListProps<C extends RankedListCategory> {categories: C[]; // a single entry renders no tabsdefaultCategory?: string; // key; falls back to the first categoryheader?: React.ReactNode | ((state: RankedState<C>) => React.ReactNode);headerMuted?: React.ReactNode | ((state: RankedState<C>) => React.ReactNode); // the quiet clause after the titlesearch?: boolean; // default falseariaLabel?: string; // labels the list for screen readerssizing?: "fill" | "content"; // default filldefaultExpandedKeys?: string[]; // initially open rows in the initial category}// what the header function receivesinterface RankedState<C> {category: C; // your category object, passed back verbatimsorting: null; // always null here; only RankedTable sortssearch: { query: string; matches: number } | null; // null when search is off}
Not supported: sorting, custom row rendering, virtualization. Every row renders, so the component fits rankings of dozens, not thousands. An empty categories array renders nothing.