Skip to main content

CanvasLayer

Containers and Specschart

The canvas mark path for ChartFrame — a drop-in sibling of the SVG marks for views past what the DOM can carry (~20k marks up), with a spatial-grid hit test and a one-tab-stop virtual cursor.

import { CanvasLayer } from "@elabs-ai/components-charts";
Install
npm install @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-charts

Skipping the @source line is the most common first failure: Tailwind does not scan node_modules, so every component renders unstyled. The one-time wiring

Prompt for your coding agent

Paste it into Claude Code, Cursor, Codex or any other coding agent. Text in [brackets] is yours to fill in.

Add the brand-ui CanvasLayer chart (from @elabs-ai/components-charts) to this project.

1. Read https://elabs-ai.com/llms.txt first: it maps every package and the rules. If you can use MCP servers, connect https://elabs-ai.com/mcp and use its search, docs and tokens tools. If not, run the same lookups in the terminal: `npx -y @elabs-ai/components-cli search "<what you need>"` and `npx -y @elabs-ai/components-cli docs <Component>`.
2. Look CanvasLayer up before writing code: `npx -y @elabs-ai/components-cli docs CanvasLayer` prints its real props, variants, what it pairs with and what to avoid.
3. If @elabs-ai/components-charts is not installed yet: `pnpm add @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-charts` (or `npm install @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-charts`). If this is the first brand-ui package in the project, wire it once: import `@elabs-ai/components-tokens/styles.css` plus the `themes/light.css` and `themes/dark.css` stylesheets in the CSS entry, add one Tailwind `@source` line per installed @elabs-ai package (Tailwind does not scan node_modules, so without it everything renders unstyled), and wrap the app root in `ThemeProvider` from `@elabs-ai/components-tokens`.
4. Use it here: [the screen or file, and what it should show]
5. Rules: use only @elabs-ai/components-* components and the semantic tokens — no raw colours, no raw font sizes, no arbitrary Tailwind values. Look up each component's real props with the docs tool before you use it; never guess a prop.
6. Done means: the typecheck passes, `npx -y @elabs-ai/components-cli audit src --strict` reports nothing, and you have opened the app and looked at every screen you touched in the light and the dark theme.

Avoid

  • Reaching for it below ~20k marks — the SVG path is inspectable, stylable and testable, and ChartDatapointLayer already gives every point a real keyboard target. A canvas is what you accept when the DOM has run out, not an upgrade.
  • Backing hitTest with a linear scan over the points — it runs on every pointermove, so at 50k points the hover path, not the draw, is what makes the canvas view feel worse than the SVG one it replaced. Use createSpatialGrid.
  • Shipping it without accessibleDescription — canvas pixels are invisible to assistive technology, so with no parallel summary the marks simply cease to exist for a screen-reader user. That is the one failure this component cannot recover from on the reader's behalf.
  • Hardcoding a colour in draw (a hex, an rgb()) instead of canvasTokenColor — the layer redraws on a theme flip, which repaints exactly the same wrong colours.
  • Registering one ChartDatapointLayer target per point to 'get keyboard for free' — that is the DOM cost the canvas exists to avoid; the virtual cursor is the contract here.

Works with

Pairs with

Examples

4 live examples

50,000 marks

The RM-046 budget, measured in the browser rather than asserted in CI (CI hardware variance would make a hard threshold a flake). The readout below the plot is the real number for THIS machine; on a 2020-class laptop 50,000 marks draw in well under the 50 ms budget.

Keyboard Cursor

The keyboard contract: ONE tab stop for the whole picture, arrow keys walk the marks, and a single SVG focus ring follows the cursor. A screen reader gets the region summary on entry and the focused mark from the live region.

Empty

No marks: no canvas cursor, no tab stop, no focus ring.

API

CanvasLayer

PropTypeDescription
selectionMark(datum: T, index: number) => CanvasSelectionMark | nullSelection gestures (RM-144): where a datum sits (CSS px inside the layer) and the category it selects. Required for `selectionGestures` to mount — it is how the canvas marks enter the selection engine's registry.
selectionAxes{ x?: GestureAxis; y?: GestureAxis }The data axes behind `selectionMark`'s pixels, so intents carry data units.
pointsrequiredT[]The marks, in the order the keyboard cursor walks them. This layer never renders one element per point — the array is the cursor's index space, and the source of the mark count.
drawrequired(ctx: CanvasRenderingContext2D, scales: ChartScales, dpr: number) => voidPaints one frame into a `dpr`-transformed, already-cleared context, in CSS pixels. Read colours with `canvasTokenColor`, never a literal — the layer redraws on theme change, which only helps if the callback re-reads tokens.
hitTestrequired(x: number, y: number) => T | nullNearest datum to a CSS-pixel position inside the layer, or `null`. Back it with `createSpatialGrid`, not a linear scan.
onDatapointFocus(datum: T | null) => voidFires when the KEYBOARD cursor lands on a datum, and with `null` on blur.
onDatapointHover(datum: T | null) => voidFires when the POINTER moves onto, or off, a datum.
onDatapointActivate(datum: T) => voidFires on a click over a mark, or Enter/Space on the keyboard cursor.
focusRect(datum: T) => CanvasLayerRectWhere the focus ring goes for a datum, in CSS pixels. Omit it and the ring frames the whole layer instead — focus stays visible either way.
labelFor(datum: T) => stringSpoken text for one datum. Without it the cursor announces nothing useful.
renderTooltip(datum: T) => ReactNodeTooltip body for the hovered/focused datum. Omit for no tooltip.
widthnumberFixed width in CSS pixels. Omitted → measured from the element.
heightnumberFixed height in CSS pixels. Omitted → measured from the element.
marginPartial<Margin>Plot insets passed through to `draw`. Defaults to zero on every side.
drawSignaturestringChanges when the data behind `draw` changes; triggers a repaint.
animateInbooleanRamp `scales.progress` 0→1 on mount. Ignored under reduced motion.
accessibleLabelstringAccessible name for the mark region and its keyboard cursor.
accessibleDescriptionstringThe parallel summary — what the marks convey, in words. This is the channel that replaces what AT loses when marks stop being DOM elements. Write it like a chart caption ("50,000 events across 12 case rows, March to June; density peaks mid-April"), not like a data dump.
selectionGesturesreadonly ChartSelectionGesture[]Gestures to enable. Unset → no gesture layer, DOM byte-identical to today.
onSelectionIntentChartSelectionIntentHandler<Record<string, unknown>>Fires with every intent. Required for the gesture layer to mount.
selectionConfirmChartSelectionConfirmDefault `"immediate"`.
selectionFieldstringThe field name carried in intents. Default: the chart's `xDataKey`.
selectionHitRuleChartSelectionHitRuleRect / lasso hit rule. Default `"overlap"` (the report-builder BI suite).
selectionToolbar"auto" | "none"`"auto"` (default): a toolbar appears when gestures are enabled; `"none"` hides it.

Also accepts: Omit<HTMLAttributes<HTMLDivElement>, "onClick" | "children">, ChartSelectionGestureProps

Theming

The tokens each state resolves to. Change the token and every theme follows.

StateToken
marksevery colour the draw callback paints is read per frame via canvasTokenColor(--chart-*) — ctx.fillStyle cannot resolve a CSS variable, so a literal here is a canvas that stops theming
focusthe focused datum gets ONE SVG overlay rect stroked with the --ring token (the SVG stroke reads the variable directly, not the shared focus utility), never a ring painted into the picture the caller draws
cursorthe focused datum's label is spoken by a single polite live region; the button's own name stays static
reducedMotionthe enter ramp does not run at all — scales.progress starts and stays at 1