npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@x12i/charts

v1.1.0

Published

Charts for the Unified UI Kit: one JSON ChartSpec, a token-themed SVG renderer, an optional ECharts canvas renderer for large data, and accessible text alternatives.

Readme

@x12i/charts

Accessible charts for the Unified UI Kit. You describe a chart as one JSON-serializable ChartSpec; the package draws it as SVG with the kit's design tokens, adds a legend, tooltip, keyboard navigation, loading/empty/error states, a generated text description and a data table, and switches to an ECharts canvas for very large data.

  • 16 chart types: bar, lollipop, line, area, step, sparkline, combo (bars and lines, optionally on two axes), scatter, bubble, histogram, waterfall, donut, pie, heatmap, gauge and progress ring.
  • Accessible by construction: every chart has a name, a description, keyboard navigation, a screen-reader announcement per point and a "View as table" toggle. Color is never the only way to tell series apart.
  • Tokens only: no colors, sizes or fonts of its own. Light, dark and high-contrast themes work through data-theme.
  • Right to left and localized: logical CSS, mirrored axes, Intl for numbers, dates and lists, and a message catalog for every word it shows.
  • Server-safe: the root entry has no React and no DOM.

Install

pnpm add @x12i/charts @x12i/ui @x12i/themes react react-dom
# Only if you want the canvas renderer (see "Performance"):
pnpm add echarts

@x12i/ui, react and react-dom are peer dependencies; echarts is an optional peer dependency. The package depends on @x12i/tokens and a few small D3 modules (d3-array, d3-scale, d3-shape, d3-time, d3-format, d3-time-format; all ISC). It is ESM only.

Import both stylesheets once, and set the theme on a parent element:

import "@x12i/ui/styles.css";
import "@x12i/charts/styles.css";

The stylesheet is made of zero-specificity rules (:where(...)) on data-kit="chart-*" hooks, so your own CSS always wins without !important.

Two entry points

| Import | Contents | Needs | | --- | --- | --- | | @x12i/charts | The headless core: ChartSpec types and defineChartSpec, validation, data transforms, statistics, scales and layout math, formatting, message catalog, text descriptions, data tables and CSV, the ECharts option builder. | Nothing. No React, no DOM, no browser globals. Safe in server code, workers and scripts. | | @x12i/charts/react | Chart, ChartFrame, ChartCard, ChartGrid, ChartDataTable, ChartLegend, ChartState, ChartConfigProvider, SvgChart, EChartsChart. | React 19, @x12i/ui. |

dist/react.js starts with "use client", so the components work in a Next.js App Router tree without wrapping. The root entry has no directive and runs in server components, route handlers and workers.

Server rendering

Chart renders on the server. Without a width it uses a fixed default width (640) on the server and on the first client render, so hydration matches; then it measures its container and re-draws to fit. Pass width and height for charts that must not move. The default locale is the fixed "en", never the runtime's, so the server and browser print identical text. Pass locale (or set it once with ChartConfigProvider) to localize.

// A server component, route handler or script: no React involved.
import { chartToCsv, describeChart, validateChartSpec } from "@x12i/charts";

const issues = validateChartSpec(spec);
const { name, text } = describeChart(spec);   // the screen-reader description
const csv = chartToCsv(spec, { locale: "de" });

Quick start

import { ChartFrame } from "@x12i/charts/react";
import type { ChartSpec } from "@x12i/charts";

// Define specs outside the component, or memoize them. A new object on every
// render makes the chart rebuild its model on every render.
const tickets: ChartSpec = {
  title: "Platform has the most open tickets",
  source: "Support desk, 1 Nov 2025",
  data: {
    values: [
      { team: "Platform", tickets: 42 },
      { team: "Growth", tickets: 31 },
      { team: "Payments", tickets: 18 },
      { team: "Search", tickets: 12 },
    ],
  },
  mark: "bar",
  x: { field: "team", type: "category", title: "Team" },
  y: { field: "tickets", type: "number", title: "Open tickets" },
  highlight: { where: "team == 'Platform'" },
  labels: { values: true },
  a11y: { description: "Platform has 42 open tickets, more than any other team." },
};

