Reusable Visualization logo

Reusable Visualization

OrganizationPopular
lightdash
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.

Overview

Publisherlightdash
Repositorylightdash
Skill namereusable-visualization
Stars
6.2K
Forks
790
Bundled files
Instructions only
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • Self-contained

    Everything the model needs lives in the instructions — no extra files to sync.

  • Open source

    Published by lightdash on GitHub. Read the source before you install it.

Installation

Install the Reusable Visualization AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/lightdash/lightdash/tree/main/sandboxes/data-apps/template/.claude/skills/reusable-visualization
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/lightdash/lightdash.git /tmp/lightdash
mkdir -p .claude/skills
cp -r /tmp/lightdash/sandboxes/data-apps/template/.claude/skills/reusable-visualization .claude/skills/reusable-visualization
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Reusable Visualization in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Reusable Visualization on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Reusable Visualization is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

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>
      <span aria-hidden style={{ position: 'fixed', left: menu.x, top: menu.y, width: 1, height: 1 }} />
    </DropdownMenuTrigger>
    <DropdownMenuContent
      onCloseAutoFocus={(event) => {
        event.preventDefault();
        if (menu.keyboard) {
          requestAnimationFrame(() => markElements.current.get(menu.pointKey)?.focus());
        }
      }}
    >
      {/* independently gated action items */}
    </DropdownMenuContent>
  </DropdownMenu>,
  document.body,
)}

Interaction hygiene

