Chart
SVG charts you own: bar, line, area, pie and radial, with tooltips and legends.
- v1.0.0
- SSR
- Zoneless
- 2.2 kB gzip
- Desktop
- Mobile
Use the arrow keys to read each month.
| Month | Desktop | Mobile |
|---|---|---|
| January | 186 | 80 |
| February | 305 | 200 |
| March | 237 | 120 |
| April | 73 | 190 |
| May | 209 | 130 |
| June | 214 | 140 |
import { Component, computed, signal } from '@angular/core';
import {
ChartAxis,
ChartContainer,
ChartCursor,
ChartGrid,
ChartLabel,
ChartLegend,
ChartLegendIndicator,
ChartLegendItem,
ChartPlot,
ChartTooltip,
ChartTooltipBody,
ChartTooltipIndicator,
ChartTooltipItem,
ChartTooltipLabel,
ChartTooltipList,
ChartTooltipName,
ChartTooltipValue,
scaleBand,
scaleLinear,
type ChartConfig,
} from '@/ui/chart/chart';
/** The plot's size, in viewBox units. */
const WIDTH = 500;
const HEIGHT = 225;
/**
* Desktop and mobile visitors by month, as grouped bars. The chart is one
* tab stop: the arrow keys move between months, showing each one's
* tooltip and announcing its values; a hidden table holds every value.
*/
@Component({
selector: 'example-chart-bar',
imports: [
ChartAxis,
ChartContainer,
ChartCursor,
ChartGrid,
ChartLabel,
ChartLegend,
ChartLegendIndicator,
ChartLegendItem,
ChartPlot,
ChartTooltip,
ChartTooltipBody,
ChartTooltipIndicator,
ChartTooltipItem,
ChartTooltipLabel,
ChartTooltipList,
ChartTooltipName,
ChartTooltipValue,
],
template: `
<figure
ndChartContainer
[config]="config"
class="w-lg max-w-full flex-col gap-2"
>
<figcaption id="chart-bar-caption" class="text-sm font-medium">
Visitors, January to June 2026
</figcaption>
<svg
ndChartPlot
[attr.viewBox]="'0 0 ' + width + ' ' + height"
[xs]="centers"
[(active)]="active"
aria-labelledby="chart-bar-caption"
aria-describedby="chart-bar-hint"
>
<g ndChartGrid>
@for (tick of y.ticks(4); track tick) {
<line
x1="0"
[attr.x2]="width"
[attr.y1]="y(tick)"
[attr.y2]="y(tick)"
/>
}
</g>
@if (current(); as row) {
<rect
ndChartCursor
kind="band"
[attr.x]="x(row.month) - x.step * 0.1"
y="0"
[attr.width]="x.bandwidth + x.step * 0.2"
[attr.height]="height"
/>
}
@for (row of data; track row.month) {
<rect
[attr.x]="x(row.month)"
[attr.y]="y(row.desktop)"
[attr.width]="bar"
[attr.height]="y(0) - y(row.desktop)"
rx="4"
fill="var(--color-desktop)"
/>
<rect
[attr.x]="x(row.month) + bar"
[attr.y]="y(row.mobile)"
[attr.width]="bar"
[attr.height]="y(0) - y(row.mobile)"
rx="4"
fill="var(--color-mobile)"
/>
}
</svg>
<div ndChartAxis>
@for (row of data; track row.month; let i = $index) {
<span ndChartLabel [style.left.%]="((centers[i] ?? 0) / width) * 100">
{{ row.month.slice(0, 3) }}
</span>
}
</div>
@if (current(); as row) {
<div ndChartTooltip [style.left.%]="left()">
<div ndChartTooltipLabel>{{ row.month }}</div>
<div ndChartTooltipList>
@for (key of keys; track key) {
<div ndChartTooltipItem>
<span
ndChartTooltipIndicator
[color]="'var(--color-' + key + ')'"
></span>
<div ndChartTooltipBody>
<span ndChartTooltipName>{{ config[key]?.label }}</span>
<span ndChartTooltipValue>{{ row[key] }}</span>
</div>
</div>
}
</div>
</div>
}
<ul ndChartLegend>
@for (key of keys; track key) {
<li ndChartLegendItem>
<span
ndChartLegendIndicator
[color]="'var(--color-' + key + ')'"
></span>
{{ config[key]?.label }}
</li>
}
</ul>
<p id="chart-bar-hint" class="sr-only">
Use the arrow keys to read each month.
</p>
<p class="sr-only" aria-live="polite">{{ summary() }}</p>
<table class="sr-only">
<caption>
Visitors by month
</caption>
<thead>
<tr>
<th scope="col">Month</th>
<th scope="col">Desktop</th>
<th scope="col">Mobile</th>
</tr>
</thead>
<tbody>
@for (row of data; track row.month) {
<tr>
<th scope="row">{{ row.month }}</th>
<td>{{ row.desktop }}</td>
<td>{{ row.mobile }}</td>
</tr>
}
</tbody>
</table>
</figure>
`,
})
export class ChartBarExample {
protected readonly width = WIDTH;
protected readonly height = HEIGHT;
protected readonly keys = ['desktop', 'mobile'] as const;
protected readonly config: ChartConfig = {
desktop: { label: 'Desktop', color: 'var(--chart-1)' },
mobile: { label: 'Mobile', color: 'var(--chart-2)' },
};
protected readonly data = [
{ month: 'January', desktop: 186, mobile: 80 },
{ month: 'February', desktop: 305, mobile: 200 },
{ month: 'March', desktop: 237, mobile: 120 },
{ month: 'April', desktop: 73, mobile: 190 },
{ month: 'May', desktop: 209, mobile: 130 },
{ month: 'June', desktop: 214, mobile: 140 },
];
protected readonly x = scaleBand(
this.data.map((row) => row.month),
[0, WIDTH],
);
protected readonly y = scaleLinear(
[0, Math.max(...this.data.map((row) => Math.max(row.desktop, row.mobile)))],
[HEIGHT, 8],
);
protected readonly bar = this.x.bandwidth / 2;
protected readonly centers = this.data.map(
(row) => this.x(row.month) + this.x.bandwidth / 2,
);
protected readonly active = signal<number | null>(null);
protected readonly current = computed(() => {
const index = this.active();
return index === null ? null : this.data[index];
});
protected readonly left = computed(() => {
const index = this.active();
return index === null ? 0 : ((this.centers[index] ?? 0) / WIDTH) * 100;
});
protected readonly summary = computed(() => {
const row = this.current();
return row
? `${row.month}: desktop ${row.desktop}, mobile ${row.mobile}.`
: '';
});
}
Charts are SVG you own: bar, line, area, pie, donut and radial charts
drawn in your component's template from the chart lib's scales, ticks and
path builders, with no outside dependency, so they render on the server.
ChartContainer sets a color per series from a config; the tooltip and
legend parts style the rest. Shapes are SVG, scaled to the chart's width,
while axis and value labels are HTML (ChartAxis and ChartLabel),
placed by percentage, so they keep the text's size at every width. The plot is one tab stop: the arrow keys
move between data points, showing the tooltip, while a polite live region
reads each value and a hidden table holds them all.
Installation
npx @ndop/cli add chartpnpm dlx @ndop/cli add chartyarn dlx @ndop/cli add chartbunx @ndop/cli add chartInstall the dependencies
npm install @ndop/corepnpm add @ndop/coreyarn add @ndop/corebun add @ndop/coreCopy the code into your project
At the paths your
ndop.jsonaliases point to; these use the defaults.src/app/ui/chart/chart.tsimport { booleanAttribute, computed, Directive, ElementRef, inject, input, model, } from '@angular/core'; import { cn } from '@/lib/utils'; export { arcPath, areaPath, extent, linePath, niceTicks, pieArcs, scaleBand, scaleLinear, scaleTime, stack, type BandPadding, type BandScale, type ChartArc, type ChartCurve, type ChartPoint, type LinearScale, type PieOptions, type TimeScale, } from '@/lib/chart'; /** A series' label and color, by its key. */ export type ChartConfig = Record< string, { /** The series' name, for tooltips and legends. */ readonly label?: string; /** A CSS color, such as `var(--chart-1)`. */ readonly color?: string; /** Colors for the light and dark themes, used with `light-dark()`. */ readonly theme?: { readonly light: string; readonly dark: string }; } >; /** * A chart's frame: it sets `--color-<key>` for each series in `config`, * so the SVG, tooltip and legend use `var(--color-desktop)`, and keeps * a video aspect ratio. Put it on a `figure` with a `figcaption`. * * @example * ```html * <figure ndChartContainer [config]="config"> * <figcaption>Visitors</figcaption> * <svg ndChartPlot viewBox="0 0 500 225">…</svg> * <div ndChartAxis>…</div> * </figure> * ``` */ @Directive({ selector: '[ndChartContainer]', host: { 'data-slot': 'chart', '[style]': 'colors()', '[class]': 'classes()', }, }) export class ChartContainer { /** Each series' label and color, by key. */ readonly config = input<ChartConfig>({}); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly colors = computed(() => Object.fromEntries( Object.entries(this.config()).flatMap(([key, item]) => { const color = item.theme ? `light-dark(${item.theme.light}, ${item.theme.dark})` : item.color; return color ? [[`--color-${key}`, color]] : []; }), ), ); protected readonly classes = computed(() => cn( 'relative flex aspect-video justify-center text-xs [&_svg]:overflow-visible', this.class(), ), ); } /** * The plot, on the chart's `svg`: one tab stop where the arrow keys, * `Home` and `End` move between data points and the pointer picks the * nearest one, setting `active`. Bind a tooltip and a polite live region * to `active`, so the keyboard and screen readers reach each value. Give * it `xs`, each point's horizontal position in `viewBox` units, and name * it with `aria-label` or `aria-labelledby`. * * It spans the chart's width at its `viewBox`'s aspect ratio, so a * position in `viewBox` units is the same percentage of the plot's box, * where the HTML labels and the tooltip go. For a fixed height instead, * set one with a class and `preserveAspectRatio="none"`, and give your * lines and outlines `vector-effect="non-scaling-stroke"` so they keep * their width, as the grid and cursor do. * * @example * ```html * <svg ndChartPlot viewBox="0 0 500 225" [xs]="xs()" [(active)]="active" aria-label="Visitors by month">…</svg> * ``` */ @Directive({ selector: 'svg[ndChartPlot]', host: { 'data-slot': 'chart-plot', role: 'group', tabindex: '0', '[attr.data-active]': 'active()', '[class]': 'classes()', '(keydown)': 'keydown($event)', '(pointermove)': 'pointer($event)', '(pointerleave)': 'leave()', '(focus)': 'focus()', '(blur)': 'active.set(null)', }, }) export class ChartPlot { /** Each data point's horizontal position, in `viewBox` units. */ readonly xs = input<readonly number[]>([]); /** The index of the active data point, or `null`. Two-way bindable. */ readonly active = model<number | null>(null); /** * Whether the pointer picks the point nearest it horizontally; turn it * off for pies, whose slices set `active` on hover themselves. */ readonly trackPointer = input(true, { transform: booleanAttribute }); /** Extra classes, merged last so they win. */ readonly class = input(''); private readonly element = inject<ElementRef<SVGSVGElement>>(ElementRef).nativeElement; protected readonly classes = computed(() => cn( 'block h-auto w-full rounded-md outline-hidden focus-visible:ring-[3px] focus-visible:ring-ring/50', this.class(), ), ); /** * Moves between data points with the arrow keys, `Home` and `End`. * * @param event - The keydown event. */ protected keydown(event: KeyboardEvent): void { const last = this.xs().length - 1; const current = this.active() ?? 0; let next: number; switch (event.key) { case 'ArrowRight': case 'ArrowDown': next = Math.min(last, current + 1); break; case 'ArrowLeft': case 'ArrowUp': next = Math.max(0, current - 1); break; case 'Home': next = 0; break; case 'End': next = last; break; case 'Escape': this.active.set(null); return; default: return; } event.preventDefault(); this.active.set(next); } /** * Picks the data point nearest the pointer. * * @param event - The pointermove event. */ protected pointer(event: PointerEvent): void { const matrix = this.element.getScreenCTM?.(); const xs = this.xs(); if (!this.trackPointer() || !matrix || xs.length === 0) { return; } // From screen pixels to viewBox units, whatever the SVG's size. const x = (event.clientX - matrix.e) / matrix.a; let nearest = 0; xs.forEach((value, index) => { if (Math.abs(value - x) < Math.abs((xs[nearest] as number) - x)) { nearest = index; } }); this.active.set(nearest); } /** Clears the active point when the pointer leaves, unless focused. */ protected leave(): void { if (this.element.ownerDocument.activeElement !== this.element) { this.active.set(null); } } /** Activates the first point when focus arrives from the keyboard. */ protected focus(): void { if (this.active() === null && this.xs().length > 0) { this.active.set(0); } } } /** * The grid lines behind the data, on a `g`. They stay a pixel wide * whatever the plot's size. * * @example * ```html * <g ndChartGrid>…<line /></g> * ``` */ @Directive({ selector: '[ndChartGrid]', host: { 'data-slot': 'chart-grid', '[class]': 'classes()' }, }) export class ChartGrid { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( '[&_line]:stroke-border/50 [&_line]:[vector-effect:non-scaling-stroke]', this.class(), ), ); } /** * The x axis' labels, in a band under the plot. They're HTML rather than * SVG text, so they keep their size at every width instead of scaling * with the plot. Put a `ChartLabel` in it for each tick, at the tick's * position across the plot in percent. Screen readers skip it: the * chart's table holds the same labels. * * @example * ```html * <div ndChartAxis> * <span ndChartLabel [style.left.%]="(x / width) * 100">Jan</span> * </div> * ``` */ @Directive({ selector: '[ndChartAxis]', host: { 'data-slot': 'chart-axis', 'aria-hidden': 'true', '[class]': 'classes()', }, }) export class ChartAxis { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('relative h-4 text-muted-foreground', this.class()), ); } /** * A label at a point of the chart, such as an axis tick or a value, in * HTML so it keeps its size at every width. It sits at its `left`, a * percentage of the plot's width, centered on it by default; a label at * the plot's edge aligns `left` or `right` instead, so it stays within * the plot. In a `ChartAxis` it sits at the top of the band. Over the * plot, put the plot and the label in a `relative` box and set its `top` * too, centering it with `-translate-y-1/2`. * * Like the plot, it isn't mirrored in right-to-left layouts, so it's * placed with `left` and aligns to physical sides, not `start` and `end`. * * @example * ```html * <span ndChartLabel [style.left.%]="0" align="left">Jan</span> * <span ndChartLabel [style.left.%]="50">Apr</span> * <span ndChartLabel [style.left.%]="100" align="right">Jun</span> * ``` */ @Directive({ selector: '[ndChartLabel]', host: { 'data-slot': 'chart-label', '[attr.data-align]': 'align()', '[class]': 'classes()', }, }) export class ChartLabel { /** * Which of its sides is at its `left`: `center` for its middle, `left` * for its left edge (it extends right), `right` for its right edge. */ readonly align = input<'center' | 'left' | 'right'>('center'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'pointer-events-none absolute top-0 whitespace-nowrap', this.align() === 'center' && '-translate-x-1/2', this.align() === 'right' && '-translate-x-full', this.class(), ), ); } /** * The marker of the active data point: a line, which stays a pixel * wide whatever the plot's size, or a band behind bars. * * @example * ```html * <rect ndChartCursor kind="band" … /> * ``` */ @Directive({ selector: '[ndChartCursor]', host: { 'data-slot': 'chart-cursor', 'aria-hidden': 'true', '[attr.data-kind]': 'kind()', '[class]': 'classes()', }, }) export class ChartCursor { /** `line` for line and area charts, `band` for bars. */ readonly kind = input<'line' | 'band'>('line'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'pointer-events-none data-[kind=band]:fill-muted data-[kind=line]:stroke-border data-[kind=line]:[vector-effect:non-scaling-stroke]', this.class(), ), ); } /** * The tooltip of the active data point, drawn over the chart. It's for * sighted users; a polite live region tells screen readers the same. * * @example * ```html * <div ndChartTooltip [style.left.%]="left()">…</div> * ``` */ @Directive({ selector: '[ndChartTooltip]', host: { 'data-slot': 'chart-tooltip', 'aria-hidden': 'true', '[class]': 'classes()', }, }) export class ChartTooltip { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl', 'pointer-events-none absolute top-0 z-10 -translate-x-1/2', this.class(), ), ); } /** * The tooltip's heading, such as the month. * * @example * ```html * <div ndChartTooltipLabel>January</div> * ``` */ @Directive({ selector: '[ndChartTooltipLabel]', host: { 'data-slot': 'chart-tooltip-label', '[class]': 'classes()' }, }) export class ChartTooltipLabel { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('font-medium', this.class())); } /** * The list of a tooltip's series. * * @example * ```html * <div ndChartTooltipList>…</div> * ``` */ @Directive({ selector: '[ndChartTooltipList]', host: { 'data-slot': 'chart-tooltip-list', '[class]': 'classes()' }, }) export class ChartTooltipList { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('grid gap-1.5', this.class())); } /** * One series in a tooltip: its indicator, name and value. * * @example * ```html * <div ndChartTooltipItem>…</div> * ``` */ @Directive({ selector: '[ndChartTooltipItem]', host: { 'data-slot': 'chart-tooltip-item', '[class]': 'classes()' }, }) export class ChartTooltipItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground', 'items-center', this.class(), ), ); } /** * A series' color swatch in a tooltip: a `dot`, a `line` or a `dashed` * line. * * @example * ```html * <span ndChartTooltipIndicator color="var(--color-desktop)"></span> * ``` */ @Directive({ selector: '[ndChartTooltipIndicator]', host: { 'data-slot': 'chart-tooltip-indicator', '[style.--color-bg]': 'color()', '[style.--color-border]': 'color()', '[class]': 'classes()', }, }) export class ChartTooltipIndicator { /** Its shape. */ readonly indicator = input<'dot' | 'line' | 'dashed'>('dot'); /** The series' color. */ readonly color = input('currentColor'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)', this.indicator() === 'dot' && 'h-2.5 w-2.5', this.indicator() === 'line' && 'w-1', this.indicator() === 'dashed' && 'w-0 border-[1.5px] border-dashed bg-transparent', this.class(), ), ); } /** * The name and value of a series in a tooltip, spread across its width. * * @example * ```html * <div ndChartTooltipBody> * <span ndChartTooltipName>Desktop</span> * <span ndChartTooltipValue>186</span> * </div> * ``` */ @Directive({ selector: '[ndChartTooltipBody]', host: { 'data-slot': 'chart-tooltip-body', '[class]': 'classes()' }, }) export class ChartTooltipBody { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex flex-1 justify-between leading-none', 'items-center', this.class(), ), ); } /** * A series' name in a tooltip. * * @example * ```html * <span ndChartTooltipName>Desktop</span> * ``` */ @Directive({ selector: '[ndChartTooltipName]', host: { 'data-slot': 'chart-tooltip-name', '[class]': 'classes()' }, }) export class ChartTooltipName { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('text-muted-foreground', this.class()), ); } /** * A series' value in a tooltip, in tabular figures. * * @example * ```html * <span ndChartTooltipValue>186</span> * ``` */ @Directive({ selector: '[ndChartTooltipValue]', host: { 'data-slot': 'chart-tooltip-value', '[class]': 'classes()' }, }) export class ChartTooltipValue { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('font-mono font-medium text-foreground tabular-nums', this.class()), ); } /** * The legend: the series' swatches and names, under the chart. * * @example * ```html * <ul ndChartLegend>…</ul> * ``` */ @Directive({ selector: '[ndChartLegend]', host: { 'data-slot': 'chart-legend', '[class]': 'classes()' }, }) export class ChartLegend { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex items-center justify-center gap-4', 'pt-3', this.class()), ); } /** * One series in the legend. * * @example * ```html * <li ndChartLegendItem>…</li> * ``` */ @Directive({ selector: '[ndChartLegendItem]', host: { 'data-slot': 'chart-legend-item', '[class]': 'classes()' }, }) export class ChartLegendItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground', this.class(), ), ); } /** * A series' color swatch in the legend. * * @example * ```html * <span ndChartLegendIndicator color="var(--color-desktop)"></span> * ``` */ @Directive({ selector: '[ndChartLegendIndicator]', host: { 'data-slot': 'chart-legend-indicator', '[style.background-color]': 'color()', '[class]': 'classes()', }, }) export class ChartLegendIndicator { /** The series' color. */ readonly color = input('currentColor'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('h-2 w-2 shrink-0 rounded-[2px]', this.class()), ); }src/app/lib/chart.ts/** A point in SVG user units. */ export interface ChartPoint { /** Horizontal position. */ readonly x: number; /** Vertical position. */ readonly y: number; } /** How a line or area joins its points. */ export type ChartCurve = 'linear' | 'monotone' | 'step'; /** A scale from numbers to positions, as from data to pixels. */ export interface LinearScale { /** * The position of a value. * * @param value - A value in the domain. * @returns Its position in the range. */ (value: number): number; /** The domain, after `nice`. */ readonly domain: readonly [number, number]; /** * Round values across the domain, for axis labels and grid lines. * * @param count - About how many; defaults to 5. * @returns The values. */ ticks(count?: number): number[]; } /** A scale from categories to bands, as for bars. */ export interface BandScale { /** * Where a category's band starts. * * @param key - A category in the domain. * @returns The band's start, or `NaN` for an unknown category. */ (key: string): number; /** The width of each band. */ readonly bandwidth: number; /** The distance from one band's start to the next one's. */ readonly step: number; } /** A scale from dates to positions. */ export interface TimeScale { /** * The position of a date. * * @param value - A date in the domain. * @returns Its position in the range. */ (value: Date): number; /** * Dates at round intervals across the domain: days, weeks, months or * years, whichever gives about `count`. * * @param count - About how many; defaults to 5. * @returns The dates. */ ticks(count?: number): Date[]; } /** One slice of a pie, donut or radial chart. */ export interface ChartArc { /** Where it starts, in radians clockwise from 12 o'clock. */ readonly startAngle: number; /** Where it ends, in radians. */ readonly endAngle: number; /** Its value. */ readonly value: number; /** Its index in the values. */ readonly index: number; } /** Space around a {@link BandScale}'s bands, in fractions of a step. */ export interface BandPadding { /** Space between bands; defaults to 0.2. */ readonly inner?: number; /** Space before the first band and after the last; defaults to 0.2. */ readonly outer?: number; } /** The angles a pie spans, for {@link pieArcs}. */ export interface PieOptions { /** Where the first slice starts, in radians clockwise from 12 o'clock. */ readonly startAngle?: number; /** Where the last slice ends; defaults to a full turn after 0. */ readonly endAngle?: number; /** The gap between slices, in radians; defaults to 0. */ readonly padAngle?: number; } /** * The smallest and largest of some numbers. * * @param values - The numbers. * @returns `[min, max]`, or `[0, 0]` for none. */ export function extent(values: readonly number[]): [number, number] { if (values.length === 0) { return [0, 0]; } return [Math.min(...values), Math.max(...values)]; } /** * A round step for about `count` ticks between two values: 1, 2 or 5 * times a power of ten. * * @param min - The lower value. * @param max - The upper value. * @param count - About how many ticks. * @returns The step. */ function tickStep(min: number, max: number, count: number): number { const raw = Math.abs(max - min) / Math.max(1, count); const power = 10 ** Math.floor(Math.log10(raw || 1)); const error = raw / power; const factor = error >= 7.5 ? 10 : error >= 3.5 ? 5 : error >= 1.5 ? 2 : 1; return factor * power; } /** * Round values from `min` to `max`, about `count` of them. * * @param min - The lower value. * @param max - The upper value. * @param count - About how many; defaults to 5. * @returns The values, ascending. */ export function niceTicks(min: number, max: number, count = 5): number[] { if (min === max) { return [min]; } const step = tickStep(min, max, count); const start = Math.ceil(min / step) * step; const ticks: number[] = []; for (let value = start; value <= max + step / 1e6; value += step) { // Rounds away floating point noise, such as 0.30000000000000004. ticks.push(Number(value.toPrecision(12))); } return ticks; } /** * A linear scale. With `nice`, the domain extends to round values, so the * axis starts and ends on a tick. * * @param domain - The values, `[min, max]`. * @param range - The positions, `[start, end]`; reversed works, as for a * y axis that grows upward. * @param nice - Whether to round the domain out; defaults to `true`. * @returns The scale. * * @example * ```ts * const y = scaleLinear([0, 305], [200, 0]); * y(150); // 100 * ``` */ export function scaleLinear( domain: readonly [number, number], range: readonly [number, number], nice = true, ): LinearScale { let [d0, d1] = domain; if (nice && d0 !== d1) { const step = tickStep(d0, d1, 5); d0 = Math.floor(d0 / step) * step; d1 = Math.ceil(d1 / step) * step; } const [r0, r1] = range; const span = d1 - d0 || 1; const scale = ((value: number) => r0 + ((value - d0) / span) * (r1 - r0)) as LinearScale; Object.assign(scale, { domain: [d0, d1] as const, ticks: (count = 5) => niceTicks(d0, d1, count), }); return scale; } /** * A band scale: the range split into one band per category, with padding * between bands and at the ends, in fractions of a step. * * @param domain - The categories, in order. * @param range - The positions, `[start, end]`. * @param padding - Space between bands and at the ends. * @returns The scale. * * @example * ```ts * const x = scaleBand(['Jan', 'Feb'], [0, 400]); * x('Feb'); // where February's bar starts * x.bandwidth; // the bar's width * ``` */ export function scaleBand( domain: readonly string[], range: readonly [number, number], padding: BandPadding = {}, ): BandScale { const inner = padding.inner ?? 0.2; const outer = padding.outer ?? 0.2; const [r0, r1] = range; const step = (r1 - r0) / Math.max(1, domain.length - inner + outer * 2); const index = new Map(domain.map((key, i) => [key, i])); const scale = ((key: string) => { const i = index.get(key); return i === undefined ? Number.NaN : r0 + step * (outer + i); }) as BandScale; Object.assign(scale, { bandwidth: step * (1 - inner), step }); return scale; } /** Time intervals for ticks, shortest first. */ const INTERVALS = [ { days: 1, months: 0 }, { days: 7, months: 0 }, { days: 0, months: 1 }, { days: 0, months: 3 }, { days: 0, months: 12 }, ] as const; /** * A time scale: linear on timestamps, with ticks on round days, weeks, * months or years. * * @param domain - The dates, `[first, last]`. * @param range - The positions, `[start, end]`. * @returns The scale. * * @example * ```ts * const x = scaleTime([new Date(2026, 0, 1), new Date(2026, 5, 30)], [0, 600]); * x.ticks(6); // the 1st of each month * ``` */ export function scaleTime( domain: readonly [Date, Date], range: readonly [number, number], ): TimeScale { const [d0, d1] = domain.map((date) => date.getTime()) as [number, number]; const linear = scaleLinear([d0, d1], range, false); const scale = ((value: Date) => linear(value.getTime())) as TimeScale; Object.assign(scale, { ticks(count = 5) { const days = (d1 - d0) / 86_400_000; const interval = INTERVALS.find( (option) => days / (option.days || option.months * 30.4) <= count, ) ?? INTERVALS[4]; const first = new Date(d0); let date = interval.months > 0 ? new Date(first.getFullYear(), first.getMonth(), 1) : new Date(first.getFullYear(), first.getMonth(), first.getDate()); const ticks: Date[] = []; while (date.getTime() <= d1) { if (date.getTime() >= d0) { ticks.push(date); } date = interval.months > 0 ? new Date(date.getFullYear(), date.getMonth() + interval.months, 1) : new Date( date.getFullYear(), date.getMonth(), date.getDate() + interval.days, ); } return ticks; }, }); return scale; } /** * Formats a number for an SVG path, short and without noise. * * @param value - The number. * @returns Its text. */ function n(value: number): string { return String(Math.round(value * 100) / 100); } /** * The slopes of a monotone cubic through points, which never overshoot * them (Fritsch–Carlson). * * @param points - The points, by ascending x. * @returns One tangent per point. */ function monotoneTangents(points: readonly ChartPoint[]): number[] { const count = points.length; const slopes: number[] = []; for (let i = 0; i < count - 1; i++) { const a = points[i] as ChartPoint; const b = points[i + 1] as ChartPoint; slopes.push((b.y - a.y) / (b.x - a.x || 1)); } const tangents = points.map((_, i) => { if (i === 0) return slopes[0] ?? 0; if (i === count - 1) return slopes[count - 2] ?? 0; const before = slopes[i - 1] as number; const after = slopes[i] as number; return before * after <= 0 ? 0 : (before + after) / 2; }); for (let i = 0; i < count - 1; i++) { const slope = slopes[i] as number; if (slope === 0) { tangents[i] = 0; tangents[i + 1] = 0; continue; } const alpha = (tangents[i] as number) / slope; const beta = (tangents[i + 1] as number) / slope; const length = alpha * alpha + beta * beta; if (length > 9) { const tau = 3 / Math.sqrt(length); tangents[i] = tau * alpha * slope; tangents[i + 1] = tau * beta * slope; } } return tangents; } /** * The path segments through points, after a first `M`. * * @param points - The points, by ascending x. * @param curve - How to join them. * @returns The segments, as SVG path commands. */ function segments(points: readonly ChartPoint[], curve: ChartCurve): string { let path = ''; if (curve === 'monotone' && points.length > 2) { const tangents = monotoneTangents(points); for (let i = 0; i < points.length - 1; i++) { const a = points[i] as ChartPoint; const b = points[i + 1] as ChartPoint; const dx = (b.x - a.x) / 3; path += `C${n(a.x + dx)},${n(a.y + dx * (tangents[i] as number))},${n(b.x - dx)},${n(b.y - dx * (tangents[i + 1] as number))},${n(b.x)},${n(b.y)}`; } return path; } for (let i = 1; i < points.length; i++) { const point = points[i] as ChartPoint; if (curve === 'step') { const previous = points[i - 1] as ChartPoint; const middle = (previous.x + point.x) / 2; path += `H${n(middle)}V${n(point.y)}H${n(point.x)}`; } else { path += `L${n(point.x)},${n(point.y)}`; } } return path; } /** * An SVG path through points, for a line chart. * * @param points - The points, by ascending x. * @param curve - How to join them; defaults to `linear`. * @returns The path's `d`, or an empty string for no points. * * @example * ```html * <path [attr.d]="linePath(points(), 'monotone')" /> * ``` */ export function linePath( points: readonly ChartPoint[], curve: ChartCurve = 'linear', ): string { const [first] = points; return first ? `M${n(first.x)},${n(first.y)}${segments(points, curve)}` : ''; } /** * An SVG path filling between a line through points and a baseline, for * an area chart. With `lower` points, as for a stacked series, it fills * between the two lines. * * @param points - The top points, by ascending x. * @param baseline - The bottom's y, or the bottom points. * @param curve - How to join them; defaults to `linear`. * @returns The path's `d`, or an empty string for no points. */ export function areaPath( points: readonly ChartPoint[], baseline: number | readonly ChartPoint[], curve: ChartCurve = 'linear', ): string { const [first] = points; const last = points[points.length - 1]; if (!first || !last) { return ''; } const top = `M${n(first.x)},${n(first.y)}${segments(points, curve)}`; if (typeof baseline === 'number') { return `${top}L${n(last.x)},${n(baseline)}L${n(first.x)},${n(baseline)}Z`; } const lower = [...baseline].reverse(); const start = lower[0] as ChartPoint; return `${top}L${n(start.x)},${n(start.y)}${segments(lower, curve)}Z`; } /** * An SVG path for a slice of a ring, centered on 0,0: a pie slice when * `innerRadius` is 0, a donut segment or a radial bar otherwise. * * @param arc - The slice's angles, in radians clockwise from 12 o'clock. * @param innerRadius - The hole's radius; 0 for a pie. * @param outerRadius - The slice's radius. * @returns The path's `d`. * * @example * ```html * <path [attr.d]="arcPath(slice, 60, 100)" transform="translate(120 120)" /> * ``` */ export function arcPath( arc: Pick<ChartArc, 'startAngle' | 'endAngle'>, innerRadius: number, outerRadius: number, ): string { // A full circle as one arc has equal ends, which SVG draws as nothing. const end = Math.min(arc.endAngle, arc.startAngle + Math.PI * 2 - 1e-6); const point = (radius: number, angle: number) => `${n(radius * Math.sin(angle))},${n(-radius * Math.cos(angle))}`; const large = end - arc.startAngle > Math.PI ? 1 : 0; const outer = `M${point(outerRadius, arc.startAngle)}A${n(outerRadius)},${n(outerRadius)},0,${large},1,${point(outerRadius, end)}`; if (innerRadius <= 0) { return `${outer}L0,0Z`; } return `${outer}L${point(innerRadius, end)}A${n(innerRadius)},${n(innerRadius)},0,${large},0,${point(innerRadius, arc.startAngle)}Z`; } /** * The angles of a pie's slices, in order, each proportional to its value. * * @param values - The values; negatives count as 0. * @param options - The angles to span and the gap between slices. * @returns One arc per value. */ export function pieArcs( values: readonly number[], options: PieOptions = {}, ): ChartArc[] { const start = options.startAngle ?? 0; const span = (options.endAngle ?? Math.PI * 2) - start; const pad = options.padAngle ?? 0; const total = values.reduce((sum, value) => sum + Math.max(0, value), 0); let angle = start; return values.map((value, index) => { const share = total > 0 ? (Math.max(0, value) / total) * span : 0; const arc = { startAngle: angle + pad / 2, endAngle: Math.max(angle + pad / 2, angle + share - pad / 2), value, index, }; angle += share; return arc; }); } /** * Stacks series on top of each other, as for stacked bars or areas. * * @param series - Each series' values, in the same order. * @returns For each series, each value's `[bottom, top]`. * * @example * ```ts * stack([[1, 2], [3, 4]]); // [[[0, 1], [0, 2]], [[1, 4], [2, 6]]] * ``` */ export function stack( series: readonly (readonly number[])[], ): [number, number][][] { const totals: number[] = []; return series.map((values) => values.map((value, index) => { const bottom = totals[index] ?? 0; totals[index] = bottom + value; return [bottom, bottom + value]; }), ); }src/app/lib/utils.ts/** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core';Update the import paths to match your project
The files import each other through the
@/uiand@/libaliases.
Usage
import {
ChartAxis,
ChartContainer,
ChartCursor,
ChartGrid,
ChartLabel,
ChartLegend,
ChartLegendIndicator,
ChartLegendItem,
ChartPlot,
ChartTooltip,
ChartTooltipBody,
ChartTooltipIndicator,
ChartTooltipItem,
ChartTooltipLabel,
ChartTooltipList,
ChartTooltipName,
ChartTooltipValue,
scaleBand,
scaleLinear,
type ChartConfig,
} from '@/ui/chart/chart';<figure
ndChartContainer
[config]="config"
class="w-lg max-w-full flex-col gap-2"
>
<figcaption id="chart-bar-caption" class="text-sm font-medium">
Visitors, January to June 2026
</figcaption>
<svg
ndChartPlot
[attr.viewBox]="'0 0 ' + width + ' ' + height"
[xs]="centers"
[(active)]="active"
aria-labelledby="chart-bar-caption"
aria-describedby="chart-bar-hint"
>
<g ndChartGrid>
@for (tick of y.ticks(4); track tick) {
<line
x1="0"
[attr.x2]="width"
[attr.y1]="y(tick)"
[attr.y2]="y(tick)"
/>
}
</g>
@if (current(); as row) {
<rect
ndChartCursor
kind="band"
[attr.x]="x(row.month) - x.step * 0.1"
y="0"
[attr.width]="x.bandwidth + x.step * 0.2"
[attr.height]="height"
/>
}
@for (row of data; track row.month) {
<rect
[attr.x]="x(row.month)"
[attr.y]="y(row.desktop)"
[attr.width]="bar"
[attr.height]="y(0) - y(row.desktop)"
rx="4"
fill="var(--color-desktop)"
/>
<rect
[attr.x]="x(row.month) + bar"
[attr.y]="y(row.mobile)"
[attr.width]="bar"
[attr.height]="y(0) - y(row.mobile)"
rx="4"
fill="var(--color-mobile)"
/>
}
</svg>
<div ndChartAxis>
@for (row of data; track row.month; let i = $index) {
<span ndChartLabel [style.left.%]="((centers[i] ?? 0) / width) * 100">
{{ row.month.slice(0, 3) }}
</span>
}
</div>
@if (current(); as row) {
<div ndChartTooltip [style.left.%]="left()">
<div ndChartTooltipLabel>{{ row.month }}</div>
<div ndChartTooltipList>
@for (key of keys; track key) {
<div ndChartTooltipItem>
<span
ndChartTooltipIndicator
[color]="'var(--color-' + key + ')'"
></span>
<div ndChartTooltipBody>
<span ndChartTooltipName>{{ config[key]?.label }}</span>
<span ndChartTooltipValue>{{ row[key] }}</span>
</div>
</div>
}
</div>
</div>
}
<ul ndChartLegend>
@for (key of keys; track key) {
<li ndChartLegendItem>
<span
ndChartLegendIndicator
[color]="'var(--color-' + key + ')'"
></span>
{{ config[key]?.label }}
</li>
}
</ul>
<p id="chart-bar-hint" class="sr-only">
Use the arrow keys to read each month.
</p>
<p class="sr-only" aria-live="polite">{{ summary() }}</p>
<table class="sr-only">
<caption>
Visitors by month
</caption>
<thead>
<tr>
<th scope="col">Month</th>
<th scope="col">Desktop</th>
<th scope="col">Mobile</th>
</tr>
</thead>
<tbody>
@for (row of data; track row.month) {
<tr>
<th scope="row">{{ row.month }}</th>
<td>{{ row.desktop }}</td>
<td>{{ row.mobile }}</td>
</tr>
}
</tbody>
</table>
</figure>Examples
Chart line
Desktop and mobile visitors by month, as two smooth lines.
- Desktop
- Mobile
Use the arrow keys to read each month.
| Month | Desktop | Mobile |
|---|---|---|
| January | 186 | 80 |
| February | 305 | 200 |
| March | 237 | 120 |
| April | 73 | 190 |
| May | 209 | 130 |
| June | 214 | 140 |
import { Component, computed, signal } from '@angular/core';
import {
ChartAxis,
type ChartConfig,
ChartContainer,
ChartCursor,
ChartGrid,
ChartLabel,
ChartLegend,
ChartLegendIndicator,
ChartLegendItem,
ChartPlot,
ChartTooltip,
ChartTooltipBody,
ChartTooltipIndicator,
ChartTooltipItem,
ChartTooltipLabel,
ChartTooltipList,
ChartTooltipName,
ChartTooltipValue,
linePath,
scaleLinear,
} from '@/ui/chart/chart';
/** The plot's size, in viewBox units. */
const WIDTH = 500;
const HEIGHT = 225;
/**
* Desktop and mobile visitors by month, as two smooth lines. The arrow
* keys move between months, showing each one's values; a hidden table
* holds every value.
*/
@Component({
selector: 'example-chart-line',
imports: [
ChartAxis,
ChartContainer,
ChartCursor,
ChartGrid,
ChartLabel,
ChartLegend,
ChartLegendIndicator,
ChartLegendItem,
ChartPlot,
ChartTooltip,
ChartTooltipBody,
ChartTooltipIndicator,
ChartTooltipItem,
ChartTooltipLabel,
ChartTooltipList,
ChartTooltipName,
ChartTooltipValue,
],
template: `
<figure
ndChartContainer
[config]="config"
class="w-lg max-w-full flex-col gap-2"
>
<figcaption id="chart-line-caption" class="text-sm font-medium">
Visitors, January to June 2026
</figcaption>
<svg
ndChartPlot
[attr.viewBox]="'0 0 ' + width + ' ' + height"
[xs]="xs"
[(active)]="active"
aria-labelledby="chart-line-caption"
aria-describedby="chart-line-hint"
>
<g ndChartGrid>
@for (tick of y.ticks(4); track tick) {
<line
x1="0"
[attr.x2]="width"
[attr.y1]="y(tick)"
[attr.y2]="y(tick)"
/>
}
</g>
@if (active() !== null) {
<line
ndChartCursor
[attr.x1]="(left() * width) / 100"
[attr.x2]="(left() * width) / 100"
y1="0"
[attr.y2]="height"
/>
}
@for (key of keys; track key) {
<path
[attr.d]="lines()[key]"
fill="none"
[attr.stroke]="'var(--color-' + key + ')'"
stroke-width="2"
/>
@if (current(); as row) {
<circle
[attr.cx]="(left() * width) / 100"
[attr.cy]="y(row[key])"
r="4"
[attr.fill]="'var(--color-' + key + ')'"
/>
}
}
</svg>
<div ndChartAxis>
@for (
row of data;
track row.month;
let i = $index, first = $first, last = $last
) {
<span
ndChartLabel
[style.left.%]="((xs[i] ?? 0) / width) * 100"
[align]="first ? 'left' : last ? 'right' : 'center'"
>
{{ row.month.slice(0, 3) }}
</span>
}
</div>
@if (current(); as row) {
<div ndChartTooltip [style.left.%]="left()">
<div ndChartTooltipLabel>{{ row.month }}</div>
<div ndChartTooltipList>
@for (key of keys; track key) {
<div ndChartTooltipItem>
<span
ndChartTooltipIndicator
indicator="line"
[color]="'var(--color-' + key + ')'"
></span>
<div ndChartTooltipBody>
<span ndChartTooltipName>{{ config[key]?.label }}</span>
<span ndChartTooltipValue>{{ row[key] }}</span>
</div>
</div>
}
</div>
</div>
}
<ul ndChartLegend>
@for (key of keys; track key) {
<li ndChartLegendItem>
<span
ndChartLegendIndicator
[color]="'var(--color-' + key + ')'"
></span>
{{ config[key]?.label }}
</li>
}
</ul>
<p id="chart-line-hint" class="sr-only">
Use the arrow keys to read each month.
</p>
<p class="sr-only" aria-live="polite">{{ summary() }}</p>
<table class="sr-only">
<caption>
Visitors by month
</caption>
<thead>
<tr>
<th scope="col">Month</th>
<th scope="col">Desktop</th>
<th scope="col">Mobile</th>
</tr>
</thead>
<tbody>
@for (row of data; track row.month) {
<tr>
<th scope="row">{{ row.month }}</th>
<td>{{ row.desktop }}</td>
<td>{{ row.mobile }}</td>
</tr>
}
</tbody>
</table>
</figure>
`,
})
export class ChartLineExample {
protected readonly width = WIDTH;
protected readonly height = HEIGHT;
protected readonly keys = ['desktop', 'mobile'] as const;
protected readonly config: ChartConfig = {
desktop: { label: 'Desktop', color: 'var(--chart-1)' },
mobile: { label: 'Mobile', color: 'var(--chart-2)' },
};
protected readonly data = [
{ month: 'January', desktop: 186, mobile: 80 },
{ month: 'February', desktop: 305, mobile: 200 },
{ month: 'March', desktop: 237, mobile: 120 },
{ month: 'April', desktop: 73, mobile: 190 },
{ month: 'May', desktop: 209, mobile: 130 },
{ month: 'June', desktop: 214, mobile: 140 },
];
protected readonly xs = this.data.map(
(_, index) => (index / (this.data.length - 1)) * WIDTH,
);
protected readonly active = signal<number | null>(null);
protected readonly current = computed(() => {
const index = this.active();
return index === null ? null : this.data[index];
});
protected readonly left = computed(() => {
const index = this.active();
return index === null ? 0 : ((this.xs[index] ?? 0) / WIDTH) * 100;
});
protected readonly summary = computed(() => {
const row = this.current();
return row
? `${row.month}: desktop ${row.desktop}, mobile ${row.mobile}.`
: '';
});
protected readonly y = scaleLinear(
[0, Math.max(...this.data.map((row) => Math.max(row.desktop, row.mobile)))],
[HEIGHT, 8],
);
protected readonly lines = computed(() => ({
desktop: linePath(
this.data.map((row, index) => ({
x: this.xs[index] ?? 0,
y: this.y(row.desktop),
})),
'monotone',
),
mobile: linePath(
this.data.map((row, index) => ({
x: this.xs[index] ?? 0,
y: this.y(row.mobile),
})),
'monotone',
),
}));
}
Chart area
Desktop and mobile visitors by month, as stacked areas.
- Desktop
- Mobile
Use the arrow keys to read each month.
| Month | Desktop | Mobile |
|---|---|---|
| January | 186 | 80 |
| February | 305 | 200 |
| March | 237 | 120 |
| April | 73 | 190 |
| May | 209 | 130 |
| June | 214 | 140 |
import { Component, computed, signal } from '@angular/core';
import {
areaPath,
ChartAxis,
type ChartConfig,
ChartContainer,
ChartCursor,
ChartGrid,
ChartLabel,
ChartLegend,
ChartLegendIndicator,
ChartLegendItem,
ChartPlot,
ChartTooltip,
ChartTooltipBody,
ChartTooltipIndicator,
ChartTooltipItem,
ChartTooltipLabel,
ChartTooltipList,
ChartTooltipName,
ChartTooltipValue,
scaleLinear,
stack,
} from '@/ui/chart/chart';
/** The plot's size, in viewBox units. */
const WIDTH = 500;
const HEIGHT = 225;
/**
* Desktop and mobile visitors by month, as stacked areas: mobile sits on
* top of desktop. The arrow keys move between months; a hidden table holds
* every value.
*/
@Component({
selector: 'example-chart-area',
imports: [
ChartAxis,
ChartContainer,
ChartCursor,
ChartGrid,
ChartLabel,
ChartLegend,
ChartLegendIndicator,
ChartLegendItem,
ChartPlot,
ChartTooltip,
ChartTooltipBody,
ChartTooltipIndicator,
ChartTooltipItem,
ChartTooltipLabel,
ChartTooltipList,
ChartTooltipName,
ChartTooltipValue,
],
template: `
<figure
ndChartContainer
[config]="config"
class="w-lg max-w-full flex-col gap-2"
>
<figcaption id="chart-area-caption" class="text-sm font-medium">
Visitors, January to June 2026
</figcaption>
<svg
ndChartPlot
[attr.viewBox]="'0 0 ' + width + ' ' + height"
[xs]="xs"
[(active)]="active"
aria-labelledby="chart-area-caption"
aria-describedby="chart-area-hint"
>
<g ndChartGrid>
@for (tick of y.ticks(4); track tick) {
<line
x1="0"
[attr.x2]="width"
[attr.y1]="y(tick)"
[attr.y2]="y(tick)"
/>
}
</g>
@for (key of keys; track key) {
<path
[attr.d]="areas()[key]"
[attr.fill]="'var(--color-' + key + ')'"
fill-opacity="0.4"
[attr.stroke]="'var(--color-' + key + ')'"
/>
}
@if (active() !== null) {
<line
ndChartCursor
[attr.x1]="(left() * width) / 100"
[attr.x2]="(left() * width) / 100"
y1="0"
[attr.y2]="height"
/>
}
</svg>
<div ndChartAxis>
@for (
row of data;
track row.month;
let i = $index, first = $first, last = $last
) {
<span
ndChartLabel
[style.left.%]="((xs[i] ?? 0) / width) * 100"
[align]="first ? 'left' : last ? 'right' : 'center'"
>
{{ row.month.slice(0, 3) }}
</span>
}
</div>
@if (current(); as row) {
<div ndChartTooltip [style.left.%]="left()">
<div ndChartTooltipLabel>{{ row.month }}</div>
<div ndChartTooltipList>
@for (key of keys; track key) {
<div ndChartTooltipItem>
<span
ndChartTooltipIndicator
indicator="dashed"
[color]="'var(--color-' + key + ')'"
></span>
<div ndChartTooltipBody>
<span ndChartTooltipName>{{ config[key]?.label }}</span>
<span ndChartTooltipValue>{{ row[key] }}</span>
</div>
</div>
}
</div>
</div>
}
<ul ndChartLegend>
@for (key of keys; track key) {
<li ndChartLegendItem>
<span
ndChartLegendIndicator
[color]="'var(--color-' + key + ')'"
></span>
{{ config[key]?.label }}
</li>
}
</ul>
<p id="chart-area-hint" class="sr-only">
Use the arrow keys to read each month.
</p>
<p class="sr-only" aria-live="polite">{{ summary() }}</p>
<table class="sr-only">
<caption>
Visitors by month
</caption>
<thead>
<tr>
<th scope="col">Month</th>
<th scope="col">Desktop</th>
<th scope="col">Mobile</th>
</tr>
</thead>
<tbody>
@for (row of data; track row.month) {
<tr>
<th scope="row">{{ row.month }}</th>
<td>{{ row.desktop }}</td>
<td>{{ row.mobile }}</td>
</tr>
}
</tbody>
</table>
</figure>
`,
})
export class ChartAreaExample {
protected readonly width = WIDTH;
protected readonly height = HEIGHT;
protected readonly keys = ['desktop', 'mobile'] as const;
protected readonly config: ChartConfig = {
desktop: { label: 'Desktop', color: 'var(--chart-1)' },
mobile: { label: 'Mobile', color: 'var(--chart-2)' },
};
protected readonly data = [
{ month: 'January', desktop: 186, mobile: 80 },
{ month: 'February', desktop: 305, mobile: 200 },
{ month: 'March', desktop: 237, mobile: 120 },
{ month: 'April', desktop: 73, mobile: 190 },
{ month: 'May', desktop: 209, mobile: 130 },
{ month: 'June', desktop: 214, mobile: 140 },
];
protected readonly xs = this.data.map(
(_, index) => (index / (this.data.length - 1)) * WIDTH,
);
protected readonly active = signal<number | null>(null);
protected readonly current = computed(() => {
const index = this.active();
return index === null ? null : this.data[index];
});
protected readonly left = computed(() => {
const index = this.active();
return index === null ? 0 : ((this.xs[index] ?? 0) / WIDTH) * 100;
});
protected readonly summary = computed(() => {
const row = this.current();
return row
? `${row.month}: desktop ${row.desktop}, mobile ${row.mobile}.`
: '';
});
/** Mobile stacked on desktop: each value's bottom and top. */
protected readonly stacked = stack([
this.data.map((row) => row.desktop),
this.data.map((row) => row.mobile),
]);
protected readonly y = scaleLinear(
[0, Math.max(...(this.stacked[1] ?? []).map(([, top]) => top))],
[HEIGHT, 8],
);
protected readonly areas = computed(() => {
const points = (series: number, edge: 0 | 1) =>
(this.stacked[series] ?? []).map((pair, index) => ({
x: this.xs[index] ?? 0,
y: this.y(pair[edge]),
}));
return {
desktop: areaPath(points(0, 1), this.y(0), 'monotone'),
mobile: areaPath(points(1, 1), points(1, 0), 'monotone'),
};
});
}
Chart pie
Visitors by browser, as a donut with the total in its middle.
- Chrome
- Safari
- Firefox
- Edge
- Other
Use the arrow keys to read each browser.
| Browser | Visitors |
|---|---|
| Chrome | 275 |
| Safari | 200 |
| Firefox | 187 |
| Edge | 173 |
| Other | 90 |
import { Component, computed, signal } from '@angular/core';
import {
arcPath,
type ChartConfig,
ChartContainer,
ChartLabel,
ChartLegend,
ChartLegendIndicator,
ChartLegendItem,
ChartPlot,
ChartTooltip,
ChartTooltipBody,
ChartTooltipIndicator,
ChartTooltipItem,
ChartTooltipList,
ChartTooltipName,
ChartTooltipValue,
pieArcs,
} from '@/ui/chart/chart';
/**
* Visitors by browser, as a donut with the total in its middle. The
* arrow keys move between browsers, enlarging each slice; a hidden table
* holds every value.
*/
@Component({
selector: 'example-chart-pie',
imports: [
ChartContainer,
ChartLabel,
ChartLegend,
ChartLegendIndicator,
ChartLegendItem,
ChartPlot,
ChartTooltip,
ChartTooltipBody,
ChartTooltipIndicator,
ChartTooltipItem,
ChartTooltipList,
ChartTooltipName,
ChartTooltipValue,
],
template: `
<figure
ndChartContainer
[config]="config"
class="aspect-square w-xs max-w-full flex-col items-center gap-2"
>
<figcaption id="chart-pie-caption" class="text-sm font-medium">
Visitors by browser, June 2026
</figcaption>
<div class="relative w-full">
<svg
ndChartPlot
viewBox="-125 -125 250 250"
[xs]="indexes"
trackPointer="false"
[(active)]="active"
aria-labelledby="chart-pie-caption"
aria-describedby="chart-pie-hint"
>
@for (slice of slices; track slice.index) {
<path
[attr.d]="
arcPath(slice, 60, active() === slice.index ? 110 : 100)
"
[attr.fill]="'var(--color-' + data[slice.index]?.browser + ')'"
class="stroke-background"
stroke-width="2"
(pointerenter)="active.set(slice.index)"
/>
}
</svg>
<div
ndChartLabel
class="top-1/2 left-1/2 flex -translate-y-1/2 flex-col items-center"
>
<span class="text-3xl font-bold text-foreground tabular-nums">{{
total
}}</span>
<span class="text-muted-foreground">Visitors</span>
</div>
</div>
@if (current(); as row) {
<div ndChartTooltip class="left-1/2 top-8">
<div ndChartTooltipList>
<div ndChartTooltipItem>
<span
ndChartTooltipIndicator
[color]="'var(--color-' + row.browser + ')'"
></span>
<div ndChartTooltipBody>
<span ndChartTooltipName>{{ config[row.browser]?.label }}</span>
<span ndChartTooltipValue>{{ row.visitors }}</span>
</div>
</div>
</div>
</div>
}
<ul ndChartLegend class="flex-wrap">
@for (row of data; track row.browser) {
<li ndChartLegendItem>
<span
ndChartLegendIndicator
[color]="'var(--color-' + row.browser + ')'"
></span>
{{ config[row.browser]?.label }}
</li>
}
</ul>
<p id="chart-pie-hint" class="sr-only">
Use the arrow keys to read each browser.
</p>
<p class="sr-only" aria-live="polite">{{ summary() }}</p>
<table class="sr-only">
<caption>
Visitors by browser
</caption>
<thead>
<tr>
<th scope="col">Browser</th>
<th scope="col">Visitors</th>
</tr>
</thead>
<tbody>
@for (row of data; track row.browser) {
<tr>
<th scope="row">{{ config[row.browser]?.label }}</th>
<td>{{ row.visitors }}</td>
</tr>
}
</tbody>
</table>
</figure>
`,
})
export class ChartPieExample {
protected readonly config: ChartConfig = {
chrome: { label: 'Chrome', color: 'var(--chart-1)' },
safari: { label: 'Safari', color: 'var(--chart-2)' },
firefox: { label: 'Firefox', color: 'var(--chart-3)' },
edge: { label: 'Edge', color: 'var(--chart-4)' },
other: { label: 'Other', color: 'var(--chart-5)' },
};
protected readonly data = [
{ browser: 'chrome', visitors: 275 },
{ browser: 'safari', visitors: 200 },
{ browser: 'firefox', visitors: 187 },
{ browser: 'edge', visitors: 173 },
{ browser: 'other', visitors: 90 },
];
protected readonly indexes = this.data.map((_, index) => index);
protected readonly active = signal<number | null>(null);
protected readonly current = computed(() => {
const index = this.active();
return index === null ? null : this.data[index];
});
protected readonly summary = computed(() => {
const row = this.current();
return row
? `${this.config[row.browser]?.label}: ${row.visitors} visitors.`
: '';
});
protected readonly arcPath = arcPath;
protected readonly slices = pieArcs(
this.data.map((row) => row.visitors),
{ padAngle: 0.02 },
);
protected readonly total = this.data.reduce(
(sum, row) => sum + row.visitors,
0,
);
}
Chart radial
Visitors by browser, as bars around rings.
- Chrome
- Safari
- Firefox
- Edge
- Other
Use the arrow keys to read each browser.
| Browser | Visitors |
|---|---|
| Chrome | 275 |
| Safari | 200 |
| Firefox | 187 |
| Edge | 173 |
| Other | 90 |
import { Component, computed, signal } from '@angular/core';
import {
arcPath,
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendIndicator,
ChartLegendItem,
ChartPlot,
ChartTooltip,
ChartTooltipBody,
ChartTooltipIndicator,
ChartTooltipItem,
ChartTooltipList,
ChartTooltipName,
ChartTooltipValue,
} from '@/ui/chart/chart';
/**
* Visitors by browser, as bars around rings: each bar's length is its
* share of the most visited browser's. The arrow keys move between
* browsers; a hidden table holds every value.
*/
@Component({
selector: 'example-chart-radial',
imports: [
ChartContainer,
ChartLegend,
ChartLegendIndicator,
ChartLegendItem,
ChartPlot,
ChartTooltip,
ChartTooltipBody,
ChartTooltipIndicator,
ChartTooltipItem,
ChartTooltipList,
ChartTooltipName,
ChartTooltipValue,
],
template: `
<figure
ndChartContainer
[config]="config"
class="aspect-square w-xs max-w-full flex-col items-center gap-2"
>
<figcaption id="chart-radial-caption" class="text-sm font-medium">
Visitors by browser, June 2026
</figcaption>
<svg
ndChartPlot
viewBox="-125 -125 250 250"
[xs]="indexes"
trackPointer="false"
[(active)]="active"
aria-labelledby="chart-radial-caption"
aria-describedby="chart-radial-hint"
>
@for (row of data; track row.browser; let i = $index) {
<path
[attr.d]="
arcPath({ startAngle: 0, endAngle: 2 * PI }, ring(i) - 8, ring(i))
"
class="fill-muted"
/>
<path
[attr.d]="
arcPath(
{ startAngle: 0, endAngle: angle(row.visitors) },
ring(i) - 8,
ring(i)
)
"
[attr.fill]="'var(--color-' + row.browser + ')'"
[attr.opacity]="active() === null || active() === i ? 1 : 0.4"
(pointerenter)="active.set(i)"
/>
}
</svg>
@if (current(); as row) {
<div ndChartTooltip class="left-1/2 top-8">
<div ndChartTooltipList>
<div ndChartTooltipItem>
<span
ndChartTooltipIndicator
[color]="'var(--color-' + row.browser + ')'"
></span>
<div ndChartTooltipBody>
<span ndChartTooltipName>{{ config[row.browser]?.label }}</span>
<span ndChartTooltipValue>{{ row.visitors }}</span>
</div>
</div>
</div>
</div>
}
<ul ndChartLegend class="flex-wrap">
@for (row of data; track row.browser) {
<li ndChartLegendItem>
<span
ndChartLegendIndicator
[color]="'var(--color-' + row.browser + ')'"
></span>
{{ config[row.browser]?.label }}
</li>
}
</ul>
<p id="chart-radial-hint" class="sr-only">
Use the arrow keys to read each browser.
</p>
<p class="sr-only" aria-live="polite">{{ summary() }}</p>
<table class="sr-only">
<caption>
Visitors by browser
</caption>
<thead>
<tr>
<th scope="col">Browser</th>
<th scope="col">Visitors</th>
</tr>
</thead>
<tbody>
@for (row of data; track row.browser) {
<tr>
<th scope="row">{{ config[row.browser]?.label }}</th>
<td>{{ row.visitors }}</td>
</tr>
}
</tbody>
</table>
</figure>
`,
})
export class ChartRadialExample {
protected readonly config: ChartConfig = {
chrome: { label: 'Chrome', color: 'var(--chart-1)' },
safari: { label: 'Safari', color: 'var(--chart-2)' },
firefox: { label: 'Firefox', color: 'var(--chart-3)' },
edge: { label: 'Edge', color: 'var(--chart-4)' },
other: { label: 'Other', color: 'var(--chart-5)' },
};
protected readonly data = [
{ browser: 'chrome', visitors: 275 },
{ browser: 'safari', visitors: 200 },
{ browser: 'firefox', visitors: 187 },
{ browser: 'edge', visitors: 173 },
{ browser: 'other', visitors: 90 },
];
protected readonly indexes = this.data.map((_, index) => index);
protected readonly active = signal<number | null>(null);
protected readonly current = computed(() => {
const index = this.active();
return index === null ? null : this.data[index];
});
protected readonly summary = computed(() => {
const row = this.current();
return row
? `${this.config[row.browser]?.label}: ${row.visitors} visitors.`
: '';
});
protected readonly arcPath = arcPath;
protected readonly PI = Math.PI;
private readonly most = Math.max(...this.data.map((row) => row.visitors));
/**
* A browser's ring, from the outside in.
*
* @param index - The browser's index.
* @returns The ring's outer radius.
*/
protected ring(index: number): number {
return 110 - index * 16;
}
/**
* How far a value's bar goes around its ring.
*
* @param visitors - The value.
* @returns The bar's end angle, in radians.
*/
protected angle(visitors: number): number {
return (visitors / this.most) * Math.PI * 1.5;
}
}
Accessibility
Chart has no keyboard interaction or focus handling of its own; it keeps the semantics of the element it's on.
API reference
ChartContainer
[ndChartContainer]A chart's frame: it sets --color-<key> for each series in config,
so the SVG, tooltip and legend use var(--color-desktop), and keeps
a video aspect ratio. Put it on a figure with a figcaption.
| ChartContainer binding | Type | Default |
|---|---|---|
[config]Each series' label and color, by key. | ChartConfig | {} |
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | chart |
ChartPlot
svg[ndChartPlot]The plot, on the chart's svg: one tab stop where the arrow keys,
Home and End move between data points and the pointer picks the
nearest one, setting active. Bind a tooltip and a polite live region
to active, so the keyboard and screen readers reach each value. Give
it xs, each point's horizontal position in viewBox units, and name
it with aria-label or aria-labelledby.
It spans the chart's width at its viewBox's aspect ratio, so a
position in viewBox units is the same percentage of the plot's box,
where the HTML labels and the tooltip go. For a fixed height instead,
set one with a class and preserveAspectRatio="none", and give your
lines and outlines vector-effect="non-scaling-stroke" so they keep
their width, as the grid and cursor do.
| ChartPlot binding | Type | Default |
|---|---|---|
[xs]Each data point's horizontal position, in viewBox units. | readonly number[] | [] |
[trackPointer]Whether the pointer picks the point nearest it horizontally; turn it
off for pies, whose slices set active on hover themselves. | boolean | true |
[class]Extra classes, merged last so they win. | string | '' |
[(active)]The index of the active data point, or null. Two-way bindable. | number | null | null |
| Data attribute | Values |
|---|---|
data-slot | chart-plot |
data-active | — |
ChartGrid
[ndChartGrid]The grid lines behind the data, on a g. They stay a pixel wide
whatever the plot's size.
| ChartGrid binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | chart-grid |
ChartAxis
[ndChartAxis]The x axis' labels, in a band under the plot. They're HTML rather than
SVG text, so they keep their size at every width instead of scaling
with the plot. Put a ChartLabel in it for each tick, at the tick's
position across the plot in percent. Screen readers skip it: the
chart's table holds the same labels.
| ChartAxis binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | chart-axis |
ChartLabel
[ndChartLabel]A label at a point of the chart, such as an axis tick or a value, in
HTML so it keeps its size at every width. It sits at its left, a
percentage of the plot's width, centered on it by default; a label at
the plot's edge aligns left or right instead, so it stays within
the plot. In a ChartAxis it sits at the top of the band. Over the
plot, put the plot and the label in a relative box and set its top
too, centering it with -translate-y-1/2.
Like the plot, it isn't mirrored in right-to-left layouts, so it's
placed with left and aligns to physical sides, not start and end.
| ChartLabel binding | Type | Default |
|---|---|---|
[align]Which of its sides is at its left: center for its middle, left
for its left edge (it extends right), right for its right edge. | 'center' | 'left' | 'right' | 'center' |
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | chart-label |
data-align | — |
ChartCursor
[ndChartCursor]The marker of the active data point: a line, which stays a pixel wide whatever the plot's size, or a band behind bars.
| ChartCursor binding | Type | Default |
|---|---|---|
[kind]line for line and area charts, band for bars. | 'line' | 'band' | 'line' |
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | chart-cursor |
data-kind | — |
ChartTooltip
[ndChartTooltip]The tooltip of the active data point, drawn over the chart. It's for sighted users; a polite live region tells screen readers the same.
| ChartTooltip binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | chart-tooltip |
ChartTooltipLabel
[ndChartTooltipLabel]The tooltip's heading, such as the month.
| ChartTooltipLabel binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | chart-tooltip-label |
ChartTooltipList
[ndChartTooltipList]The list of a tooltip's series.
| ChartTooltipList binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | chart-tooltip-list |
ChartTooltipItem
[ndChartTooltipItem]One series in a tooltip: its indicator, name and value.
| ChartTooltipItem binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | chart-tooltip-item |
ChartTooltipIndicator
[ndChartTooltipIndicator]A series' color swatch in a tooltip: a dot, a line or a dashed
line.
| ChartTooltipIndicator binding | Type | Default |
|---|---|---|
[indicator]Its shape. | 'line' | 'dot' | 'dashed' | 'dot' |
[color]The series' color. | string | 'currentColor' |
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | chart-tooltip-indicator |
ChartTooltipBody
[ndChartTooltipBody]The name and value of a series in a tooltip, spread across its width.
| ChartTooltipBody binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | chart-tooltip-body |
ChartTooltipName
[ndChartTooltipName]A series' name in a tooltip.
| ChartTooltipName binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | chart-tooltip-name |
ChartTooltipValue
[ndChartTooltipValue]A series' value in a tooltip, in tabular figures.
| ChartTooltipValue binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | chart-tooltip-value |
ChartLegend
[ndChartLegend]The legend: the series' swatches and names, under the chart.
| ChartLegend binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | chart-legend |
ChartLegendItem
[ndChartLegendItem]One series in the legend.
| ChartLegendItem binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | chart-legend-item |
ChartLegendIndicator
[ndChartLegendIndicator]A series' color swatch in the legend.
| ChartLegendIndicator binding | Type | Default |
|---|---|---|
[color]The series' color. | string | 'currentColor' |
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | chart-legend-indicator |
Agent notes
Do
- Put the chart in a
figurewith afigcaptionthat says what it shows, and name the plot after it. - Bind a polite live region to the active point, and include a table of the data, hidden or behind a toggle.
- Use the theme's chart colors,
var(--chart-1)tovar(--chart-5). - Put labels in
ChartLabels rather than SVGtext, which shrinks on phones and grows on wide screens. - Align a label at the plot's edge with
align="left"oralign="right", so it stays within the plot instead of hanging half outside it.
Don't
- Don't rely on color alone: label series in the legend and tooltip.
- Don't animate values on load; it delays reading them.