export function TicketsChart() {
  return <ChartFrame spec={tickets} />;
}

ChartFrame is a <figure> with a heading (the title states the finding), a subtitle, a toolbar (View as table, Download CSV), the chart, the source line and the data table. Use Chart alone when you supply your own frame, and ChartCard for the same frame on a bordered surface.

Line

const revenue: ChartSpec = {
  title: "Revenue outgrew cost every month",
  subtitle: "Monthly, USD thousands",
  source: "Finance ledger",
  data: {
    values: [
      { month: "2025-01-01", revenue: 120, cost: 90 },
      { month: "2025-02-01", revenue: 135, cost: 96 },
      { month: "2025-03-01", revenue: 128, cost: 99 },
      { month: "2025-04-01", revenue: 160, cost: 104 },
    ],
  },
  mark: "line",
  x: { field: "month", type: "time", title: "Month" },
  y: { field: "revenue", type: "number", title: "USD thousands" },
  series: [
    { id: "revenue", label: "Revenue", field: "revenue" },
    { id: "cost", label: "Cost", field: "cost" },
  ],
  annotations: [{ type: "reference-line", y: 130, label: "Target" }],
  a11y: { description: "Revenue rose from 120 to 160 while cost rose from 90 to 104." },
};

<ChartFrame spec={revenue} />;

Series that differ in kind are listed in series. Without series, one series is drawn from y, or one per value of color: { field }.

Bar variants

// Grouped (default), stacked, normalized to 100%, or horizontal:
{ mark: "bar", color: { field: "region", type: "category" } }
{ mark: "bar", color: { field: "region", type: "category" }, stack: "stacked" }
{ mark: "bar", color: { field: "region", type: "category" }, stack: "normalized" }
{ mark: "bar", orientation: "horizontal" }

Donut

const share: ChartSpec = {
  title: "Platform and Growth hold three quarters of tickets",
  data: { ref: "tickets" },                    // resolved from `datasets`
  mark: "donut",
  x: { field: "team", type: "category", title: "Team" },
  y: { field: "tickets", type: "number", title: "Open tickets" },
  donut: { centerCaption: "tickets" },         // the center shows the total
  a11y: { description: "Platform 41 percent, Growth 30 percent, the rest 29 percent." },
};

<ChartFrame spec={share} datasets={{ tickets: rows }} />;

Slices beyond donut.maxSlices (default 5) merge into "Other". Slices show their share; the legend toggles them.

Sparkline

import { Chart } from "@x12i/charts/react";

const trend: ChartSpec = {
  title: "Revenue trend",
  data: { values: monthly },
  mark: "sparkline",
  x: { field: "month", type: "time" },
  y: { field: "revenue", type: "number" },
  a11y: { description: "Revenue trends up." },
};

// 48 high by default. A non-interactive sparkline is a labelled image.
<Chart spec={trend} width={120} height={32} interactive={false} />;

Other types

| mark | Key fields | Notes | | --- | --- | --- | | area | x, y, optional color + stack | Stacked or normalized with stack. | | step | x, y | Steps instead of slopes. | | lollipop | x (category), y | A stem and a dot per category. | | combo | series with mark: "bar" \| "line" \| "area" \| "step" \| "scatter" | Second axis needs dualAxis: true and axis: "y2"; a visible warning is added. | | scatter | x (number), y | Optional color for groups. | | bubble | x, y, size | Bubble area encodes size. | | histogram | x (the measure) and bins: { count } or bins: { thresholds } | Bins the raw values. | | waterfall | x (steps), y (change), waterfall: { totals } | Increase, decrease and total bars with connectors. | | heatmap | x, y (categories), value | Sequential ramp, or diverging when values cross zero. | | gauge | gauge: { value, min, max, target, bands, format } | data: { values: [] } is fine for a literal value. | | progress-ring | gauge: { value, min, max, format: "percent" } | |

