Skip to main content

reusable-visualization

Build ONE reusable chart visualization component that receives its data and its settings from the host application instead of fetching them, and declares the fields and config options the host exposes to viewers. Use this whenever a single chart component is reused across many different queries rather than built for one — in Lightdash, the `data_app_viz` app template, also called a "viz". Covers the `useVizContext()` hook, the declaration contract, the config-option vocabulary, and series colours.

Source facts

Repository
lightdash/lightdash
Last source activity
October 1, 2026 at 16:55
Detected SKILL.md language
English
Stars
6,183
Forks
790

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
reusable-visualization
description
Build ONE reusable chart visualization component that receives its data and its settings from the host application instead of fetching them, and declares the fields and config options the host exposes to viewers. Use this whenever a single chart component is reused across many different queries rather than built for one — in Lightdash, the `data_app_viz` app template, also called a "viz". Covers the `useVizContext()` hook, the declaration contract, the config-option vocabulary, and series colours.
# Reusable visualization This is ONE reusable chart component, not an app. It runs no query and owns no explore. Lightdash runs the query, then hands the component the result rows plus a mapping from the field names the component declared to the columns in those rows. The same component is reused across many different queries — every contract below follows from that: it cannot hardcode column names, so it declares a field mapping; it cannot hardcode display choices for queries it has never seen, so it declares config options. No dashboard, navigation, multiple panels, filters or page chrome. Recharts, echarts, D3 or plain SVG all work. Everything the viewer can reach later exists only because the component declared it: the columns they map, and every control in the chart's config panel. A literal hardcoded instead stays frozen until somebody regenerates the whole viz. ## The hook `useVizContext()` from `@lightdash/query-sdk` is the only channel to the host — data, settings, and host actions all come from it. Do not add a message listener and do not fetch anything yourself; the only host interaction is through the helpers the hook returns. ```tsx const context = useVizContext(); const { fieldMapping, fields, rows, options, colorPalette, pivotDetails, ready, pointMenu, underlyingData, drillDown, subtotals, } = context; ``` - `fieldMapping` — `Record<string, string | string[]>`: each single-field input maps to one query field id; inputs declared with `multiple: true` map to ordered arrays. Narrow with `Array.isArray(binding)` before iterating. Read each id with `getFormatted(row, fieldId)` (display text) and `getRaw(row, fieldId)` (raw value). - `fields` — `Record<string, { label, tableLabel?, format? }>`: display metadata for each bound query field id. Name fields with `getFieldLabel(context, fieldId)` — the field's label ("Total revenue"), or the raw id only when the host sent no metadata. `format` is the semantic-layer format (`type` such as `currency`/`percent`/`number`, plus `round`, `currency`, `compact`, `prefix`, `suffix`) for formatters that have no cell to hand, such as axis ticks; per-value text still comes from `getFormatted`. - `rows` — the host-fetched result rows, keyed by query field id. - `options` — `Record<string, boolean | number | string>`: the current value of each config option you declared (the viewer's choice, else your declared `default`). - `colorPalette` — `string[]`: the Lightdash palette resolved for this chart. Always pushed, whether or not you declared `colorPalette`. Empty only when the host resolved none. - `seriesColors` — `Record<string, string>`: final host-resolved colours keyed by backend pivot column name. Read them through `resolveSeriesColor(context, column, index)`. - `valueColors` — `Record<string, Record<string, string>>`: final host-resolved colours keyed by query field id then raw value. Read them through `resolveValueColor(context, fieldId, rawValue, index)`. - `pivotDetails` — the complete backend-pivot layout, or `null` for ordinary rows. See "Backend-pivoted results" below. - `ready` — false until the first context arrives. - `pointMenu` — host-rendered data-point action menu for a clicked mark; this is the current data-point action contract. See "Data-point actions" below. - `underlyingData` — host-mediated access to the raw rows behind a clicked data point; the fallback per-action dialog for hosts that predate `pointMenu`. See "Data-point actions" below. - `drillDown` — host-mediated drill on a clicked data point; the fallback per-action dialog for hosts that predate `pointMenu`. See "Data-point actions" below. - `subtotals` — aggregated rows for the levels of a declared hierarchy. Declare `hierarchy: { field: "<input name>" }` in the schema, naming a dimension input with `multiple: true` ordered from the top level down. `rows` stays at the most detailed level. When `subtotals.enabled`, `subtotals.dimensions` lists the bound field ids and `await subtotals.get({ level, parentValues })` returns `{ rows }` for one level: `level: 0` with `parentValues: []` is the top level, `level: 1` with the raw value of one top-level group returns that group's children. Rows come back unsorted, one per group and one per series value when the result is pivoted. Subtotals belong to the `rows` they were fetched for: refetch when `rows` change, handle a rejected promise, and render from `rows` alone when `subtotals.enabled` is false. Read all of them. Pass the complete `context` to the colour helpers; they preserve model colours and shared dashboard assignments before falling back to `colorPalette`. ### These app-level APIs do not apply to a viz The sandbox skill (`/app/skill.md`) is written for data apps, which do run queries. A reusable visualization does not. Do **not** use `useLightdash()`, `filtersFor(EXPLORE)`, `addFilter({...})`, or `format(row, fieldName)` — there is no explore to query or filter, and formatting arrives pre-computed via `getFormatted`. Where this skill and the sandbox skill differ, this skill wins for a viz. Everything else in the sandbox skill (React, shadcn, charting, theming, floating surfaces, screenshots) still applies. ## Rendering rules - **Fill the viewport.** Give the root element `height: 100vh` (or `position: fixed; inset: 0`), NOT `height: 100%` — that collapses to a 0-height invisible box unless every ancestor also sets a height, leaving auto-sizing charts like recharts `<ResponsiveContainer>` nothing to measure. Confirm the chart actually renders and isn't a blank box. - **Fit the frame.** The viewport is an iframe exactly the size of the chart's tile, and the tile never resizes to its content. See "Fitting the frame" below. - **Placeholder.** Show a clearly visible placeholder (readable, good contrast — never near-white on white) while `!ready`, when a required field is unmapped, or when there are no rows. - **Fundamentals.** Clear axes and labels, readable spacing, a tooltip on hover. Give every axis a `tickFormatter` (compact numbers, shortened labels) or `hide` it — raw ticks overflow and overlap on a chart the viewer can resize. - **Labels, never ids.** Wherever the chart names a field — axis titles, legend and series names, table headers, tooltip rows, default labels — use `getFieldLabel(context, fieldId)`. Never show the query id (`orders_total_revenue`) or a name derived from it (humanized, or with the table prefix stripped). ### Fitting the frame A tile can be anything from about 300px wide and 300px tall (a phone, where a dashboard stacks its tiles in one column) to a full-width desktop tile. The template already makes the frame the container: it scrolls vertically when the content is taller than the tile, and its base font size shrinks in a narrow tile. What is left to the chart: - Size type, padding and gaps in `rem` or Tailwind classes, never `px`, so they shrink with the frame. - A row of items wraps onto more rows as the frame narrows (auto-fit grid columns or `flex-wrap`). The minimum item width is what one item needs to keep its values on one line, capped just under half the frame so two still fit on a phone: `min(<that width in rem>, 45%)`. - A value stays on one line and is never truncated. A long label wraps between words. A table is `width: 100%` with wrapping header cells. - Add no scroll region of your own. A title or table header row that should stay visible while the frame scrolls is `position: sticky; top: 0` with an opaque background. ## Series colours Use the query SDK's host-resolved colour helpers for every series or group: - Backend-pivoted series: `resolveSeriesColor(context, column, index)`. - Client-side groups: `resolveValueColor(context, fieldId, rawValue, index)`. The helpers first honor model-defined fixed colours and Lightdash's shared dashboard colour assignment, then fall back to `colorPalette[index % colorPalette.length]`. Keep a small fallback array only for the case where the helper returns `undefined`, and declare `colorPalette` in your output so the viewer gets the palette picker. This is the same rule as the sandbox skill's "chart series colors must come from `CHART_COLORS`" and `references/d3.md`'s "never hand-pick palettes" — the same Lightdash palette, delivered differently. An app imports `CHART_COLORS`; a viz reads the resolved colours off the hook, because the viewer picks the palette per chart. In a viz, use the hook, not `CHART_COLORS`. ## Backend-pivoted results A mapped `series` field makes Lightdash pivot the results before they reach the viz. In that case the mapped metric id is no longer a row key: each series becomes a generated column described by `pivotDetails.valuesColumns`. Match `valuesColumns` whose `referenceField` equals the mapped metric id. For each match, use `pivotValues` to identify and label the mapped series value. Generated `pivotColumnName` keys contain the same `VizContextCell` objects as ordinary field keys: read them with `getRaw(row, column.pivotColumnName)` or `getFormatted(row, column.pivotColumnName)`. Never coerce `row[fieldId]` directly with `String`, `Number`, or template interpolation; that coerces the cell object rather than its value. Preserve the ordinary `fieldMapping` row path when `pivotDetails` is `null`, so the same viz works without a mapped series and on older unpivoted charts. The rest of `pivotDetails` describes the full layout rather than assuming the viz is a Cartesian chart: - `indexColumn` — row-grain fields and their `time`/`category` axis semantics. - `groupByColumns` — pivot-header fields in backend layout order. - `originalColumns` — original field ids and semantic types before pivoting. - `sortBy` — the sort applied to the pivoted result, including anchored pivot values. - `totalColumnCount` — the untruncated pivot column count; use it when the viz needs to explain that the returned columns hit a limit. - `passthroughDimensions` — hidden dimensions retained on rows for cross-field rendering. Use these when the visualization's shape benefits from them: a table can build row and column headers, while a chart can select a time axis from metadata instead of guessing from values. Treat `fieldMapping` as the declared interface; metadata describes how those fields were laid out, not permission to render unrelated query fields automatically. ```tsx const metricId = fieldMapping['value']; const seriesId = fieldMapping['series']; const pivotedMetrics = pivotDetails?.valuesColumns.filter( (column) => column.referenceField === metricId, ) ?? []; const series = pivotedMetrics.map((column, index) => ({ columnId: column.pivotColumnName, label: column.pivotValues.find((value) => value.referenceField === seriesId) ?.formatted ?? 'Unknown', color: resolveSeriesColor(context, column, index) ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length], })); const categoryId = fieldMapping['category']; const data = rows.map((row) => ({ label: getFormatted(row, categoryId), ...Object.fromEntries( series.map(({ columnId }) => [ columnId, Number(getRaw(row, columnId) ?? 0), ]), ), })); ``` Use the order of `valuesColumns` for the helper's fallback index. A chart with multiple declared series fields builds its label from each matching `pivotValues` entry in declared field order. Backend-pivoted rows do not have safe one-source-row provenance, so the host sets `underlyingData.enabled` to false for them. ## Data-point actions: pointMenu, underlying data, and drill-down Every chart whose marks satisfy the provenance rule below MUST wire the standard data-point action. This is part of the component contract, not an optional nicety — a "keep it minimal" prompt does not waive it. `pointMenu.enabled` (or, on the fallback path, `underlyingData.enabled` and `drillDown.enabled`) decides at RUNTIME whether anything shows; you always write the wiring, and it costs nothing visually when disabled. ### `pointMenu`: the current contract The current contract is a single call on click: `pointMenu.open({ x, y, row, metric, fieldId? })`, with `x`/`y` taken from the click event's `clientX`/`clientY`. Lightdash renders the entire menu — copy value, view underlying data, drill, dashboard cross-filtering — over the viz and owns all subsequent UI; the component renders no menu of its own. `pointMenu.open` resolves `{ shown: boolean }`; `shown: false` means the host had no applicable action for that point, and the component still renders nothing extra. ```tsx onClick={(event, datum) => { if (pointMenu.enabled) { pointMenu .open({ x: event.clientX, y: event.clientY, row: datum.sourceRow, metric: 'value', }) .catch(() => {}); return; } // Fallback for hosts that predate the point menu: openLocalActionMenu(event, datum); }} ``` Only when `pointMenu.enabled` is false (a host that predates the capability) does the component fall back to building its own in-viz action menu from `underlyingData.open` and `drillDown.open`, as described in the rest of this section. Provenance is the contract: **every interactive datum keeps a reference to its untransformed source row.** When mapping `rows` into chart data, carry the row: ```tsx const data = rows.map((row) => ({ label: getFormatted(row, catField), value: Number(getRaw(row, valField) ?? 0), sourceRow: row, // ← required for the data-point action })); ``` Only attach the action where one mark maps to exactly ONE source row and ONE metric-slot field. A mark that aggregates several rows (a binned bucket, a "top N + other" slice) gets no data-point action. ### Fallback: the in-viz action menu Everything through "Open the menu at the data point" below applies only when `pointMenu.enabled` is false. It calls the two per-action APIs directly and builds its own accessible in-viz menu instead of delegating to a Lightdash-rendered one. When `underlyingData.enabled` is true, viewers get the fallback action on chart marks: click a data point → small action menu → "View underlying data" → the standard Lightdash dialog listing the raw rows behind that point, with its Download button. When it is false (host too old, viewer lacks permission, embed), render no menu item — never a disabled one. On click, send the intent and render nothing else. Lightdash opens the dialog outside the viz and owns its table, loading, error, sorting, and download controls: ```tsx underlyingData.open({ row: datum.sourceRow, metric: 'value' }).catch(() => {}); ``` `metric` is the declared field NAME from your `fields` (the same key you read from `fieldMapping`), not a query field id. The custom chart type owns only its data-point action menu. It never renders an underlying-data dialog, table, loading state, error state, or download control inside the iframe. For a multiple-metric input, also pass `fieldId`, the member of that input's binding whose mark was clicked: `underlyingData.open({ row: datum.sourceRow, metric: 'values', fieldId })`. Use the same `fieldId` with `drillDown.open`. A mark combining several metrics cannot identify one underlying metric and gets no data-point action. ### Drill into a data point When `drillDown.enabled` is true, the same data-point action menu also offers "Drill into *{formatted metric value}*" (e.g. "Drill into $1,234"). On selection, fire the intent and render nothing else — Lightdash opens its own drill dialog outside the viz: ```tsx drillDown.open({ row: datum.sourceRow, metric: 'value' }).catch(() => {}); ``` `metric` is the declared field NAME, exactly as for `underlyingData`. The same provenance contract applies: the item appears only where one mark maps to exactly ONE source row and ONE metric-slot field, and only when `drillDown.enabled` — never a disabled item. The two flags are independent: show each action on its own flag (a viewer may have one permission but not the other). ### Open the menu at the data point Anchor this menu at the activated mark, not at an edge of the iframe or chart. If neither action is enabled, do not open or render an empty menu and do not give marks action-button semantics. Keep the clicked datum's `sourceRow` and declared metric name (and `fieldId` for a multiple input) in the menu state; the menu items must act on that exact identity. Pass the helper a library-neutral anchor: the activating DOM mark, whether activation was by pointer or keyboard, and pointer viewport coordinates when available. For a pointer activation, use native `clientX` and `clientY`, never chart-relative coordinates such as `chartX` or `chartY`. For keyboard activation, anchor at the centre of the activating mark's `getBoundingClientRect()` instead. Adapt each chart library before calling the helper. Recharts' React click handler supplies `event.currentTarget`, `event.clientX`, `event.clientY`, and `event.detail === 0` for keyboard-originated clicks. For ECharts, extract the native pointer event from its click parameters before reading `clientX` and `clientY`; do not assume React fields exist. D3 already passes the native event. **Make each eligible SVG mark keyboard reachable while either action is enabled.** When neither is enabled, omit the button role, tab stop, and action handlers. Otherwise give the mark `tabIndex={0}`, `role="button"`, and an accessible name from its category and formatted value. In Recharts, use a custom `shape` to wire `onClick` and `onKeyDown` on the rendered mark; the chart-wide `accessibilityLayer` alone does not open per-mark menus. On Enter or Space, prevent the default key action and call the same helper with `keyboard: true` and the mark as `activator`. Verify Tab → mark → Enter/Space → menu → Escape returns focus to that mark. Register rendered marks in a ref map keyed by stable datum key, using a ref callback on the SVG element: `ref={(node) => registerMark(datum.key, node)}`. Resolve the current mark after the menu unmounts when restoring focus: a saved DOM element can become detached during chart rerenders. Use the existing shadcn/Radix `DropdownMenu`, rendered with `createPortal(..., document.body)`. Its 1px fixed trigger is positioned at the stored viewport coordinates; this avoids transformed iframe ancestors changing the containing block for `position: fixed`. Let `DropdownMenuContent` use its normal Radix collision handling so a menu near a viewport edge flips or shifts onscreen. The placement state is small; the existing `underlyingData.open` and `drillDown.open` calls remain the menu items' host actions: ```tsx const markElements = useRef(new Map()); const registerMark = (key, node) => { if (node) markElements.current.set(key, node); else markElements.current.delete(key); }; const openFallbackMenu = (datum, anchor) => { if (!underlyingData.enabled && !drillDown.enabled) return; const rect = anchor.activator.getBoundingClientRect(); setTooltipVisible(false); setMenu({ sourceRow: datum.sourceRow, metric: datum.metric, fieldId: datum.fieldId, pointKey: datum.key, keyboard: anchor.keyboard, x: anchor.keyboard ? rect.left + rect.width / 2 : anchor.clientX, y: anchor.keyboard ? rect.top + rect.height / 2 : anchor.clientY, }); if (!anchor.keyboard) anchor.activator.blur(); }; {menu && (underlyingData.enabled || drillDown.enabled) && createPortal( <DropdownMenu open onOpenChange={(open) => !open && setMenu(null)}> <DropdownMenuTrigger asChild>
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub