Skip to content

Theming

10 min read · Updated 2 Nov 2026

This page belongs to Data App developer guide. What the visualization packages let you style, and how, is in the Visualizations reference; this page covers building a theme around it.

The visualizations have no theme channel. They start in the built-in light theme and do not follow your page. Every colour a chart shows in your dark theme is one your app set with an operation.

Define every colour once as a token on :root, redefine only the tokens under :root[data-theme='dark'], and use tokens instead of literal colours elsewhere in the stylesheet.

The values below are neutral placeholders that show the token names. They are not a palette: choose every value for your app, as Let the subject set the look describes.

:root {
--page: #eeeeee;
--surface: #ffffff;
--line: #d4d4d4;
--ink: #262626;
--muted: #666666;
--panel-border: 1px solid transparent;
--panel-shadow: 0 1px 2px rgb(0 0 0 / 8%);
--accent: #525252;
--delta-up: #1a7f4b;
--delta-down: #b42318;
--radius: 8px;
}
:root[data-theme='dark'] {
--page: #0f0f0f;
--surface: #1f1f1f;
--line: #3a3a3a;
--ink: #e8e8e8;
--muted: #a3a3a3;
--panel-border: 1px solid var(--line);
--panel-shadow: none;
--accent: #a3a3a3;
--delta-up: #6ee7b7;
--delta-down: #f28b82;
}

The component recipes in these docs read these names: --accent for focus rings and selected states, --delta-up and --delta-down for rises and falls, and --radius for card corners. Keep the names when you replace the values, and check the contrast of each colour against --surface.

Set data-theme on <html> from script, resolving the system preference yourself, and offer an explicit override. Do not also write prefers-color-scheme rules in CSS: they fall out of step the moment the viewer picks a theme explicitly.

const media = matchMedia('(prefers-color-scheme: dark)');
const applyTheme = (choice: 'system' | 'light' | 'dark') => {
const theme = choice === 'system' ? (media.matches ? 'dark' : 'light') : choice;
document.documentElement.dataset.theme = theme;
return theme;
};

Re-run it on the media query’s change event while the choice is system.

Choose backgrounds, borders and shadows for each theme.

  • Light: shadows can separate panels from the page without a border.
  • Dark: use a lighter panel background and a thin border where shadows provide insufficient contrast.
  • Use consistent panel backgrounds for related sections.

These are Astrato’s own dark surfaces, for an app that should sit beside them. They are not a starting point for other apps. Map roles to your app’s tokens and supported chart styling, then check contrast and categorical distinguishability at the actual sizes.

Role Astrato Dark Astrato Blue-Green
Page / navigation #121316 / #0c0d0f #0b1418 / #071014
Surface / alternate surface #242529 / #1e1f23 #12222a / #0e1b22
Line #3c4043 #2a4450
Text / muted text #e8eaed / #9aa0a6 #e6f1f3 / #8aa6ae
Interaction emphasis / accent #2f3136 / #3b82f6 #1b3540 / #06b6d4
Categorical palette #a78bfa, #3b82f6, #2dd4bf #3b82f6, #06b6d4, #2dd4bf
Good / warning / critical #6ee7b7 / #fbbf24 / #f28b82 #4ade80 / #f59e0b / #fb7185

Use a font that loads, as Load visualization fonts shows, and tabular figures for numbers. Keep category identity separate from good/bad state and reinforce meaningful colours with labels or another cue. A percentage alone does not establish whether a result is good or bad.

Use the logo, colours and typefaces of the company the app is for, from what you were given or its official website. Prefer values the company publishes over guesses from a screenshot.

  • Colours. Use the brand colour for primary actions and the main series, and neutrals for surfaces and text. Keep good and bad colours distinct from the brand colour.
  • Logo. Place it in the header unaltered, in the variant that suits the background. Never invent a logo or use another company’s.
  • Type. Use the company’s typeface where it is published for public use. Otherwise choose the closest available face and say that you did.
  • Contrast. Check text and marks against the brand background in every theme you support, and adjust tints rather than the logo.
  • Rights. Unless the company has authorized the app, say in the app that the name and logo belong to their owner and that it is not an official product. Confirm the source of third-party imagery.

