import { CanvasLayer } from "@elabs-ai/components-charts";npm install @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-chartsPaste 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
Used inside
Pairs with
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
| Prop | Type | Description |
|---|---|---|
selectionMark | (datum: T, index: number) => CanvasSelectionMark | null | Selection 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. |
pointsrequired | T[] | 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) => void | Paints 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 | null | Nearest datum to a CSS-pixel position inside the layer, or `null`. Back it with `createSpatialGrid`, not a linear scan. |
onDatapointFocus | (datum: T | null) => void | Fires when the KEYBOARD cursor lands on a datum, and with `null` on blur. |
onDatapointHover | (datum: T | null) => void | Fires when the POINTER moves onto, or off, a datum. |
onDatapointActivate | (datum: T) => void | Fires on a click over a mark, or Enter/Space on the keyboard cursor. |
focusRect | (datum: T) => CanvasLayerRect | Where 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) => string | Spoken text for one datum. Without it the cursor announces nothing useful. |
renderTooltip | (datum: T) => ReactNode | Tooltip body for the hovered/focused datum. Omit for no tooltip. |
width | number | Fixed width in CSS pixels. Omitted → measured from the element. |
height | number | Fixed height in CSS pixels. Omitted → measured from the element. |
margin | Partial<Margin> | Plot insets passed through to `draw`. Defaults to zero on every side. |
drawSignature | string | Changes when the data behind `draw` changes; triggers a repaint. |
animateIn | boolean | Ramp `scales.progress` 0→1 on mount. Ignored under reduced motion. |
accessibleLabel | string | Accessible name for the mark region and its keyboard cursor. |
accessibleDescription | string | The 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. |
selectionGestures | readonly ChartSelectionGesture[] | Gestures to enable. Unset → no gesture layer, DOM byte-identical to today. |
onSelectionIntent | ChartSelectionIntentHandler<Record<string, unknown>> | Fires with every intent. Required for the gesture layer to mount. |
selectionConfirm | ChartSelectionConfirm | Default `"immediate"`. |
selectionField | string | The field name carried in intents. Default: the chart's `xDataKey`. |
selectionHitRule | ChartSelectionHitRule | Rect / 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. |
Theming
The tokens each state resolves to. Change the token and every theme follows.
| State | Token |
|---|---|
| marks | every 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 |
| focus | the 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 |
| cursor | the focused datum's label is spoken by a single polite live region; the button's own name stays static |
| reducedMotion | the enter ramp does not run at all — scales.progress starts and stays at 1 |