These rules apply on both paths — the host-rendered pointMenu and the fallback in-viz menu. One floating surface at a time, and no leftover emphasis — native Lightdash charts show a menu OR a tooltip, never both, and clicking leaves no mark highlighted:

  • Opening the action menu closes the tooltip. Drive tooltip visibility from your own state (recharts: gate <Tooltip> via controlled props or a wrapper's visibility; echarts: dispatchAction({ type: 'hideTip' }) on click). While the menu is open the tooltip stays hidden, and it must not reappear until the pointer moves again after the menu closes.
  • No persistent focus or active styling on a clicked mark. Disable click emphasis (recharts: no activeShape on click state; echarts: turn off lingering emphasis/select). A keyboard activator keeps its normal focus affordance and receives focus again when the menu closes; it must not look like a selected data point. Only hover may emphasise, and only while hovering.
  • Subtle hover, no cursor band. The library-default full-height band behind the hovered mark (recharts <Tooltip cursor>) is not native behaviour — use cursor={false} or a faint theme-token fill.
  • Tooltips are themed and deduplicated. Background, text and border come from the theme tokens (never library-default white), and each value appears once: one line per series actually under the pointer.

The declaration

Alongside the component you emit one structured declaration — as structured output, not a file. It has four parts: fields, configOptions, colorPalette and optional inputGuidance. Lightdash builds the field-mapping UI and the chart config panel from it, so the component is unusable without it.

The correspondence is exact in both directions: every key read from fieldMapping or options is declared, and everything declared is read. A declared option nothing reads is a dead control the viewer can move with no effect.

fields

One entry per input the component reads:

  • name — the key read from fieldMapping. Unique, no spaces.
  • label — human label shown in the mapping UI.
  • type — dimension (a category/grouping column), metric (a numeric measure), series (a dimension used to split or colour the chart), or column (any result column, whether metric or dimension).
  • required — false only when the chart still renders with this field unmapped.
  • multiple — optional boolean, default false. Set true when this input accepts an ordered selection of fields of its declared type. Its fieldMapping value is a string[], including when only one field is selected. A required input needs at least one selection; an empty array means the viewer cleared it. Render a placeholder until required selections exist.
  • description — optional reusable mapping help, maximum 160 characters. Use one or two short, plain sentences explaining the field's role in the chart. Keep it agnostic to any business, explore, or query; do not imply fixed categories or values.

Do not generate field examples. Viewers supply their own values; describe the field's role and data shape without prescribing categories from a particular business.

Ordered multiple-field inputs

Use one multiple input for a variable number of measures or grouping columns instead of declaring a fixed number of numbered slots:

json
{
  "fields": [
    { "name": "groups", "label": "Grouping fields", "type": "dimension", "required": true, "multiple": true },
    { "name": "values", "label": "Measures", "type": "metric", "required": true, "multiple": true }
  ],
  "configOptions": [],
  "colorPalette": null
}
tsx
const { fieldMapping, rows } = useVizContext();
const groups = Array.isArray(fieldMapping.groups) ? fieldMapping.groups : [];
const values = Array.isArray(fieldMapping.values) ? fieldMapping.values : [];
const data = rows.map((row) => ({
  label: groups.map((id) => getFormatted(row, id)).join(' / '),
  values: values.map((id) => ({ id, value: getRaw(row, id) })),
}));

Array order is the viewer's display order: preserve it when building axes, columns, legends and series. For pivoted metrics, iterate the selected ids first and then match pivotDetails.valuesColumns by referenceField. The flag also works for series and column inputs. Existing single inputs continue receiving strings in fieldMapping; leave their declarations unchanged when adding a separate multiple input. Keep field names stable across compatible upgrades so saved selections can be reconciled.

inputGuidance

Optional reusable help shown above the mappings, maximum 200 characters. Use at most two short, plain sentences. State what each row represents, any ordering the visualization relies on, and how to change a query that has a different shape. For a funnel, say that each row contains one stage label and its count in stage order; separate stage metrics or boolean flags need reshaping into stage/count rows, or a differently authored chart. This explains how the same viz can be reused with other queries; it does not make the component infer semantics or transform host data.

configOptions

One entry per setting the viewer can change without regenerating the viz. Every option is a whole-viz value applying to the entire chart — there is no per-series option. Colour series and groups with the SDK's resolved-colour helpers.

Every option has:

  • name — the key read from options. Unique, no spaces.
  • label — human label shown next to the control.
  • group — optional tab name. Options sharing a group are rendered in the same config tab; ungrouped options share a default tab.
  • default — REQUIRED on every option. The value the viz uses until the viewer changes it; its shape follows type. Use the value you would otherwise have hardcoded.
  • type — exactly one of these five, no others:
typecontroldefaultextra keys
booleanswitchtrue / false—
selectdropdownone of the declared choice values (a string)choices: array of { "value": "...", "label": "..." }, at least one entry
numbernumber inputa numbermin, max — both optional numbers
textsingle-line text inputa string—
colorsingle coloura hex string, e.g. "#7162FF"—

The color control includes swatches from the active Lightdash palette and accepts custom hex colours. It stores the chosen hex string. Changing the palette or light/dark theme updates the available swatches; it does not change previously chosen colours.

Series colours are not in this list. They are declared separately, on colorPalette.

colorPalette

Whether the viewer gets the standard Lightdash palette picker. Declare { "group": "..." } when your component colours anything with the resolved-colour helpers or colorPalette, or null when it colours nothing. group is optional and works like an option's: the picker joins that tab, and gets a tab of its own when no option shares the name.

This is not a config option. There is one palette per chart, it has no name and no default, and its colours arrive on colorPalette — never on options.

Worked example

Component and declaration lining up. Your chart will differ; the correspondence must not.

tsx
import { useVizContext, getFormatted, getRaw, resolveValueColor } from '@lightdash/query-sdk';
import { Bar, BarChart, Cell, LabelList, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';

function Chart() {
  const context = useVizContext();
  const { fieldMapping, rows, options, ready } = context;
  if (!ready) return <div style={{ height: '100vh' }}>Loading…</div>;
  const catField = fieldMapping['category'];            // your field name -> column id
  const valField = fieldMapping['value'];
  const showLabels = options['showLabels'] as boolean;  // your option name -> current value
  const maxBars = options['maxBars'] as number;
  const fallbackColors = ['#7162FF', '#1A1B1E'];
  const data = rows.slice(0, maxBars).map((row) => ({
    label: getFormatted(row, catField),                 // display text, e.g. "Completed"
    category: getRaw(row, catField),                    // raw value for host-resolved colour
    value: Number(getRaw(row, valField) ?? 0),          // raw number
  }));
  return (
    // The root fills the viewport, so ResponsiveContainer has a height to measure.
    <div style={{ height: '100vh' }}>
      <ResponsiveContainer width="100%" height="100%">
        <BarChart data={data}>
          <XAxis
            dataKey="label"
            tickFormatter={(v) => (v.length > 12 ? `${v.slice(0, 11)}…` : v)}
          />
          <YAxis tickFormatter={(v) => v.toLocaleString()} />
          <Tooltip />
          <Bar dataKey="value">
            {data.map((d, i) => (
              <Cell
                key={d.label}
                fill={resolveValueColor(context, catField, d.category, i) ?? fallbackColors[i % fallbackColors.length]}
              />
            ))}
            {showLabels && <LabelList dataKey="value" position="top" />}
          </Bar>
        </BarChart>
      </ResponsiveContainer>
    </div>
  );
}

The declaration that component emits is exactly:

fields: [{ "name": "category", "label": "Category", "type": "dimension", "required": true, "description": "The label for each chart row." },
         { "name": "value", "label": "Value", "type": "metric", "required": true, "description": "The numeric value for that row." }]
configOptions: [{ "name": "showLabels", "label": "Show value labels", "group": "Labels", "type": "boolean", "default": true },
                { "name": "maxBars", "label": "Max bars", "type": "number", "default": 10, "min": 1, "max": 50 }]
colorPalette: {}
inputGuidance: "Use one row per category in display order. Reshape queries with separate category columns into category/value rows before mapping."

Final pass, before you finish

Re-read the component you just wrote and list every literal in it: each colour, each true/false you chose, each number, each string you typed into the chart. Take the list one entry at a time and ask "would a viewer plausibly want this different?". This is the minimum, not a menu:

  • every element you chose to show or hide (value labels, legend, gridlines, an axis) → boolean
  • every variant you picked between (vertical/horizontal, grouped/stacked, curve style) → select
  • every number you chose (bar width, max rows, decimal places, a threshold) → number
  • every string you wrote into the chart (title, axis label, empty-state text) → text
  • every accent colour that is not a series colour (a target line, a highlight) → color
  • the series colours, whenever the chart draws more than one series → resolved-colour helper plus colorPalette

Where the answer is yes, make that literal the option's default, declare the option, and read the option in its place. Leave it hardcoded only where changing it would break the chart.

Then check both directions: every key you read from options is declared, and every option you declared is read somewhere. colorPalette is declared when you use either resolved- colour helper or colour from colorPalette — it is never read from options.

Then resize the frame mentally, to a 320px by 320px phone tile and to a wide desktop tile: nothing is wider than the frame, rows of items wrap, and a title or table header stays in view if the frame scrolls.

Before returning the declaration, add a description to each slot whose role could be ambiguous to a viewer, and add inputGuidance whenever row shape or ordering matters. Keep that help reusable across queries: describe the chart contract rather than a specific dataset.

Finally, if any mark maps to exactly one source row, the data-point action is wired: each interactive datum carries sourceRow, and the click handler calls pointMenu.open({ x, y, row, metric }) when pointMenu.enabled, falling back to an in-viz menu — gated on underlyingData.enabled and drillDown.enabled — only when it is not. Omitting the action on a chart whose marks satisfy provenance is a defect, not a simplification. Then click a mark mentally: the tooltip closes, nothing stays highlighted, and only the menu remains — one floating surface at a time.

Frequently asked questions

What does the Reusable Visualization AI skill do?

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.

Why use Reusable Visualization on TypingMind?

Because you install it once and use it with any model. Reusable Visualization is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Reusable Visualization in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/lightdash/lightdash/tree/main/sandboxes/data-apps/template/.claude/skills/reusable-visualization. TypingMind reads its SKILL.md and installs it as a skill you can enable per chat.

Which AI models can use Reusable Visualization?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Reusable Visualization?

As many as you like. As long as a model supports skills, you can use Reusable Visualization with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Reusable Visualization AI skill free?

It is published on GitHub by lightdash. Check the repository for licensing terms. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