If no company is named, do not guess one: use the direction you have or an existing design system, or let the subject set the look. In App Builder, Nash cannot read other websites, so give it the colours, the logo and the font names.

Measure contrast between the page background, panel background and borders. These ratios are starting points for separating surfaces, not text-accessibility thresholds:

Pair Contrast ratio
--page against --surface ≥ 1.15
--surface against --line ≥ 1.4

When panel and page backgrounds match, check border contrast against that background.

const luminance = (hex) => {
const [r, g, b] = [1, 3, 5]
.map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const contrast = (a, b) => {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};

Style header controls and their popovers consistently, including the period picker, settings dialog and object menu.

One approach that works well is to declare the header and its popovers once, outside the theme blocks, so they remain dark when the page theme changes. These values are placeholders too:

:root {
--chrome-surface: #262626;
--chrome-sunken: #1a1a1a;
--chrome-ink: #f0f0f0;
--chrome-muted: rgb(240 240 240 / 66%);
--chrome-line: #404040;
--chrome-accent: #bdbdbd;
--control-surface: #333333;
--control-ink: #f0f0f0;
--control-border: #4d4d4d;
}

Watch the shared button reset. Calendar days and header buttons often inherit background: var(--surface); color: var(--ink) from one base rule, which is the page’s theme; override each with the chrome tokens or they go invisible on a dark popover in the light theme.

A filter object is styled through operations, not CSS. Read the same token values from script when you build its operations, so the filter and the hand-built controls beside it cannot drift apart:

const token = (name: string) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();

A chart definition has no colour fields: none of the chart types’ supported definition features is a palette. Colour is set afterwards with operations such as setColoringType and setCustomPaletteColors; see Styling and theming for the mechanics.

A table definition is different: its presentation and totals take literal colours and font families. That freezes one theme’s colours into the definition. In an app with two themes, treat the definition’s colours as the light theme and apply the dark values as operations when the theme changes.

Put the palette on the chart, not on a global theme

Section titled “Put the palette on the chart, not on a global theme”

Charts in one dashboard rarely want the same palette. A bar with two measures colours per measure, a pie steps through tints of one hue, a sankey colours per node. One global palette cannot express that, so keep a palette per chart in your own chart configuration, with a light and a dark variant:

const salesByChannel = {
palette: {
// Placeholders: four tints of your app's own hue.
light: ['#262626', '#525252', '#6e6e6e', '#8c8c8c'],
dark: ['#f0f0f0', '#cfcfcf', '#adadad', '#8c8c8c'],
},
};
  • Set a colouring type only where a chart needs one. Palette operations are refused while a chart is drawn in one colour, and several chart types open that way; see Colouring types. Chart types exposing setColoringType share an enum, but do not render every value the same way. Verify changes on each affected chart type.
  • Check palette contrast in both themes. Use a separate dark palette where the light palette has insufficient contrast, especially for near-black and near-white colours.
  • A canvas-rendered object has no CSS hook. Pie slices, for example, are drawn to canvas, so an svg path { stroke } rule matches nothing. Use the type’s operations (for slice separation, setPieCornerRadius).

When several objects on a page colour the same dimension, give each value one colour everywhere: if Operational is dark blue in a grouped bar, it is dark blue in the bar that shows link status alone.

  • Colour the dimension in every object that shows it, or in none. A breakdown drawn in one colour beside a chart that colours the same values suggests the two show different things.
  • Share one palette. Keep the ordered palette for that dimension in one constant and apply it with setCustomPaletteColors to every object that colours the dimension, in both themes, after any colouring type that object needs.
  • Assign colours by value, not by position. Set setColoringPersistentColors to true on each of those objects, so a value keeps its colour when filtering removes or reorders other values; see Styling and theming.
  • Check each value in the browser. Persistent colours hash each value into the palette, so two values can share a colour. If they do, change the palette’s order or length until every value on the page has its own colour.
  • Let one chart be the key. A breakdown by the coloured dimension names each colour on its own axis, so the other charts on the page can drop their legends. An HTML legend cannot follow hashed colours; see Legend.
  • Use colour for one dimension per page. When a second dimension is coloured too, the two palettes compete for the same reading.

There is no operation that assigns a colour to a named value; see App limitations.

A theme change is appearance only. Restyle mounted objects with applyRuntimeOperations rather than compiling a new vSpec, which would re-query. Re-apply every operation that carries a colour, including the per-column and per-measure ones as well as the chart-wide ones: a table’s column colours live on the column, and a restyle that sends only the table-wide batch leaves its progress bars and indicators in the previous theme.

Load every font family used by visualization operations. Use the same family names in those operations and the page’s font tokens; unavailable fonts use the browser’s fallback.

Charts draw their axis, label and legend text in Roboto, on canvas, and read the font when they draw. Load Roboto in the page, and wait for it with document.fonts.load('12px Roboto') before mounting objects. Without it, chart text uses a fallback face that may not match your page.

Bundle fonts with your app from npm, importing only the subsets and weights you use. Replace <heading-face> with the family you chose for the app:

Terminal window
npm install @fontsource/roboto @fontsource/<heading-face>
import '@fontsource/roboto/latin-400.css';
import '@fontsource/roboto/latin-500.css';
import '@fontsource/<heading-face>/latin-600.css';

To check that a font loaded, look in document.fonts for a face with the status loaded. document.fonts.check() also returns true for a family that has no face at all.

In App Builder, the preview blocks remote stylesheets and fonts, so a web-font link does nothing there: bundle the font. Every imported file is embedded in the preview, so import few weights. A new Builder app’s src/styles.css maps the name Roboto to a system face. Delete those @font-face rules when you bundle Roboto itself.

Round an object’s corners on its host: give the container border-radius and overflow: hidden. The object’s own inner content box is square, so a radius set on the object alone can leave its header painting into the corner.

Dragging across a chart’s dimension axis marks the range with a label at each end (“Dec 2025”, “Aug 2026”). Those labels are small HTML elements with inline styles: a fixed white background, and text in the colour set for the axis labels. In a dark theme, where the axis labels are light, the range labels become light text on white and cannot be read. No operation styles them, so style them from your stylesheet. !important is needed because the styles are inline, and the label turns into an <input> while its value is edited, so cover both:

.viz-surface [data-key*='-bubble-'],
.viz-surface [data-key*='-bubble-'] input {
background-color: var(--surface) !important;
color: var(--ink) !important;
border: 1px solid var(--accent) !important;
}

Replace .viz-surface with the container you mount objects into, and the variables with your own tokens. To check it, drag a range across the chart’s axis and look at the labels while the range is still shown; they are DOM, so document.querySelectorAll('[data-key*="-bubble-"]') also lists them and their computed colours. If that finds nothing, the markup has changed and the rule does not apply.

This was verified on a combo chart with @astrato-io/visualizations 0.4.0 only. It has not been checked on 0.5.1 or on other chart types. The data-key pattern and the edit <input> are internal to the package, so run the check above before relying on the rule with any other version.

new Intl.NumberFormat(undefined, …) follows the viewer’s browser. The visualizations format their own axis and label text, so a browser locale that differs from theirs puts two number conventions on one page — a decimal comma in your cards beside a decimal point on the chart axis below them. Export one locale constant and use it in every formatter the app owns: KPI values, deltas, annotations, UI and exported reports. Ordinary value formatting needs no renderer exception; Chart rendering policy governs chart and analytical-table substitutions.

This does not show up on an English-locale machine. Check navigator.language and test with another locale before calling number formatting done.

Colour a rise and a fall literally — up and down — rather than as good and bad, unless the metric has an agreed direction. Colour deltas with --delta-up and --delta-down; on a coloured tile, where they can go muddy, redefine them on the tile. Reinforce them with an arrow or sign so colour is not the only signal.

Compare the colours and font names in copied theme modules against the app’s stylesheet tokens, then update any mismatches. Check charts, tables and filters as well as the surrounding page.