Everything else in chartTypes (for example treemap, sankey, stat) is reserved in the spec but not drawn yet; see "Limitations".

ChartSpec in brief

interface ChartSpec {
  title: string;                 // a sentence that states the finding (required)
  subtitle?: string;             // scope and units
  source?: string;               // where the data came from
  data: { ref: string } | { values: Row[] };
  mark: ChartType;
  x?: Encoding; y?: Encoding; color?: Encoding; size?: Encoding; value?: Encoding;
  series?: SeriesSpec[];         // explicit series: id, label, field | points, mark, axis, tone, marker, dash, stack, hidden, format
  stack?: "none" | "stacked" | "normalized" | "diverging";
  orientation?: "vertical" | "horizontal";
  scales?: { x?, y?, y2? };      // linear | log | sqrt | time | band | ordinal, domain, nice, zero, reverse, padding
  axes?: { x?, y?, y2? };        // show, title, grid, ticks, tickValues, format, labelAngle, maxLabelLength
  thresholds?: Threshold[];      // labelled limits; points that breach get an extra mark and a note
  annotations?: Annotation[];    // reference-line, reference-band, text, forecast, point (all labelled)
  transforms?: Transform[];      // aggregate, bin, stack, percent-of-total, sort, top-n, rolling-average, cumulative-sum, period-change, downsample
  highlight?: { where: string }; // one accent: `team == 'Platform'`
  legend?: { show?: boolean | "auto"; position?: "top" | "bottom" };
  labels?: { direct?: boolean | "auto"; values?: boolean };
  interactions?: { tooltip?; legendIsolate? };
  dualAxis?: boolean;
  renderer?: "svg" | "canvas" | "auto";
  direction?: "ltr" | "rtl" | "auto";
  timeZone?: string;             // IANA zone for formatting time values (default "UTC")
  a11y: { description: string; table?: boolean };   // description is required
}

Formats are named (compact, percent, integer, decimal, currency:USD, date, datetime, time, month, year), d3-format specifiers (,.2f) or d3-time-format patterns (%b %Y).

validateChartSpec(spec, { datasets }) returns { severity, code, path, message } issues for anything you can get wrong (missing title, unknown field, a bar chart with a truncated baseline, a second axis without dualAxis, too many series, too many rows for SVG, ...). isValidChartSpec is the boolean version.

Design rules the validator and defaults enforce: bars start at zero, one accent on grey, at most 8 categorical hues, dual axes only behind an explicit flag, targets and limits always labelled.

Component API

Chart / SvgChart / EChartsChart

| Prop | Type | Description | | --- | --- | --- | | spec | ChartSpec | The chart. Memoize it. | | datasets | Record<string, Row[]> | Rows for data: { ref }. Falls back to ChartConfigProvider. | | width, height | number | Layout units. Default: fill the container; height by type (320, sparkline 48, gauge 220). | | locale | string | BCP 47 tag for numbers, dates, lists and plurals. Default "en". | | messages | ChartMessagesInput | Overrides for every word the chart shows or announces. | | dir | "ltr" \| "rtl" | Text direction. Default: the page's dir, then the locale. | | renderer | "svg" \| "canvas" \| "auto" | Overrides spec.renderer. | | target | "screen" \| "print" \| "report" | print and report always use SVG. | | loadEcharts | () => Promise<{ init }> | Loads echarts for canvas. Default import("echarts"). | | loading, error, onRetry | boolean, unknown, () => void | Loading and error states at the chart's height. | | hiddenSeries, defaultHiddenSeries, onHiddenSeriesChange | string[] | Controlled or uncontrolled legend toggles. | | interactive | boolean | false draws a static, non-focusable labelled image. Default true. | | tooltip | boolean | Default spec.interactions.tooltip !== false. | | onPointActivate | (info: ChartPointInfo, event) => void | Click, Enter or Space on a point. | | onActivePointChange | (info \| null) => void | The hovered or focused point changed. | | className, style, id | | On the root element. |

