Charts
Chart legend
A list of series names with a mark and an optional value, for any chart or one of your own.
- Built on
<svg>color-mix()subgrid- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 1.8 kB
- Dependencies
- React
- Team1,284
- Business642
- Enterprise118
- Trial407
Try its options
Loading its options.
Code
import { ChartLegend } from "@/components/equip/chart-legend";
const plans = [
{ label: "Team", value: 1284 },
{ label: "Business", value: 642 },
{ label: "Enterprise", value: 118 },
{ label: "Trial", value: 407 },
];
export default function Demo() {
return <ChartLegend items={plans} orientation="column" aria-label="Accounts by plan" style={{ width: 168 }} />;
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<ul role="list" aria-label="Accounts by plan" style="width:168px" class="eq-chart-legend" data-orientation="column">
<li class="eq-chart-legend-item" data-series="0"><span class="eq-chart-legend-mark" data-mark="square" aria-hidden="true"></span><span class="eq-chart-legend-name">Team</span><span class="eq-chart-legend-value">1,284</span></li>
<li class="eq-chart-legend-item" data-series="1"><span class="eq-chart-legend-mark" data-mark="square" aria-hidden="true"></span><span class="eq-chart-legend-name">Business</span><span class="eq-chart-legend-value">642</span></li>
<li class="eq-chart-legend-item" data-series="2"><span class="eq-chart-legend-mark" data-mark="square" aria-hidden="true"></span><span class="eq-chart-legend-name">Enterprise</span><span class="eq-chart-legend-value">118</span></li>
<li class="eq-chart-legend-item" data-series="3"><span class="eq-chart-legend-mark" data-mark="square" aria-hidden="true"></span><span class="eq-chart-legend-name">Trial</span><span class="eq-chart-legend-value">407</span></li>
</ul>Examples
Line marks
Each series step has its own dash as well as its own strength, so a legend for lines still reads in greyscale or print.
- p50
- p90
- p95
- p99
Code
import { ChartLegend } from "@/components/equip/chart-legend";
export default function Example() {
return (
<ChartLegend
mark="line"
aria-label="Latency percentiles"
items={[{ label: "p50" }, { label: "p90" }, { label: "p95" }, { label: "p99" }]}
/>
);
}HTMLworks in any framework, with the part's CSS
<ul role="list" aria-label="Latency percentiles" class="eq-chart-legend" data-orientation="row">
<li class="eq-chart-legend-item" data-series="0"><svg class="eq-chart-legend-mark" data-mark="line" aria-hidden="true" width="18" height="10" viewBox="0 0 18 10"><line x1="1" y1="5" x2="17" y2="5" /></svg><span class="eq-chart-legend-name">p50</span></li>
<li class="eq-chart-legend-item" data-series="1"><svg class="eq-chart-legend-mark" data-mark="line" aria-hidden="true" width="18" height="10" viewBox="0 0 18 10"><line x1="1" y1="5" x2="17" y2="5" /></svg><span class="eq-chart-legend-name">p90</span></li>
<li class="eq-chart-legend-item" data-series="2"><svg class="eq-chart-legend-mark" data-mark="line" aria-hidden="true" width="18" height="10" viewBox="0 0 18 10"><line x1="1" y1="5" x2="17" y2="5" /></svg><span class="eq-chart-legend-name">p95</span></li>
<li class="eq-chart-legend-item" data-series="3"><svg class="eq-chart-legend-mark" data-mark="line" aria-hidden="true" width="18" height="10" viewBox="0 0 18 10"><line x1="1" y1="5" x2="17" y2="5" /></svg><span class="eq-chart-legend-name">p99</span></li>
</ul>Point shapes with values
Point marks change shape per series, and numbers are formatted with the same options as the chart they sit beside.
- Organic search$84
- Paid social$61
- Email$112
- Referral$97
Code
import { ChartLegend } from "@/components/equip/chart-legend";
export default function Example() {
return (
<ChartLegend
mark="point"
orientation="column"
aria-label="Average order value by channel"
format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}
items={[
{ label: "Organic search", value: 84 },
{ label: "Paid social", value: 61 },
{ label: "Email", value: 112 },
{ label: "Referral", value: 97 },
]}
style={{ maxWidth: 260 }}
/>
);
}HTMLworks in any framework, with the part's CSS
<ul role="list" aria-label="Average order value by channel" style="max-width:260px" class="eq-chart-legend" data-orientation="column">
<li class="eq-chart-legend-item" data-series="0"><svg class="eq-chart-legend-mark" data-mark="point" aria-hidden="true" width="10" height="10" viewBox="0 0 10 10"><circle cx="5" cy="5" r="3.75" /></svg><span class="eq-chart-legend-name">Organic search</span><span class="eq-chart-legend-value">$84</span></li>
<li class="eq-chart-legend-item" data-series="1"><svg class="eq-chart-legend-mark" data-mark="point" aria-hidden="true" width="10" height="10" viewBox="0 0 10 10"><rect x="1.5" y="1.5" width="7" height="7" /></svg><span class="eq-chart-legend-name">Paid social</span><span class="eq-chart-legend-value">$61</span></li>
<li class="eq-chart-legend-item" data-series="2"><svg class="eq-chart-legend-mark" data-mark="point" aria-hidden="true" width="10" height="10" viewBox="0 0 10 10"><path d="M5 0.75 9.25 5 5 9.25 0.75 5Z" /></svg><span class="eq-chart-legend-name">Email</span><span class="eq-chart-legend-value">$112</span></li>
<li class="eq-chart-legend-item" data-series="3"><svg class="eq-chart-legend-mark" data-mark="point" aria-hidden="true" width="10" height="10" viewBox="0 0 10 10"><path d="M5 1 9.25 8.75H0.75Z" /></svg><span class="eq-chart-legend-name">Referral</span><span class="eq-chart-legend-value">$97</span></li>
</ul>Beside your own chart
Pass series to pin an item to a step, so a legend for a filtered chart keeps the same marks as the full one.
Showing paid channels only
- Paid search38%
- Paid social21%
Code
import { ChartLegend } from "@/components/equip/chart-legend";
export default function Example() {
return (
<div style={{ display: "grid", gap: 8, maxWidth: 320 }}>
<span style={{ fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>Showing paid channels only</span>
<ChartLegend
aria-label="Paid channels"
items={[
{ label: "Paid search", value: "38%", series: 1 },
{ label: "Paid social", value: "21%", series: 3 },
]}
/>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:8px;max-width:320px">
<span style="font-size:var(--eq-text-s);color:var(--eq-ink-2)">Showing paid channels only</span>
<ul role="list" aria-label="Paid channels" class="eq-chart-legend" data-orientation="row">
<li class="eq-chart-legend-item" data-series="1"><span class="eq-chart-legend-mark" data-mark="square" aria-hidden="true"></span><span class="eq-chart-legend-name">Paid search</span><span class="eq-chart-legend-value">38%</span></li>
<li class="eq-chart-legend-item" data-series="3"><span class="eq-chart-legend-mark" data-mark="square" aria-hidden="true"></span><span class="eq-chart-legend-name">Paid social</span><span class="eq-chart-legend-value">21%</span></li>
</ul>
</div>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
items | { label: string; value?: number | ReactNode; series?: number }[] | none | One entry per series. A numeric value is formatted; series picks the step and defaults to the position. |
mark | "square" | "line" | "point" | "square" | A filled square for bars and slices, a short dashed line for lines and areas, a shape for points. |
orientation | "row" | "column" | "row" | A wrapping row, or a column with names and values lined up. |
format | Intl.NumberFormatOptions | none | How numeric values are written. |
locale | string | "en-US" | Passed to Intl.NumberFormat. |
...rest | ComponentProps<"ul"> | none | Every native list attribute. Name it with aria-label when it stands alone. |
Anatomy
| root | .eq-chart-legend | A list; data-orientation sets row or column. |
|---|---|---|
| item | .eq-chart-legend-item | data-series sets the ink strength and dash for that step. |
| mark | .eq-chart-legend-mark | data-mark is square, line or point. |
| name | .eq-chart-legend-name | |
| value | .eq-chart-legend-value |
Notes
- Steps are ink at 100, 70, 48, 32 and 20 percent, mixed with transparent, so they follow every finish, theme and accent.
- Line marks add a dash per step and point marks a shape per step, so series are never told apart by colour alone.
- The marks are hidden from assistive tech; the names and values are real text.
- Line chart, area chart, stacked bar, scatter plot and radar chart use it for their legends and readouts.
- Renders on the server with no client JavaScript.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/chart-legend.tsx72 lines
import type { ComponentProps, ReactNode } from "react";
import "./chart-legend.css";
export type ChartLegendItem = {
label: string;
/** A number is formatted with format and locale; anything else is shown as given. */
value?: number | ReactNode;
/** Which series step the mark draws, from 0. Defaults to the item's position. */
series?: number;
};
export type ChartLegendProps = ComponentProps<"ul"> & {
items: ChartLegendItem[];
/** How each mark is drawn: a filled square for bars and slices, a short line for lines and areas, a shape for points. */
mark?: "square" | "line" | "point";
/** A row that wraps, or a column with the values lined up. */
orientation?: "row" | "column";
format?: Intl.NumberFormatOptions;
locale?: string;
};
export function ChartLegend({
items,
mark = "square",
orientation = "row",
format,
locale = "en-US",
className,
...rest
}: ChartLegendProps) {
const number = new Intl.NumberFormat(locale, format);
return (
<ul
role="list"
{...rest}
className={["eq-chart-legend", className].filter(Boolean).join(" ")}
data-orientation={orientation}
>
{items.map((item, i) => {
const s = Math.max(0, Math.min(4, Math.floor(item.series ?? i)));
const value = typeof item.value === "number" ? number.format(item.value) : item.value;
return (
<li key={`${item.label}-${i}`} className="eq-chart-legend-item" data-series={s}>
<Mark kind={mark} series={s} />
<span className="eq-chart-legend-name">{item.label}</span>
{value != null && <span className="eq-chart-legend-value">{value}</span>}
</li>
);
})}
</ul>
);
}
function Mark({ kind, series }: { kind: "square" | "line" | "point"; series: number }) {
if (kind === "square") return <span className="eq-chart-legend-mark" data-mark="square" aria-hidden="true" />;
if (kind === "line") {
return (
<svg className="eq-chart-legend-mark" data-mark="line" aria-hidden="true" width="18" height="10" viewBox="0 0 18 10">
<line x1="1" y1="5" x2="17" y2="5" />
</svg>
);
}
return (
<svg className="eq-chart-legend-mark" data-mark="point" aria-hidden="true" width="10" height="10" viewBox="0 0 10 10">
{series % 4 === 0 && <circle cx="5" cy="5" r="3.75" />}
{series % 4 === 1 && <rect x="1.5" y="1.5" width="7" height="7" />}
{series % 4 === 2 && <path d="M5 0.75 9.25 5 5 9.25 0.75 5Z" />}
{series % 4 === 3 && <path d="M5 1 9.25 8.75H0.75Z" />}
</svg>
);
}components/equip/chart-legend.css101 lines
@layer equip {
.eq-chart-legend {
display: flex;
flex-wrap: wrap;
gap: var(--eq-space-1) var(--eq-space-3);
margin: 0;
padding: 0;
list-style: none;
font: var(--eq-weight) var(--eq-text-xs) / 1.3 var(--eq-font);
color: var(--eq-ink-2);
}
.eq-chart-legend[data-orientation="column"] {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--eq-space-1) var(--eq-space-2);
}
/* The series steps every chart shares: ink at a lower strength for each step,
and for lines a dash of its own, so no series is told apart by colour alone. */
.eq-chart-legend-item {
--_tone: var(--eq-chart-1);
--_dash: none;
display: inline-flex;
align-items: center;
gap: var(--eq-space-2);
min-width: 0;
}
.eq-chart-legend-item[data-series="1"] {
--_tone: var(--eq-chart-2);
--_dash: 5 4;
}
.eq-chart-legend-item[data-series="2"] {
--_tone: var(--eq-chart-3);
--_dash: 0.1 4;
}
.eq-chart-legend-item[data-series="3"] {
--_tone: var(--eq-chart-4);
--_dash: 8 4 0.1 4;
}
.eq-chart-legend-item[data-series="4"] {
--_tone: var(--eq-chart-5);
}
.eq-chart-legend[data-orientation="column"] .eq-chart-legend-item {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
}
.eq-chart-legend-mark {
display: block;
flex: none;
overflow: visible;
}
.eq-chart-legend-mark[data-mark="square"] {
width: 10px;
height: 10px;
border-radius: 2px;
background: var(--_tone, var(--eq-ink));
box-shadow: inset 0 0 0 1px var(--eq-line);
}
.eq-chart-legend-mark[data-mark="line"] line {
stroke: var(--_tone, var(--eq-ink));
stroke-width: 2;
stroke-linecap: round;
stroke-dasharray: var(--_dash);
}
.eq-chart-legend-mark[data-mark="point"] > * {
fill: color-mix(in srgb, var(--_tone, var(--eq-ink)) 35%, transparent);
stroke: var(--_tone, var(--eq-ink));
stroke-width: 1.5;
stroke-linejoin: round;
}
.eq-chart-legend-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eq-chart-legend-value {
color: var(--eq-ink);
font-weight: var(--eq-weight-strong);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.eq-chart-legend[data-orientation="column"] .eq-chart-legend-value {
text-align: end;
}
}