Skip to content
Ndop UI

Chart

SVG charts you own: bar, line, area, pie and radial, with tooltips and legends.

  • v1.0.0
  • SSR
  • Zoneless
  • 2.2 kB gzip
Visitors, January to June 2026
  • Desktop
  • Mobile

Use the arrow keys to read each month.

Visitors by month
MonthDesktopMobile
January18680
February305200
March237120
April73190
May209130
June214140
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 chart

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.

Visitors, January to June 2026
  • Desktop
  • Mobile

Use the arrow keys to read each month.

Visitors by month
MonthDesktopMobile
January18680
February305200
March237120
April73190
May209130
June214140
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.

Visitors, January to June 2026
  • Desktop
  • Mobile

Use the arrow keys to read each month.

Visitors by month
MonthDesktopMobile
January18680
February305200
March237120
April73190
May209130
June214140
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.

Visitors by browser, June 2026
925Visitors
  • Chrome
  • Safari
  • Firefox
  • Edge
  • Other

Use the arrow keys to read each browser.

Visitors by browser
BrowserVisitors
Chrome275
Safari200
Firefox187
Edge173
Other90
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.

Visitors by browser, June 2026
  • Chrome
  • Safari
  • Firefox
  • Edge
  • Other

Use the arrow keys to read each browser.

Visitors by browser
BrowserVisitors
Chrome275
Safari200
Firefox187
Edge173
Other90
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-slotchart

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.
booleantrue
[class]
Extra classes, merged last so they win.
string''
[(active)]
The index of the active data point, or null. Two-way bindable.
number | nullnull
Data attribute Values
data-slotchart-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-slotchart-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-slotchart-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-slotchart-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-slotchart-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-slotchart-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-slotchart-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-slotchart-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-slotchart-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-slotchart-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-slotchart-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-slotchart-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-slotchart-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-slotchart-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-slotchart-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-slotchart-legend-indicator

Agent notes

Do

  • Put the chart in a figure with a figcaption that 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) to var(--chart-5).
  • Put labels in ChartLabels rather than SVG text, which shrinks on phones and grows on wide screens.
  • Align a label at the plot's edge with align="left" or align="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.