SvgChart always draws SVG. EChartsChart always uses canvas (with an SVG fallback, see below). ChartPointInfo has seriesId, seriesLabel, seriesIndex, pointIndex, x, xLabel, y, size and row (the source row).

ChartFrame / ChartCard

Everything Chart takes, plus headingLevel (2-6, default 3), toolbar (default true), actions (extra buttons), view / defaultView / onViewChange ("chart" | "table"), tableMaxRows (default 200; the CSV always has every row), csvFileName, onDownload(csv, fileName) (replaces the browser download) and variant ("plain" | "card").

ChartGrid, ChartDataTable, ChartLegend, ChartState

ChartGrid lays charts out in a responsive grid (columns fixes the count, label makes it a named group). ChartDataTable is the data table on its own. ChartLegend and ColorScaleLegend and ChartState are the building blocks, exported for custom layouts.

ChartConfigProvider

Sets locale, messages, direction, datasets, loadEcharts and target for every chart inside. Providers nest and merge (the inner one wins; markNames merge key by key); props on a chart win over the provider.

<ChartConfigProvider locale="de" messages={deMessages} datasets={datasets}>
  <ChartFrame spec={spec} />
</ChartConfigProvider>

Theming

Series colors are --kit-color-chart-* tokens (accent, muted, reference, good, warning, critical, categorical-1 to categorical-8); heatmaps use the --kit-color-sequential-* and --kit-color-diverging-* ramps; axes and gridlines use --kit-color-chart-axis and --kit-color-chart-grid. Everything follows the theme set by ThemeProvider / data-theme (light, dark, high-contrast) and the density tokens. To change a color, override the token where you need it:

.my-dashboard {
  --kit-color-chart-categorical-1: var(--kit-color-accent-default);
}

Specs choose a tone by name (series: [{ id: "a", tone: "good" }]), never a color. The canvas renderer reads the same variables at runtime and redraws when data-theme changes on the page or an ancestor.

Every part of a chart has a hook: data-kit="chart", chart-svg, chart-line, chart-bar, chart-point, chart-arc, chart-cell, chart-axis, chart-tick-label, chart-legend, chart-tooltip, chart-state, chart-frame, chart-table and so on, with states as data-* attributes (data-hidden, data-active, data-highlighted, data-muted, data-breach, data-state, data-renderer). The stylesheet is tested to contain no hex or rgb() colors, no px/rem lengths, only logical properties and only existing --kit-* variables.

Accessibility

  • Name and description. The plot is role="application" with an aria-label of "{title} ({chart type})", aria-roledescription="chart" and an aria-describedby pointing to a generated description: chart type, series and point counts, ranges, trends, the highest and lowest points, thresholds and breaches, plus your own a11y.description.
  • Keyboard. Tab to the chart, then the arrow keys move between points: left and right along the categories, up and down between series at the same category; on a heatmap along both axes; on a scatter plot to the nearest point in that direction; through the slices of a pie. Home / End jump to the first and last category, PageUp / PageDown move ten categories, Enter or Space activates (onPointActivate), Escape dismisses the tooltip. In a right-to-left chart, left and right swap. A visible focus ring is drawn on the focused mark.
  • Screen readers. Each focused point is announced politely through a live region ("Mar 1, 2025, Cost: 99. 6 of 12."). The tooltip is for sighted users and is aria-hidden, so nothing is announced twice. The decorative SVG groups are aria-hidden.
  • Data table. ChartFrame always renders the data as a real <table> with caption, column headers and row headers: visible in table view, visually hidden (but in the accessibility tree) in chart view. Disable with a11y: { table: false }.
  • Color is never the only channel. Series differ by hue and marker shape and dash style (lines), or hatch pattern (bars, areas, slices; eight patterns cycle so hue + pattern pairs stay unique for 64 series). Threshold breaches get an extra mark and a note in the tooltip. Waterfall decreases are hatched, and the tooltip names every change with a sign. Status annotations carry text.
  • Legend. Entries are <button aria-pressed>; hidden series are removed from the plot, the description and the announcements.
  • Reduced motion. Animation is skipped when prefers-reduced-motion: reduce.
  • Forced colors. In Windows high-contrast mode, marks use system colors and keep their outlines and patterns.
  • Touch. A tap selects a point and keeps its tooltip after the finger lifts (a hovering mouse hides it when the pointer leaves).
  • States. Loading (role="status", aria-busy), empty, missing dataset and unsupported type use role="status"; errors use role="alert" with an optional retry. All reserve the chart's height so the page does not jump.

