- 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