Tested with axe (vitest-axe) on every chart type, in chart and table view, in every state, in right-to-left, and on the canvas renderer, with zero violations. Axe cannot check color contrast in jsdom; contrast is guaranteed by the token pairs in @x12i/themes.

Internationalization

  • Every word the charts print or announce is in defaultMessages (86 keys: states, toolbar, table columns, tooltips, point announcements, the generated description, trends). Pass messages with any subset. Templates use an ICU MessageFormat subset: {name}, {n, number}, {n, plural, =0 {none} one {# row} other {# rows}} (# is the number) and {kind, select, up {rose} other {fell}}. Values you pass as text are shown as-is; numbers go through Intl for {n, number} and for plural rules. markNames renames chart types ("line chart").
  • Numbers, dates, times, percentages, compact numbers and lists go through Intl with your locale; the default is the fixed "en".
  • Right-to-left (Arabic, Hebrew, Persian, Urdu, ...): the chart sets dir, time and category axes mirror, the value axis moves to the other side, label anchors flip for right-to-left text and keyboard left/right swap. Numeric labels stay left-to-right. Direction comes from the dir prop, then the page's dir attribute (observed for changes), then the locale.
<ChartConfigProvider
  locale="ar"
  messages={{ viewAsTable: "عرض كجدول", downloadCsv: "تنزيل CSV", legend: "المفتاح" }}
>
  <ChartFrame spec={spec} />
</ChartConfigProvider>

Performance

| Data size | Renderer | Notes | | --- | --- | --- | | Up to ~5,000 points | SVG | Fully accessible, keyboard navigable, vector. Always used for target="print" and "report". | | Above 5,000 points | Canvas (renderer: "auto") | SVG_POINT_LIMIT = 5000. Needs echarts. |

Before reaching for canvas, thin the data: transforms: [{ type: "downsample", method: "lttb", field: "value", threshold: 1000 }] keeps the visual shape of a line with a fraction of the points, and aggregate / bin reduce rows before drawing. The 5,000-point threshold is a comfort limit, not a measured guarantee: DOM-based SVG gets heavier with every mark, so test on your slowest target device. Layout is memoized on the spec, size, locale, direction and hidden series, so memoize your spec.

The canvas renderer

EChartsChart, renderer="canvas" and renderer="auto" above the limit draw with Apache ECharts, loaded lazily by import("echarts") the first time a chart needs it (apps that only draw SVG never download it). Colors are read from your --kit-* variables, and the chart redraws on theme changes. The legend, title, frame, data table and text description are the same as for SVG.

If echarts cannot be loaded, or cannot draw the particular chart (for example a waterfall that crosses zero), the chart falls back to SVG and shows a note.

echarts is an optional peer, and bundlers resolve import("echarts") at build time, so if you do not install it your bundler may warn or fail on that import. Either install echarts, or pass your own loader, which also lets you register only the echarts modules you use:

import * as echarts from "echarts/core";
import { LineChart } from "echarts/charts";
import { GridComponent, TooltipComponent } from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";

echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer]);

<ChartConfigProvider loadEcharts={async () => echarts}>{children}</ChartConfigProvider>;

CSV and tables

chartToTable(spec) and chartToCsv(spec, { locale, delimiter, bom }) produce the same table the component shows; the CSV is RFC 4180 (CRLF line ends, quoted fields). The download button adds a UTF-8 byte-order mark so spreadsheets open it correctly, and onDownload lets you send the CSV to your own handler.

Headless API

Everything in @x12i/charts, by area:

  • Spec: defineChartSpec, chartTypes, the ChartSpec types.
  • Validation: validateChartSpec, isValidChartSpec.
  • Model and data: buildChartModel, supportedMarks, isSupportedMark, applyTransforms, resolveRows, countPoints, effectiveStack, readField, histogramBins, stackValues, lttb, minMax, compileWhere / evaluateWhere (the highlight.where expression language).
  • Statistics: mean, median, quantile, sum, variance, standardDeviation, extent, correlation, linearRegression, regressionBand, forecastLinear, movingAverage, cumulativeSum, percentChange, periodChange, trendDirection.
  • Scales and layout: createContinuousScale, createBandScale, createPointScale, niceTicks, fitTickLabels, layoutChart (spec model to plain geometry; the SVG component only draws it), patternIndex.
  • Interaction as pure functions: hitTest, navigate, tooltipFor, describeTarget, pointInfo, isNavKey.
  • Formatting and messages: createFormatters, createNumberFormatter, createDateFormatter, formatList, formatShare, isRtlLocale, DEFAULT_LOCALE, defaultMessages, resolveMessages, formatMessage.
  • Text alternatives and exports: describeChart, describeModel, chartToTable, modelToTable, chartToCsv, tableToCsv.
  • Renderer: resolveRenderer, SVG_POINT_LIMIT.
  • Canvas mapping: toEChartsOption, themeColorsFromTokens (no echarts import).

Because layout is a pure function, you can render a chart to your own target (a PDF, a canvas, an email) from layoutChart(buildChartModel(spec), { width, height, rtl }).

Limitations

Honest list of what is not there yet:

  • Chart types not drawn. chartTypes reserves 50 types so saved specs stay valid, but the renderer draws the 16 in supportedMarks. Others (treemap, sankey, box-plot, stat, funnel, calendar-heatmap, maps, ...) show an "unavailable" state, and validateChartSpec warns.
  • Interactions. interactions.brush, zoom and drilldown are in the spec for forward compatibility and are ignored (the validator warns). There is no shared filter store. Legend toggling, isolate (legendIsolate), tooltips, keyboard navigation and onPointActivate work.
  • Canvas. The canvas renderer has no keyboard navigation and no right-to-left mirroring (it is an image with a description, the table and the legend); use SVG when you need them. A chart that falls back to SVG (echarts missing, or a type canvas cannot draw) stays on SVG until it is re-mounted.
  • Forced colors. In Windows high-contrast mode, heatmap cells lose their color ramp (system colors have no ramp); the values are in the tooltip, the labels and the table.
  • Time axes are laid out in UTC; timeZone changes how values are formatted, not where they sit on the axis.
  • Waterfalls that cross zero draw on SVG only.
  • Gauges are single-value. format: "percent" is the share of the min to max range: 64 of 100 and 0.64 of 1 both read "64%". A literal gauge still needs data: { values: [] }.
  • Hatch patterns are applied automatically (the second series onward in a multi-series bar, area or slice chart); they are an option of layoutChart (patterns: true), not a spec field.
  • Message catalog is English only. Other languages come from your messages.
  • Contrast depends on your theme; axe cannot measure it in jsdom.

Development

pnpm --filter @x12i/charts run build      # tsup (two entries) + copy styles.css
pnpm --filter @x12i/charts run typecheck
pnpm --filter @x12i/charts run lint       # also bans hard-coded colors and px/rem in src
pnpm --filter @x12i/charts run test       # vitest: headless units, components, axe, styles, SSR

The tests cover every headless function, every chart type (marks, axes, legend, tooltip, keyboard, states, messages, right-to-left), axe on every chart type, the stylesheet's token rules, server rendering, and the canvas renderer with a mocked echarts.