Skip to content

Styling

Control the visual appearance of your dashboard globally or per-chart.

For the implementation-backed inventory of single-chart properties, see the YAML Schema Reference. This page explains how that chart property surface relates to reusable themes and style presets.

For the current design-direction note behind dbt Charts's neutral frame colors, including why the product is exploring near-black ink and an off-white app canvas, see Tonal Foundations.

For the dbt Charts-specific chart palette direction, including the full catalog of shipped palettes with visible swatch previews, see Palettes.


Themes

A theme paints the whole board: typography, canvas, palette, and every axis, rule, and table row. Five ship with dbt Charts, you select one with a single line, and you build a custom one by extending it.

title: "Sales Dashboard"
theme: neon

rows:
  - revenue_chart

See Themes for all five side by side, what a theme owns versus what a style preset or a chart owns, and how to build a custom theme. This page covers the style: surface a theme paints, and the per-board and per-chart overrides that cascade on top of it.


Title and header overflow

Title overflow is a style-owned scaffold behavior. Use style.title.overflow to choose between clip, truncate, wrap-two, and wrap. The default is wrap-two. clip hard-cuts text, truncate adds an ellipsis, wrap-two wraps to two lines and ellipsizes the second line when needed, and wrap fully wraps. The same setting applies to Vega-Lite and table chart titles. A KPI has no title: style.label.overflow sets its label's mode, and defaults to the chart title's.

For tables, header labels wrap to two lines by default (the theme's charts.table.header.overflow); they do not follow the chart title's overflow mode. Override that with style.header_overflow for the whole table or style.columns[].header_overflow for a specific column.

Styling every chart's title

style.title styles the board's own heading (and, by default, feeds every chart title too). Use style.charts.title to style chart titles specifically, apart from the board heading:

style:
  title:
    font:
      color: "#111111"   # Board heading, and chart titles unless charts.title sets color
  charts:
    title:
      font:
        color: "#4b5563"   # Every chart's own title

Any field you don't set under style.charts.title falls back to style.title, which falls back to the theme: you never need to repeat the full set of title fields (font, sizes, position, overflow, and so on) to override just one. Precedence, low to high: style.title < style.charts.title < style.charts.<family>.title < a chart's own style.title (set on that one chart).


Global Styling

Set styling options for the entire dashboard using style: at the board root. The most commonly authored board-level style keys are:

style:
  background: "#f5f5f5"    # Working-surface background color
  font:
    family: "Inter"        # Font family name
    emoji: monochrome      # Emoji rendering mode
  formats:
    revenue: "$,.0f"       # Custom project-specific format alias
  charts:
    axis:
      grid:
        visible: true      # Chart-level axis grid default

For the full inventory of board-level style: keys, see the YAML Schema Reference. The top-level style: block is typed as StylePatch; all fields are optional, and they cascade on top of the selected theme. Commonly used top-level keys include background, font, formats, palettes, roles, charts, layout, variables, frame, title, text, border, accent, and muted.

Properties like palette, axis, legend, backgrounds, and css (which appeared in older examples) are not valid at the board style: root.

Style Options

  • background: Working-surface background color (hex, rgb, name, or "transparent")
  • font.family: Font family name
  • font.emoji: Emoji rendering mode (monochrome, system-default, disabled)
  • formats: Format alias map (see Format names and aliases)
  • charts.*: Chart-type style tree: grid, legends, palettes per chart (see Chart-Level Styling)

palettes: (role name → palette file name, for example, category: editorial-10) is authorable at the board level (not on chart-local style:, which rejects it) and still rebinds single-color role tokens (category[2], chrome[11]) wherever one is authored: a board's palettes: merges into the cascade that those tokens resolve against.

That token rebinding is a separate mechanism from palette roles (palette: category instead of palette: editorial-10), which are a built-in-theme-authoring feature only. Substituting a role for the palette file it names happens once, while one of the shipped themes (clarity, neon, paper, stark, vivid) loads. A board's own palettes: does not extend that substitution to the board's palette:/single_series_palette:/gradient palette: fields; nor does a chart's, nor a custom theme board's. Outside a built-in theme's own YAML, those fields must always name a shipped palette (see Color Palettes), never a role.

Data-freshness stamp

dbt Charts stamps how fresh the data is (Data as of 09:44 UTC on 30 Jul 2026) in the lower-left footer by default, on the same baseline as the footer attribution. The value is the query time for a fresh render, or the write time of the oldest cached query when results are served from cache, so the board never reads as fresher than it is. It is always shown in UTC, because a static export cannot know the viewer's timezone.

Use style.timestamp.position to move it between the footer baseline and the top row, style.timestamp.align to choose the left or right edge, and style.timestamp.format to change the text, a strftime string that carries the whole line, label included. The default is position: footer and align: left, and format: auto, which words the line in the board's regional.language around the time and date; likewise style.footer.text: auto and style.placeholder.overlay.text: auto. Write a value of your own and it prints verbatim, in any language.

style:
  timestamp:
    position: top
    align: right

A custom format that prints a clock time must show its zone; include %Z (or a literal UTC). The value is UTC, and an unlabeled clock would be misread as local time, so dbt Charts rejects a zone-less clock format at compile. A date-only caption needs no zone.

When the timestamp is right-aligned on the footer baseline, dbt Charts keeps the footer attribution on the far right and shifts the timestamp left so the two strings do not overlap. If the footer is hidden, a footer-right timestamp uses the right page edge.

Markdown text styling

Markdown prose in a text: block (body copy, inline `code` and fenced code blocks, and > blockquotes) is painted from the theme, so it tracks the active theme and accent automatically. Body text uses style.text.font; headings use style.title.font; links use style.accent. Inline code, fenced code, and blockquotes have their own box style groups under style.text, with the same font / background / border shape used by chart cards and callouts:

style:
  text:
    code:                 # inline + fenced code
      font:
        family: "ui-monospace, SFMono-Regular, Menlo, monospace"
        color: "#222222"
      background: "#f3f4f6"   # code surface fill
      border:
        color: "#e5e7eb"      # code box border
        width: 1
        radius: 4             # rounded code chips / blocks
    blockquote:           # > blockquote
      font:
        color: "#5f6b7a"      # muted quote text
        style: italic
      background: "#f9fafb"   # blockquote surface fill
      border:
        color: "#3b82f6"      # left rule (the theme accent by default)
        width: 3
        radius: 0
    divider:              # `---` lines and markdown-table gridlines
      color: "#e0e0e0"

style.text.divider is the one knob for both horizontal rules (---) and the gridlines of a markdown table written inside a text: block. It does not affect type: table charts; those draw their lines from style.charts.table.header.divider and style.charts.table.row.divider.

Dividers

Every separator line is a divider with the same four keys: visible, color, width (px) and dasharray (a dash pattern in px such as "6 3"; unset is solid). Table dividers add continuous. Each divider is complete in every theme, so writing one key changes only that key: setting style.text.column.divider.color recolors the line between prose columns and leaves it off.

Key Line
style.text.column.divider between prose columns (off by default)
style.text.divider markdown --- and markdown-table gridlines
style.footer.divider above the footer attribution
style.charts.table.header.divider under the table header
style.charts.table.row.divider under each body row (off by default)
style.charts.table.row.roles.{summary,total}.divider above summary and total rows
style.charts.support_table.divider between the plot and the support strip (off by default)
style.charts.support_table.row.divider under each support-strip row (off by default)

font accepts the full font overlay (family, color, size, weight, style, decoration, case); border is the standard width / color / radius group. Every key is optional in a board style:; set just the leaf you want to change (for example style.text.code.background to recolor code chips for one dashboard). The built-in neon theme ships darker code/blockquote surfaces; custom themes set these in theme YAML so all dashboards inherit them.

The font overlay is fully honored for code and blockquote prose: family, color, size, weight, style (italic), and decoration (underline / line-through) all render. case supports upper and lower; the richer transforms (title, sentence, slug, camel) are rejected on markdown prose rather than silently ignored.

Border & Corner Styling

Most border: groups in the theme (the text.code / text.blockquote boxes above, and callout cards) accept the full width / color / radius group (plus the optional dash_array / line_cap / dash_offset fields). A few interior slots render corner rounding only: their renderer never draws a stroke, so width and color would validate but do nothing. Those slots accept radius alone and reject width / color / dash_array:

  • style.variables.input.border: the variable-strip input controls
  • style.charts.spark_bar.border: the spark_bar chart's outer frame
  • style.charts.table.spark.columns.border: inline spark.type: columns bars
  • style.charts.table.spark.bar.border: inline spark.type: bar cells
title: "Top regions"

queries:
  regions:
    type: values
    columns: [region, revenue]
    values:
      - [West, 812000]
      - [East, 654000]
      - [Central, 431000]
      - [South, 298000]

charts:
  region_ranking:
    query: regions
    type: spark_bar
    x: revenue
    y: region

style:
  charts:
    spark_bar:
      border:
        radius: 8

rows:
  - region_ranking
Top Regions West812,000East654,000Central431,000South298,000 Data as of 14:30 UTC on 20 Oct 2026 made withdbt Charts
▶

A slot that does draw a stroke keeps the full group. style.text.code.border paints the box around a fenced code block, so all three fields land on the rendered rect: width as the stroke width, color as the stroke, radius as the corner rounding:

title: "Reading a board file"

style:
  frame:
    width: 700
  text:
    code:
      border:
        width: 2
        color: "#9aa4b2"
        radius: 6

rows:
  - text: |
      Every board is a YAML file. A chart names its query, its type, and the
      columns bound to each channel:

          charts:
            region_ranking:
              query: regions
              type: spark_bar
              x: revenue
              y: region
Reading a Board File Every board is a YAML file. A chart names its query, its type, and the columns bound to each channel: charts: region_ranking: query: regions type: spark_bar x: revenue y: region Data as of 14:30 UTC on 20 Oct 2026 made withdbt Charts
▶

Emoji handling

Themes control how emoji codepoints render via style.font.emoji. This field is required; the theme YAML must set it explicitly (no in-code default). The base theme cascade sets emoji: monochrome; all built-in themes inherit this via extends: unless they override it.

Value HTML surfaces SVG / PNG via vl-convert PDF via ReportLab
monochrome (default) Bundled Noto Emoji for the curated set, monochrome; anything else falls to the OS font Bundled Noto Emoji for the curated set; anything else is a missing glyph Bundled Noto Emoji on single-emoji runs; mixed-text emoji still gapped
system-default OS color emoji wins (Apple Color, Segoe UI Emoji, Noto Color Emoji) No emoji font registered; emoji codepoints render as missing glyphs Missing glyphs
disabled OS color emoji wins (no dbt Charts emoji wiring) Missing glyphs; no @font-face partial shipped Missing glyphs
# Default: dbt Charts's calm monochrome, same glyphs everywhere
style:
  font:
    emoji: monochrome
# Let the browser pick OS-native color emoji (HTML-only; SVG/PNG degrade)
style:
  font:
    emoji: system-default
# No dbt Charts emoji wiring at all
style:
  font:
    emoji: disabled

Note: system-default and disabled intentionally degrade on SVG/PNG/PDF surfaces; there is no OS fallback on those renderers. monochrome is the mode that carries emoji across surfaces, but only for the codepoints it bundles.

The bundled font covers a curated set of chart-relevant emoji (status marks, trend arrows, time, money, ops, org and geo, marks, documents, smileys, and weather), not the full emoji repertoire. An emoji outside that set falls to the OS font in a browser and is a missing glyph in SVG, PNG, and PDF. Two further limits worth knowing:

  • A codepoint spelled with the emoji variation selector (U+FE0F), the form most emoji pickers emit, is painted by the OS in a browser even when the bundled font carries it, because that selector requests emoji presentation and browsers answer it with a color font.
  • Text width is measured against the bundled font, so an emoji outside the set is measured as a narrow placeholder and painted at full width. Expect wrapping to be off around it.

If an emoji you need isn't covered, prefer a word or a chart glyph over relying on the fallback.


CSS Scope & Limitations

The style.css file styles the Dashboard Shell (HTML), but has limitations regarding internal Chart elements (SVG).

What CSS Can Style (HTML)

  • Layout: Rows, columns, grids, margins.
  • Typography: Section titles, markdown text, KPI cards, headers.
  • Interactive Elements: Filter inputs, buttons, tabs.
  • Backgrounds: Page background, section borders, shadows.

What CSS Cannot Style (Charts)

  • Chart Internals: Bars, lines, axes, legends inside the Vega-Lite charts.
  • PDF Exports: Static exports use a different rendering path where external CSS may not apply fully.

Recommendation: - Use Style Options (a chart's own style.color, chart.style) for chart colors and axes. - Use Custom CSS for layout, typography, and branding of the surrounding page.


Chart-Level Styling

Override theme for specific charts:

charts:
  custom_chart:
    title: "Custom Styled Chart"
    query: sales
    type: bar
    x: month
    y: total_revenue
    style:
      color:
        categorical:
          palette: editorial-10   # A built-in categorical palette
      legend:
        visible: true             # Show legend (default)
      axis:
        grid:
          visible: false           # Hide grid lines

Chart styles are sparse overlays on the active theme. An omitted field inherits the nearest theme or board-level value. An explicit null is accepted only where it means no value: legend.symbol_limit: null removes an inherited entry cap, footer.link: null renders no link, and axis_y.labels.format: null emits no format. Any other null fails validation; omit the key instead.


Style Options

Color Palettes

Choose from built-in color palettes:

  • editorial-10 - Default categorical palette: blue-first ten-color set tuned for calm, editorial multi-series charts
  • vivid-10 - Higher-saturation ten-color categorical set
  • tableau - Tableau-style 10-color set, for users who want the familiar hues
  • dbt-seq-blue - Blue sequential palette (light → dark)
  • dbt-seq-green, dbt-seq-amber, dbt-seq-rust, dbt-seq-teal, dbt-seq-purple, dbt-seq-brown - Single-hue sequential palettes
  • dbt-seq-gray - Achromatic sequential palette (magnitude without hue)

Choosing a palette: - Categorical data: Use editorial-10 or another categorical palette - Sequential data: Use a dbt-seq-* palette - Accessibility: Consider colorblind-friendly palettes

The full catalog, including diverging palettes, tonal category sets, and dark-theme companions, is in the Palettes guide.

dbt Charts Palette Direction

Alongside the generic built-in schemes above, dbt Charts now documents its own chart-library palette direction:

  • the default dbt Charts categorical palette is a blue-first ten-color set tuned for calm, editorial multi-series charts
  • single-series charts (one mark, no color encoding) inherit the active theme's single-series ink (for example, dark blue on clarity, warm dark brown on paper) instead of falling back to the categorical palette's anchor
  • hero-6 is a hero-blue-versus-neutrals palette for charts where one series should dominate

See Palettes for the full catalog with swatch previews.

A neutral single-series mark

Every theme's single-series ink is a color (dark blue on clarity, warm dark brown on paper) because most boards want a chart's one mark to read as data, not chrome. When a chart genuinely encodes nothing by hue and a neutral mark communicates that more honestly, pin single_series_palette to a gray scaffold step instead.

At board scope, every single-series chart on the board goes neutral:

style:
  charts:
    color:
      categorical:
        single_series_palette: [dbt-grays.10]

At chart scope, only the one chart goes neutral; a static color skips the palette-slot machinery entirely, since a single-series chart never has more than one slot to skip:

style:
  color:
    static: dbt-grays.10

Legend

Control legend display per chart (within a chart's style: block):

charts:
  my_chart:
    type: bar
    style:
      legend:
        visible: false  # Hide legend

Top-horizontal legends hide their title automatically because the color-channel name is usually redundant. Set title.visible: true to opt in when the title adds useful context:

charts:
  my_chart:
    type: bar
    style:
      legend:
        position:
          edge: top
        title:
          visible: true   # opt in; omitted means hidden for this layout

For other legend layouts, title.visible: false hides only the title while keeping the color swatches.

direction is inferred from position.edge: top and bottom legends are horizontal, left and right are vertical. Author direction to override. An inferred-horizontal legend too wide for one row wraps at compact_columns; author direction or columns to opt out.

Automatic compact top legends and wrapped horizontal legends use compact_columns from the active legend style (the base theme uses two). Set a positive integer on a chart's legend style to tune that automatic wrapping; columns: 0 leaves ordinary legends on the renderer default.

style.legend is not supported on kpi or table charts; neither renders a color legend, so authoring it is a validation error.

values pins an explicit legend entry order and filter; see Legend for the full resolution rules (matching a column name against its rendered label, and what happens when an entry doesn't resolve):

charts:
  my_chart:
    type: bar
    style:
      legend:
        values: ["Paid", "Organic", "Referral"]

Grid Lines

Show or hide grid lines per chart (within a chart's style: block):

charts:
  my_chart:
    type: bar
    style:
      axis:
        grid:
          visible: true    # Show grid lines
          # visible: false  # Hide grid lines

Ratio-percent bar, line, area, and scatter charts automatically emphasize the unity baseline at y=1 when the effective y-domain includes that value. This is the common 100% guide for retention, conversion rate, and NRR charts stored as ratios such as 1.08. Style the rule with the existing axis_y.grid.threshold color and width settings, or hide it on its own with grid.threshold.visible: false, which leaves the axis's other gridlines in place:

charts:
  nrr:
    type: line
    style:
      axis_y:
        scale:
          continuous:
            domain: [0.95, 1.2]
        grid:
          threshold:
            width: 3
            # visible: false  # hide the threshold rule only
        labels:
          format: percent_whole

The same grid.threshold style block is authorable on any axis slot, not just axis_y. A chart with its own quantitative x column, a scatter plotting one ratio against another for example, earns an independent zero or unity rule on that axis too; style or suppress it the same way, scoped to axis_x.grid.threshold.

Holding an Axis Still Across Filter Changes

A chart's measure axis is scaled to the rows its query returned, so changing a filter variable rescales it; the same value can land at a different pixel before and after the change. When viewers compare values across filter selections, pin the bounds yourself with an explicit domain:

charts:
  quarterly_revenue:
    type: bar
    x: month
    y: revenue
    style:
      axis_y:
        scale:
          continuous:
            domain: [0, 800]   # same frame of reference for every selection

An authored domain wins over every other axis-scaling behavior; headroom and the zero-baseline heuristic both step aside for it.

Starting a Line Chart at Zero

A line chart's y-axis fits the data: when every value sits far from zero, the axis zooms in so the line's movement fills the plot. Bar and area charts start at zero by default because their marks encode length. To anchor a line chart at zero, pin zero: true; zero: false forces the zoomed axis:

style:
  axis_y:
    scale:
      continuous:
        zero: true

To make that the default for every line chart in a project, set it on the line family in charts/meta.yml (or in a theme layer the project extends). Bar, area, and scatter charts are untouched, and a chart's own zero: false still wins:

style:
  charts:
    line:
      axis_y:
        scale:
          continuous:
            zero: true

Axis Labels

Hide axis labels with labels.visible: false:

style:
  axis_y:
    labels:
      visible: false   # hide y-axis labels entirely

This matches the existing convention for other axis elements (grid.visible, line.visible, ticks.visible).

Suppressing the axis title alongside x_label/y_label

style.axis_y.title.visible / style.axis_x.title.visible control whether the axis title itself is drawn. Authoring x_label/y_label normally forces the axis title on (it overrides the theme's default suppression so the label has somewhere to show up), but an explicit title.visible: false authored anywhere on the chart (style.axis_y/style.axis_x, the type-conditional style.axis_quantitative/style.axis_band, or the chart's shared style.axis patch) still wins and suppresses just the on-axis text. The label keeps doing its other jobs (the tooltip and any endpoint/rail labels), so y_label: "% from Data Lakes" plus axis_y.title.visible: false is a legal, supported combination for a chart that wants the label without the axis-title space it reserves.

This precedence holds for a board-level override too: a style.charts.axis_y.title.visible: false set once on the board suppresses the axis title on every chart, even those that author y_label. An explicit title.visible you author, whether on the board or on a single chart, always wins over the label-forcing default; a chart-level override just narrows the effect to that one chart.

Axis settings follow one rule: the more specific path wins, whichever file set it. style.charts.axis_y from a board loses to the theme's style.charts.axis_quantitative and style.charts.bar.axis_y, so name the chart type or the quantitative slot to override a theme's value there. The type slot (axis_quantitative, axis_band) beats the channel slot (axis_x, axis_y), which beats axis; this holds in a chart's own style too.

Label density

Three knobs control how densely labels are packed on an axis:

labels.overlap: two switches for resolving overlapping x-axis labels. On temporal axes dbt Charts tries skip before tilt. For bucketed temporal axes, skip shows labels at the next meaningful calendar boundary while leaving the axis, label-format time unit, and ticks unchanged. A resolved coarser labels.time_unit, whether authored or chosen automatically, also sets the default tick cadence. Categorical axes never skip because every category label carries domain information; they only use an authored font size and tilt. If the automatically thinned labels still do not fit, tilt rotates them through tilt_increments, shallowest first. Font size never changes.

Quantitative x-axes bypass overlap resolution because Vega-Lite places their continuous-scale ticks.

labels.min_gap: minimum pixel gap between adjacent label bounding boxes before they count as non-overlapping (default 16). Only active when overlap resolution is on. Raise it to add visible breathing room between retained labels.

labels.max_width: maximum pixel width before a label is truncated with … (Vega-Lite default 180px). Tighten for narrow charts; loosen for charts with long category names.

style:
  axis_x:
    labels:
      overlap:
        skip: true
        tilt: true
      min_gap: 24
      max_width: 120
Positioning and boundary

Three knobs control where a label sits relative to its tick and whether it survives clipping at the axis range edges.

bound: hide labels whose bounding boxes overflow the axis range.

Reach for it when a categorical x-axis has one very long label at the first or last position that bleeds into the chart padding or off the canvas. Accepts true (hide any overflow) or a pixel tolerance (hide only when overflow exceeds that many pixels).

charts:
  product_revenue:
    type: bar
    x: product_name     # categorical, some labels very long
    y: revenue
    style:
      axis_x:
        labels:
          bound: true   # hide labels that bleed past chart edge

flush: align the first and last labels flush with the scale range rather than centered on their tick.

Reach for it when time-series edge labels ("Jan 2022" / "Dec 2024") get clipped at chart edges. Note: Vega-Lite defaults differ by axis: true for x, false for y. Accepts true/false or a pixel tolerance (flush only when label exceeds by more than that many pixels).

style:
  charts:
    axis_y:
      labels:
        flush: true     # opt y-axis into flush alignment (VL default is false)
charts:
  monthly_revenue:
    type: line
    x: month
    y: revenue
    style:
      axis_x:
        labels:
          flush: 4      # flush only if label exceeds scale edge by more than 4px

offset: pixel offset of the label from its tick anchor.

Reach for it when fine-grained nudging is needed, for example when axis_x.ticks.offset shifts the ticks themselves or when bar-band tick positioning leaves labels misaligned with bar centers. Negative values move labels toward the chart interior; positive toward the chart edge.

charts:
  weekly_signups:
    type: bar
    x: week
    y: signups
    style:
      axis_x:
        labels:
          offset: -3    # nudge labels 3px toward chart interior

Format names and aliases

All named format identifiers in dbt Charts fall into one of two categories: engine-predefined names (owned by the engine, resolved with house rules) and custom project aliases (defined in style.formats: a d3 spec, a predefined name, or a format block).

Engine-predefined names

The following names are built into the engine. Authors write them in any format: field. A board may redefine one in style.formats, as long as the redefinition is the same kind (number or date).

format: slots take either kind: the column decides whether a date or a number is being painted. A redefined engine name must keep its kind (ERR-FORMAT-KIND-MISMATCH): a date spec under currency would paint a number axis with garbage rather than failing.

Numeric formats:

Name Example output
currency $1.23M
currency_whole $1,235
currency_full $1,234.57
currency_compact $1.2M
percent 12.3%
percent_whole 12%
percent_delta +12.3%
integer 1,235
delta +31
number 1.23M
number_full 1,234.57
compact 1.2M
year 2025
id 1204331
duration 1h 23m
bytes 1.53 MB
latitude 37.7749°
longitude −122.4194°
rating 4.5

The bare name compacts; the _full suffix spells the number out. currency and number are the names to reach for on a dashboard, where a label has to be read at a glance. currency_full and number_full are for the surfaces that must show every digit: a reconciliation view, a billing breakdown, anything that has to tie out against a source system. currency_whole and integer drop the minor unit at every magnitude. compact and currency_compact compact like number and currency but keep two significant digits (1.2M, 45K, 1.2B) for a headline where the third digit is noise. They are analytic in every slot (K, M, B, never k, mn, bn), KPIs included.

currency and number apply SI notation for large values, rounded to three significant digits with trailing zeros trimmed: 50752.9 renders as $50.8K. A raw d3 ~s spec is not the same thing: d3's own default is six significant digits, so format: "$~s" gives $50.7529K. (Both shown in the same analytic register here; a literal $~s authored inline also renders in d3's native register, $50.7529k, as described under Format aliases.)

percent, percent_whole, and percent_delta expect a 0–1 ratio; the engine multiplies by 100 before formatting, so 0.182 renders "18.2%". percent and percent_whole refuse a 0–100-shaped value outright rather than paint "1820%" (ERR-PERCENT-RANGE); divide by 100 before it reaches the chart. percent_delta is exempt, because a delta legitimately runs past 100%.

year is an identifier, not a quantity. Every other numeric name groups digits or scales by magnitude; a year should have neither (2,025 and 2.025K are both wrong for a year). year renders plain integer digits with no grouping.

Ids, durations, file sizes, coordinates and ratings. A name for each kind of number that number would otherwise abbreviate (an order id 1204331 is not 1.2 M). Each takes the plainest unit the data carries:

  • id prints the digits with no separator and never compacts. A text id (A-1204331) passes through unchanged.
  • duration reads a span in seconds and prints its two largest units: 5025 is 1h 23m, 45 is 45s, 273600 is 3d 4h. A zero lower unit is dropped (3600 is 1h), and a fraction of a second rounds half up.
  • bytes reads a size in bytes and prints decimal SI (1 kB is 1000 B) to three significant digits with the unit spaced from the number at every magnitude: 950 B, 1 kB, 1.53 MB.
  • latitude and longitude read signed degrees and print up to four decimals with a degree sign: 37.7749°, −122.4194°. A whole degree prints −122°, so axis ticks stay short.
  • rating prints one decimal: 4.5, 4.0.

duration and bytes paint on chart axes, value labels and tooltips, and in a kpi or a table. A unit the engine has no name for (km, mi) is a custom format; see A unit of your own.

title: "One column per kind of number"

queries:
  files:
    type: values
    columns: [order_id, latitude, longitude, rating]
    values:
      - [1204331, 37.7749, -122.41942, 4.5]
      - [1204332, 51.5074, -0.1278, 3.0]
      - [1204333, -33.8688, 151.2093, 4.8]

charts:
  kinds:
    query: files
    type: table
    style:
      columns:
        order_id:
          format: id
        latitude:
          format: latitude
        longitude:
          format: longitude
        rating:
          format: rating

rows:
  - kinds
One Column per Kind of Number Order IDLatitudeLongitudeRating120433137.7749°−122.4194°4.5120433251.5074−0.12783.01204333−33.8688151.20934.8 Data as of 14:30 UTC on 20 Oct 2026 made withdbt Charts
▶
title: "Time to first response"

queries:
  handle_time:
    type: values
    columns: [team, seconds]
    values:
      - [Billing, 95]
      - [Support, 62]
      - [Sales, 118]

charts:
  handle:
    query: handle_time
    type: bar
    x: team
    y: seconds
    style:
      marks:
        bar:
          labels:
            visible: true
            format: duration

rows:
  - handle
Time to First Response 050100SalesBillingSupport1m 35s1m 2s1m 58s Data as of 14:30 UTC on 20 Oct 2026 made withdbt Charts
▶

Engine-only formats (handled directly by the engine, not through a format spec; valid on KPI and table format slots only, not on Vega-rendered slots such as axis labels, mark value labels, or support_table). duration and bytes are not in this group: they have a Vega form and paint on charts as well.

Name Behavior
percent_number Formats 27.6 as 27.6% (value is already a percentage)
percent_number_delta Formats +3.2 as +3.2% with sign (value is already whole percent)
percentage_points_delta Formats +1.5 as +1.5 pts with sign. Input is whole points, not a fraction: a delta of 0.575 - 0.47 renders +0.1 pts; multiply by 100 in the query

Time formats: the names a date format takes, beside a raw strftime spec like %b %Y:

Name Example output
date_short 7 Mar 2024
time_short 14:05
date_year 2024
date_quarter Q1
date_month Jan
date_week W10

A date axis labels by the grain it detects (a monthly series reads Jan, with the year on the first label and on every January), and date_year, date_quarter, date_month and date_week name those labels. The engine's own date_month is that vocabulary, not a spec, so a slot that names one the board has not redefined raises ERR-FORMAT-GRAIN-NAME-NOT-REDEFINED. Redefine the name in style.formats with a strftime spec to relabel that grain on every axis and tooltip header of the board:

title: "Monthly revenue"

style:
  formats:
    date_month: "%b '%y"

queries:
  monthly:
    type: values
    columns: [month, revenue]
    values:
      - ["2026-01-01", 120]
      - ["2026-02-01", 150]
      - ["2026-03-01", 135]
      - ["2026-04-01", 180]

charts:
  revenue:
    query: monthly
    type: bar
    x: month
    y: revenue
    style:
      orientation: vertical

rows:
  - revenue
Monthly Revenue Jan '26Feb '26Mar '26Apr '26050100150 Data as of 14:30 UTC on 20 Oct 2026 made withdbt Charts
▶

The axis reads Jan '26, Feb '26, and the tooltip header the same. An axis's own labels.format still wins, and a number spec under a grain name raises ERR-FORMAT-KIND-MISMATCH. Days keep date_short.

Clickable text formats: an email column is a mailto: link in tables and KPIs; other charts print the text. url does the same for a web address.

Name A table cell or KPI value links to
email mailto:ann@example.com
url the value itself, which must be an absolute http(s) URL

The value prints unchanged. An authored link:, a foreign-key drill and a filter link each win over the format's link, and an empty value stays plain text. The names take no spec, so style.formats cannot redefine them (ERR-FORMAT-TEXT-REDEFINED) and a Vega-rendered slot such as an axis label rejects them (ERR-FORMAT-TEXT-IN-VEGA-SLOT). A number under either raises ERR-TABLE-FORMAT-KIND-MISMATCH, and a url value without an http:// or https:// scheme raises ERR-URL-VALUE-NOT-ABSOLUTE.

Name a column's format once, on its query. A query's formats: gives a column one of these names (or a style.formats name) and every chart that shows the column uses it, with no format: repeated per chart. A chart sets its own slot only for an exception:

queries:
  revenue_by_month:
    columns: [month, revenue, orders]
    values:
      - [2026-01-01, 1234567, 4210]
    formats:
      revenue: currency
      orders: integer

Using predefined names in charts

charts:
  revenue:
    type: bar
    x: month
    y: revenue
    style:
      axis_y:
        labels:
          format: currency

  conversion:
    type: bar
    x: date
    y: rate
    style:
      axis_y:
        labels:
          format: percent

  raw_d3:
    type: bar
    x: month
    y: amount
    style:
      axis_y:
        labels:
          format: "$,.0f"

Custom project aliases (style.formats)

Add style.formats at the theme or board level to define project-specific short names. Board keys override theme keys; theme keys not redefined by a board propagate through unchanged.

Restyle every chart at once by redefining an engine name. number, currency, percent, date_short and the other engine names are keys like any other. Every axis, tooltip, mark label, table cell, KPI value and data-table strip with no format of its own reads the board's definition:

style:
  formats:
    currency: "$,.0f"
    number: ",.0f"
    date_short: "%b %Y"

A query's formats: and a slot's own format: (an axis's labels.format, a KPI's value.format) still win. A redefined name prints exactly the spec you wrote, at every magnitude: the engine's rules for that name (compaction, trimmed zeros, and the switch to currency_full / number_full below 1) no longer apply, so a redefined currency of $.3~s prints $670m for 0.67. Redefine currency_full or number_full too and the engine's own currency and number use yours below 1. A redefinition must stay the same kind as the name: a date spec under currency raises ERR-FORMAT-KIND-MISMATCH.

A unit of your own

The engine names no distance, weight or currency unit beyond the ones above. A unit is a custom format: an object with a spec and a suffix, defined once in style.formats and used by name:

title: "Distance"

style:
  formats:
    km:
      spec: ",.0f"
      suffix: " km"

queries:
  trips:
    type: values
    columns: [route, distance]
    values:
      - [Lisbon to Madrid, 625]
      - [Madrid to Paris, 1054]
      - [Paris to Berlin, 1054]

charts:
  routes:
    query: trips
    type: table
    style:
      columns:
        distance:
          format: km

rows:
  - routes
Distance RouteDistanceLisbon to Madrid625 kmMadrid to Paris1,054Paris to Berlin1,054 Data as of 14:30 UTC on 20 Oct 2026 made withdbt Charts
▶

In a theme YAML:

style:
  formats:
    revenue: "$~s"       # project-specific alias
    arr: "$,.0f"
    cost: ",.2f"

In a board YAML (adds or overrides for this board only):

style:
  formats:
    arr: "$,.2f"    # override theme's arr alias for this board

An alias to a literal d3 spec resolves with no house post-processing. An alias to a predefined name keeps that name's house rules, so a block can name a house format with an affix, such as a second currency beside the board's own:

style:
  formats:
    usd:
      spec: number
      prefix: "US$"

An alias target that is none of these raises ERR-FORMAT-INVALID at compile time.

Any string that isn't a known name or alias is tried as a raw d3 format spec. format: "$,.0f" on an axis slot is a supported escape hatch for cases where none of the named presets fit; it passes through unchanged and is validated as a legal d3 string at compile time.

A format with its own prefix or suffix: the FormatConfig object form

A board has one currency, set by regional:: every money format and every $ spec prints it. Use the object form below only for what that cannot express: a second currency on the board (define it once as a style.formats alias), a long-form name (50 US dollars), or a combination like 50 $ US.

Every format: field above -- an axis's labels.format, axis_y.mirror.format, a mark's labels.format/ total_label.format, a layer's own axis_y.labels.format, a support_table entry's format, and each value in style.formats -- also accepts an object instead of a string:

style:
  axis_y:
    labels:
      format:
        spec: ",.0f"      # a raw d3 spec, or an engine-predefined name
        suffix: " US dollars"

A $ in spec, the money names' included, prints the board's currency, so a second currency goes in prefix/suffix over a spec without $. An affix on a money name composes around the board's currency instead: {spec: currency, prefix: "US "} prints US $12.4K on an en-US board.

Sign placement. sign_placement: before_prefix puts a negative sign ahead of the prefix (−€500); after_prefix puts it between prefix and digits (EUR −500). Unset, a prefix ending in a space (a word or code) takes after_prefix, any other prefix before_prefix. Where the prefix paints on one value only (see repeat), the sign always follows it, so the anchor's digits line up with the bare values beside it (€−50 above −40); before_prefix there raises ERR-FORMAT-SIGN-BEFORE-ANCHORED-PREFIX. A suffix always trails the digits (−500 EUR). A table column keeps the sign against the digits (€ −500) because its prefix lane is shared down the column, so sign_placement on a table column raises ERR-FORMAT-SIGN-PLACEMENT-TABLE-UNSUPPORTED.

Repeat. repeat: anchor paints the prefix and suffix on one value per axis, table column or support_table row; the engine picks which. repeat: every paints them on every value. Unset keeps each surface's default: a vertical axis and a symbol_mode: anchors table anchor, a horizontal bar's value axis repeats, and a support_table row anchors a predefined format's affix where its cells have an order. Value labels, totals, tooltips and KPIs show one value, so they ignore repeat.

style:
  axis_y:
    labels:
      format:
        spec: ",.0f"
        prefix: "EUR "
        repeat: anchor

A prefix/suffix combines freely with an SI spec, compacting axis and all -- a number spec with prefix: "US$" paints US$12.4M on an axis exactly the way the engine's own currency preset paints $12.4M; the affix composes around the shared-magnitude ladder rather than colliding with it. A raw .3~s literal keeps d3's own register (US$12.4M) unless notation says otherwise.

A prefix/suffix also composes cleanly with a literal spec that carries d3's own $ symbol -- the authored prefix comes first, then the symbol's own body: a $,.0f spec with prefix: "US " paints US $154,500 (US −$154,500 for a negative), never $US 154,500 or a dropped prefix.

Restrictions. None of these targets has an expression slot to compose a prefix/suffix/notation into:

  • A temporal axis's format (ERR-FORMAT-AFFIX-TIME-UNSUPPORTED, caught at compile) -- a strftime spec paints through Vega's native time formatter, not d3-format's number grammar.
  • A categorical (nominal/ordinal) axis (ERR-FORMAT-AFFIX-NOMINAL-AXIS-UNSUPPORTED), which fires at resolve, once the actual channel type is known.

A table column or KPI paints a date spec's affix around the date instead: a %b %Y spec with prefix: "FY " on a table column paints FY Jan 2026.

notation overrides the analytic (1.2M, suffix stated once) vs. narrative (1.2mn, repeated per value) register that currency/number otherwise pick automatically. It only affects an SI-shaped spec -- useful on a raw ~s literal with notation: narrative, which otherwise renders in d3's own native register instead of either house register. It does nothing to a date or any other non-SI spec.

An authored labels.expr overrides the axis's format entirely. When an axis carries a custom label expression, it renders exactly as written -- no prefix, suffix, or notation from the format is composed into it:

style:
  axis_y:
    labels:
      format:
        spec: ",.0f"
        prefix: "EUR "
      expr: "datum.label + ' /mo'"

Inside the expression, datum.label is Vega's own text formatted from the plain d3 spec, so a native $/# in the spec appears in it, but a FormatConfig prefix, suffix, or notation does not. Write the symbol into the expression yourself:

style:
  axis_y:
    labels:
      expr: "'EUR ' + datum.label + ' /mo'"

A surface the expression doesn't govern, like the tooltip, still paints the full format.

The object form works as a style.formats alias value too, referenced by name everywhere a string alias is. That is how a board mixes currencies: the board's own currency comes from regional:, each other one is an alias.

regional:
  currency: EUR
style:
  formats:
    usd:
      spec: ",.0f"
      prefix: "US$"
charts:
  revenue:
    type: bar
    x: month
    y: revenue_usd
    style:
      axis_y:
        labels:
          format: usd

Suppressing format with null

Use format: null (or omit format:) to suppress format emission and let VL/D3 pick its own default based on the axis scale type. This is the canonical way to override an inherited theme-level format: number per chart; no format: auto string sentinel.

Regional settings

regional: sets how a board writes numbers and dates: locale.number picks the separators and the currency template, locale.time the month, weekday and AM/PM names, currency the symbol that template prints, language the words dbt Charts generates (tab names, series counts, control labels, tooltip and duration words, quarter names, units, the footer line, and how case: title capitalizes).

regional:
  locale:
    number: de-DE   # default en-US
    time: de-DE     # default en-US
  currency: USD     # default: the number locale's own
  language: de      # default: the time locale's language

language is one of en, de, fr, es, it, pt, nl, sv, da, nb, fi, pl, cs, ru, uk, tr, ja, zh, ko. Unset, it is the language of locale.time (de-DE is de), or en when that locale has no catalog (ar-EG) or is a full definition.

Set it in charts/meta.yml and every board beneath it follows; a board's own regional: overrides it field by field. A nested board takes its root board's regional:; a different one is an error (ERR-REGIONAL-NESTED-MISMATCH). Themes don't carry it.

Number locale

locale.number is a d3-format locale name (fr-FR, de-CH, en-IN, ...). It sets the thousands separator, the decimal mark, the percent and minus signs, and where the currency goes: 1 234 567,5 in fr-FR, 12,34,567.5 in en-IN, 1.234,50 € in de-DE. It also names the magnitude abbreviations, in de-DE 1,5 M where a magnitude is declared once (an axis) and 1,5 Mio. on every value (a label).

For a locale d3 doesn't ship, write the definition yourself, mirroring d3's formatLocale:

regional:
  locale:
    number:
      decimal: ","
      thousands: "."
      grouping: [3]
      currency: ["", " EUR"]
      percent: " %"   # optional, default %
      minus: "-"      # optional, default −
      analytic: [" k", " M", " Mrd", " Bio"]           # optional, default en-US
      narrative: [" Tsd.", " Mio.", " Mrd.", " Bio."]  # optional, default en-US

analytic and narrative are the thousand, million, billion and trillion suffixes: analytic is the extra-short form, where a magnitude is declared once (1.5M on an axis); narrative is the abbreviated form, on every value (1.5mn). Each is printed as written, so a leading space is part of the suffix.

Time locale

locale.time is a d3-time-format locale name. It names the months, weekdays and AM/PM on every painted date: axis ticks, tooltips, table cells, and the %c, %x and %X composite formats. It also sets the day a date picker's calendar week starts on, from the locale's region (CLDR): Monday in de-DE, Sunday in en-US, Saturday in fa-IR. de-DE prints Mrz and Okt where en-US prints Mar and Oct. The number and time locales are independent: set both for a German board.

d3 ships time locales for ar-EG, ar-SY, ca-ES, cs-CZ, da-DK, de-CH, de-DE, en-CA, en-GB, en-US, es-ES, es-MX, fa-IR, fi-FI, fr-CA, fr-FR, he-IL, hr-HR, hu-HU, it-IT, ja-JP, ko-KR, mk-MK, nb-NO, nl-BE, nl-NL, pl-PL, pt-BR, ru-RU, sv-SE, tr-TR, uk-UA, vi-VN, zh-CN and zh-TW. For any other language or region, such as pt-PT, write the definition, mirroring d3's timeFormatLocale:

regional:
  locale:
    time:
      date_time: "%A, %e %B %Y %X"
      date: "%d/%m/%Y"
      time: "%H:%M:%S"
      periods: [AM, PM]
      days: [Domingo, Segunda-feira, Terça-feira, Quarta-feira, Quinta-feira, Sexta-feira, Sábado]
      short_days: [dom, seg, ter, qua, qui, sex, sáb]
      months: [janeiro, fevereiro, março, abril, maio, junho, julho, agosto, setembro, outubro, novembro, dezembro]
      short_months: [jan, fev, mar, abr, mai, jun, jul, ago, set, out, nov, dez]
      week_start: sunday

date_time, date and time are the patterns %c, %x and %X expand to; date_time may not contain %c, and date and time may not use %c, %x or %X. days starts on Sunday; week_start is sunday through saturday.

Currency

The engine money names -- currency, currency_whole and currency_full -- are d3 specs with a $ ($,.2f), and d3 prints the locale's currency for a $: its symbol, at its side, with its gap. A $ in a spec you write does the same. currency swaps the symbol for an ISO 4217 code's and keeps the locale's placement:

locale.number currency currency_full of 1234.5
en-US $1,234.50
de-DE 1.234,50 €
en-US EUR €1,234.50
de-DE USD 1.234,50 $
en-US CHF CHF 1,234.50

A word symbol gets a space where the locale's own symbol had none (CHF 1, not CHF1). currency accepts USD, EUR, GBP, JPY, CAD, AUD, CHF, SEK, NOK, DKK, INR, CNY, BRL and MXN; JPY has no minor unit, so currency_full prints whole yen. A custom locale whose currency template is empty has no place for a symbol, so setting currency beside it is an error.

title: "EMEA results"

regional:
  locale:
    number: de-DE
    time: de-DE

queries:
  region_results:
    type: values
    columns: [region, revenue, target, price]
    values:
      - [Nordics, 1234567, 1500000, 0.67]
      - [Benelux, 987654, 1100000, 0.82]
  total:
    type: values
    columns: [revenue]
    values:
      - [2222221]

charts:
  revenue_kpi:
    query: total
    type: kpi
    value: revenue
    style:
      value:
        format: currency
  revenue_bar:
    query: region_results
    type: bar
    x: region
    y: [revenue, target]
    style:
      axis_y:
        labels:
          format: currency
  results_table:
    query: region_results
    type: table
    style:
      columns:
        revenue:
          format: currency_whole
        target:
          format: currency_whole
        price:
          format: currency

rows:
  - revenue_kpi
  - revenue_bar
  - results_table
EMEA results 2,22Mio. €0500 Tsd. €1.000 Tsd. €1.500 Tsd. €NordicsBeneluxRevenueTargetRegionRevenueTargetPriceNordics€1.234.567€1.500.000€0,67Benelux987.6541.100.0000,82 Daten vom 20 Okt 2026, 14:30 UTC erstellt mitdbt Charts
▶

Placement. Where the currency repeats on every value -- a KPI, a label, a tooltip, a repeat: every column, an axis that repeats its unit -- it takes the locale's side (1,23 M €). Where it is declared once -- a table column in symbol_mode: anchors, an anchored support_table row, an axis that labels one tick -- it leads the digits (€1,23 M, then bare values).

One currency per board. regional: sets the currency for the whole board, including every $ spec you write. To show a second currency, or one regional.currency lacks, define a style.formats alias in the object form with prefix: or suffix: over a spec without $.


Typography

Every piece of text on a board reads one of seven named fonts declared under style.fonts. Change a named font once and every title, label, or caption bound to it follows:

style:
  fonts:
    heading:
      family: "'Source Serif 4', Georgia, serif"
    body:
      family: "Inter"
Font Used by
heading Board titles, chart titles, markdown # to ######
subtitle Title subtitles
body Markdown prose
label Axis and legend labels and titles, tooltips, KPI labels, table cells and headers, variable controls, tabs, details
caption Footers, timestamps, callout messages
numeral KPI values, quantitative axis labels, support tables, pie totals
code Inline and fenced code in markdown

A named font takes the same fields as any font: block (family, color, size, weight, style, decoration, case, line_height) and sets only the fields it names. A board's fonts: merge with the theme's key by key, so redefining heading keeps the theme's body. KPI fonts paint one line and take no line_height; a named font's is dropped there.

Heading sizes

Heading levels H1 to H6 share the heading font and differ only in size. The sizes live on the heading font as a six-step list, sizes; heading takes no size:

style:
  fonts:
    heading:
      sizes: [30, 20, 16, 14, 11, 11]

A board title reads its level's step, markdown # reads the first step, and chart titles pick a step by card width.

Applying a named font to a slot

Any font: slot except the root style.font and details.arrow.font takes a named font in one of three forms:

style:
  title:
    font: heading
  charts:
    kpi:
      value:
        font:
          extends: numeral
          size: 40
  • By name: font: heading uses the named font as-is.
  • By name with overrides: extends: plus the fields that differ.
  • Inline: a plain font: block with no name.

An unknown name raises ERR-FONT-UNKNOWN and lists the fonts in scope. When a slot names a font, it replaces whatever a theme bound to that slot. The root style.font is the base every other font inherits from, so it rejects a name; set style.fonts.body to restyle base text.

Fonts that extend fonts

A style.fonts entry takes the same forms as a slot, so a font can start from another font. Define a per-role font once and bind slots to it:

style:
  fonts:
    tag:
      extends: label
      weight: 600
      case: upper
  charts:
    kpi:
      label:
        font: tag

A bare name makes an alias: small: caption is a second name for caption.

An entry is its parent's fields plus its own, and its own fields win. Chains of any length work; a cycle raises ERR-FONT-REGISTRY and names the path. sizes is the heading ramp and is never inherited. extends: null gives an entry no parent. When a board's entry sets extends, it replaces the theme's entry of the same name instead of tweaking it.

Families

Common font choices: - Inter - Modern, readable sans-serif - Roboto - Google's Material Design font - Open Sans - Friendly, readable sans-serif - Lato - Humanist sans-serif

Case

Every font slot supports a case field that applies a letter-case transform to the text before it reaches SVG or Vega-Lite output. The default is none (no transform).

Value Behavior
none No transform (default). Text is rendered exactly as authored or returned from the database.
title Chicago/Gruber-style title case. Lowercases stopwords (a, an, and, at, but, by, for, in, of, on, or, the, to, via, vs.). Always capitalizes the first and last word. Preserves any token containing internal capitals: ARR, MRR, iPhone, GitHub, SQL are untouched.
sentence Uppercases the first letter; preserves all other characters as authored. Acronyms already capitalized by the author are preserved.
upper All characters uppercased.
lower All characters lowercased.
slug Machine identifier form: spaces/hyphens → underscore, lowercased. Order Status → order_status.
camel camelCase: first word lowercase, subsequent words capitalized. order status → orderStatus.

Text derived from a column name (axis titles, legend titles, table headers) is already sentence case before case applies: order_month reads "Order month", weight_in_lbs reads "Weight (lb)".

Why smart title case matters

The case: title value uses the Gruber algorithm, not the naive "capitalize every word" approach shipped by CSS text-transform: capitalize, Microsoft Word, and most BI tools. The key rule: any token that already contains internal capitals is left alone. This means metric initialisms common in dashboards are safe:

ARR Growth by Segment         →  ARR Growth by Segment  (correct)
Revenue by Customer Segment   →  Revenue by Customer Segment
from the top                  →  From the Top

A naive every-word capitalizer would produce Arr Growth By Segment; editorial garbage that damages brand credibility on published dashboards.

Setting case in a theme

Theme authors can enforce a casing convention across all text slots by adding case: to the font block at whichever level they want it to take effect:

# In a theme YAML; title-case all chart titles, board titles, and KPI labels
title:
  font:
    case: title

charts:
  font:
    case: title
# In a board YAML; upper-case axis tick labels for this chart only
charts:
  pipeline_stages:
    type: bar
    x: stage
    y: count
    style:
      axis_x:
        labels:
          font:
            case: upper

Scope and limitations

case applies to static authored text and data-derived static values:

  • Board title and subtitle
  • Chart title (as emitted in the Vega-Lite spec)
  • KPI label text
  • Table header display names
  • Legend titles (as emitted in the Vega-Lite spec)
  • Error and placeholder overlay text

Axis titles are not in that list. A derived axis title is the bound column's name split into words: an all-lowercase or all-uppercase word is lowercased (REVENUE renders revenue), and a mixed-case word such as MTok keeps its spelling. An authored x_label/y_label or field_labels entry renders as written.

For data-bound tick labels and legend item labels (rendered by Vega at runtime from query results), only upper and lower are applied; they map directly to Vega expression language (upper(datum.label), lower(datum.label)). The title and sentence values have no equivalent in Vega expression language, so they cannot be applied to data-bound labels at render time.

Board markdown body text (text: blocks) is intentionally excluded; applying case to Markdown would corrupt code spans, links, and emphasis markup.


Board-Level Styling

Apply CSS-like styles to any board or nested layout section:

rows:
  - title: "Styled Section"
    style:
      background: "#f0f4f8"
      border:
        width: 2
        color: "#667eea"
        radius: 8
      padding: "16px"
      font:
        color: "#333"
    cols:
      - text: Content

Supported Style Properties

Property Example SVG HTML Description
background "#f5f5f5" ✅ ✅ Background color (hex, rgb, named)
border "2px solid #ddd" or {width: 2, color: "#ddd", radius: 8} ✅ ✅ Border: CSS shorthand string, or a width / color / radius group
font.color "#333" ✅ ✅ Text color. Titles and headings use it unless title.font.color is set.
padding "16px" ✅ ✅ Inner spacing (affects content layout)
margin "8px 16px" ❌ ✅ Outer spacing
gap "12px" ✅ ✅ Override gap between child items

Padding & Sizing

Padding is accounted for in layout calculations. When you add padding to a board:

  1. The board's children have less available space
  2. Content is properly inset from the border
  3. Height calculations include the padding
# Padding formats (CSS-style)
rows:
  - style:
      padding: "16px"           # All sides equal
    cols:
      - text: Content
  - style:
      padding: "8px 16px"       # Vertical, horizontal
    cols:
      - text: Content
  - style:
      padding: "8px 16px 12px"  # Top, horizontal, bottom
    cols:
      - text: Content
  - style:
      padding: "8px 16px 12px 4px"  # Top, right, bottom, left
    cols:
      - text: Content

Border Styling

border takes the CSS shorthand string or a group; radius rounds the corners and exists only in the group form:

rows:
  - title: "Shorthand"
    style:
      border: "2px solid #667eea"  # width style color
    cols:
      - text: Content
  - title: "Group"
    style:
      border:
        width: 2
        color: "#667eea"
        radius: 8
    cols:
      - text: Content

Example: Styled Cards

Create card-style sections with backgrounds and borders:

cols:
  - text: "### Success Card"
    style:
      background: "#c8e6c9"
      border:
        width: 2
        color: "#4caf50"
        radius: 12
      font:
        color: "#1b5e20"

  - text: "### Warning Card"
    style:
      background: "#fff3e0"
      border:
        width: 2
        color: "#ff9800"
        radius: 12

Layout Sizing

Automatic Sizing

dbt Charts automatically calculates sizes based on content:

  • Charts: 300px default height (KPIs: 100px, Tables: 250px)
  • Titles: Height based on font size and text length
  • Markdown content: Height based on rendered text with word-wrapping

User-Specified Widths

In cols layouts, specify widths for individual items:

cols:
  - width: "30%"    # 30% of available width
    rows:
      - sidebar_chart
  - width: "70%"    # 70% of available width
    rows:
      - main_chart

Supported formats: - Percentages: "30%", "70%" - Pixels: "200px", "400px" - Auto (default): Remaining space divided equally

Gap Control

Control spacing between items:

# Per-section gap override
rows:
  - title: "Compact Section"
    style:
      gap: "8px"  # Smaller gap between children
    cols:
      - a
      - b

Content-Aware Heights

Heights are calculated based on content type:

Content Type Default Height
Standard chart 300px
KPI card 100px
Table 250px
Title Based on text
Markdown Based on rendered content

In cols layouts, all items get the same height (maximum of their content heights) for proper alignment.


Number Formatting

dbt Charts uses d3-format spec strings everywhere numbers appear: chart axis labels, KPI tiles, and table cells all share the same format spec language.

Format spec syntax

The d3-format spec grammar is:

[[fill]align][sign][symbol][0][width][,][.precision][~][type]
Token Meaning Example
fill Padding character (default space) 0>10.2f
align > right · < left · ^ center · = sign-then-pad <10.2f
sign - minus-only · + always · ( parens negatives · space +,.2f
symbol $ currency prefix · # alternate form $,.2f
0 Zero-pad to width 06.2f
width Minimum output width 10.2f
, Thousands separator ,.2f
.precision Decimal digits (or significant figures for s, g, r) .2f
~ Trim trailing zeros and decimal point .1~%
type Format type; see table below f

Types:

Type Description Spec Result
f Fixed-point ,.2f on 1234.56 1,234.56
% Percentage (×100 + %) .1~% on 0.05 5%
p Percentage to significant digits .1p on 0.1234 10%
e Exponential .2e on 1234.56 1.23e+3
s SI prefix ~s on 1500000 1.5M
g General (shorter of e/f) .3g on 0.001234 0.00123
r Rounded significant digits .2r on 12.56 13
d Integer d on 12.9 13
n Locale number (same as ,g) n on 1234 1,234
b Binary #b on 255 0b11111111
o Octal #o on 255 0o377
x / X Hex, lower / upper #x on 255 0xff
c Character data (verbatim) c on 1234.5678 1234.5678

Every d3-format type works in every format slot: axis labels, KPI tiles and table cells all resolve the same grammar, so a spec that renders on an axis renders identically in a KPI.

Bad specs fail at compile. A format string that is not a predefined engine name, a style.formats alias, or a valid d3-format spec fails dct validate and dct render with ERR-FORMAT-INVALID, pointing at the line and offering a "did you mean" hint, so a typo like percent_1 is caught before anything renders, not dropped silently.

B-for-billion divergence. dbt Charts's Python formatter (KPI tiles and table cells) maps the SI giga prefix G to B so that 1.5G displays as 1.5B. Chart axes use real d3.js in the browser, which emits G. This is a deliberate dbt Charts extension. The number format rounds to 3 significant figures and uses K/M/B/T with no space (analytic; narrative k/mn/bn/trn) in dbt Charts (vs k/M/G/T in d3).

For the full d3-format reference see https://d3js.org/d3-format.

Numeric display conventions

dbt Charts's defaults for numeric display on dashboard surfaces. The d3 spec language above is the mechanism; the conventions below are the house style that governs which spec to reach for.

Prefer predefined names over raw d3 specs. Use currency, currency_whole, currency_full, percent, percent_delta, integer, delta, number from the engine-predefined set (Format names and aliases above). Raw d3 specs only when no predefined name fits, for example, sub-1% percent precision needs ".2%". Raw specs work, but a board full of them is harder to read and won't benefit from future engine improvements to the house formatters.

KPI support deltas without a glyph: use style.support.format: delta, not integer. format: integer (,.0f) drops the sign on positive values; a support value of +31 renders as bare 31, direction-blind. format: delta (+,d) forces the leading sign on positive values (d3 emits the minus sign on negatives either way). Reserve integer for values that are not deltas (counts, totals). When the support row already carries a directional glyph (▲/▼ via support.glyph), the icon communicates direction and either format reads fine.

Currency: reach for the bare name. format: currency ($.3~s) is the dashboard default ($1.2M, $47) because the digits below the top three add precision a reader doesn't use at a glance and crowd KPI rails and table columns. format: currency_whole ($,.0f) writes the number out without cents. Reserve format: currency_full ($,.2f) for surfaces that demand reconciliation accuracy: billing breakdowns, financial statements, reports where exact match-to-source matters. Negative currency uses a minus prefix (-$1,234), not accounting parens.

Two notation families ship with the theme.

Family Form Use for
Analytic $2.5M, no space before the suffix, uppercase K/M/B/T Dashboard chrome: axes, KPIs, dense tables, tooltips
Narrative $2.5mn, no space, lowercase k/mn/bn/trn Prose surfaces: text cards, annotations, page/section/chart titles

Theme choice (clarity, neon, etc.) is visual identity. Notation family is independent; a narrative-feeling theme can still use analytic notation in its axes.

Precision is a group decision. The reader's context (a single headline KPI vs. a long table column) determines how much precision is tolerable as much as the value's magnitude does. Magnitude sets the default; surface modulates.

Default by magnitude:

Range Format Examples
≥ 20% whole percent 23%, 47%, 89%
1–20% one decimal (percent) 1.2%, 8.7%, 12.5%
< 1% two decimals (raw d3 ".2%") 0.12%, 0.045%

Modulate by surface. A single headline KPI or short rail (1–4 values) can afford one more decimal than the band default; there's nothing to scan against. A long table column or axis (>10 similar-magnitude values) is a real tradeoff: whole-percent aids scan-uniformity and distribution reading; the band default's precision aids close comparison across rows. The right call depends on the column's actual use: distribution scan favors the simpler form, close comparison favors keeping the decimal. Adjacent surfaces showing the same metric may format differently: a reconciliation table at .0% while its headline KPI shows .1%.

Meaningful-precision override. Some metrics carry signal in tenths of a percent regardless of magnitude or surface: A/B test conversion rates (4.27% vs 4.31% is a 1% relative lift), MoM churn at basis-point resolution, conversion rates in a tight range. For these, use ".2%" regardless of band. Precision is fundamentally a data-layer call; if the data carries signal in tenths or hundredths, the chart should show it.

Zero values strip trailing decimals, even when siblings have them. Render 0, $0, 0%; never 0.00, $0.00, 0.0%. A $0 KPI picks format: currency_whole even if its partner KPI uses format: currency for sub-dollar precision. The value is exactly zero; sibling-matching decimals fake precision the data doesn't have.

NULL renders as — (em-dash), never as 0. They are different claims about the data.

A KPI whose query returns zero rows raises an error. A silent 0 or — hides a real problem (broken query, empty filter, wrong source).

Tables anchor the currency symbol. Default symbol_mode: anchors puts $ on the first row of a column; rows below omit it. The first row anchors the column for the rows below.

Compaction is a column-level decision, not per-value. Compact when ≥4 similar-magnitude values exceed 10,000, or when surface density demands it. With 2–3 values the longer form usually reads fine. Adjacent surfaces showing the same metric may compact differently: a reconciliation table can show full precision while the headline KPI compacts.


Best Practices

Consistent Styling

  • Use the same color palette throughout a dashboard
  • Keep font choices consistent
  • Use grid lines consistently (all on or all off)

Accessibility Considerations

  • Choose colorblind-friendly palettes
  • Ensure sufficient contrast
  • Use labels and legends for clarity

Color Values

A color field accepts:

  • hex: #fff, #1d4ed8, #1d4ed880
  • a CSS color name: steelblue, transparent, none
  • a palette token: dbt-grays.2, category[2]
  • a color function: rgb(), hsl(), oklch(), var(--brand)

Anything else (url(...), gradients, {{ ... }}, characters such as ; { } ") fails validation at the field. A bare name that is not a CSS color name or palette token fails when the style resolves. Table column colors may also name a query column.

A font family is a CSS stack. Names that are not valid CSS identifiers, such as Source Serif 4, are quoted for you. A family name may not contain a comma.

Color Choices

  • Categorical data: Use distinct colors (editorial-10)
  • Sequential data: Use gradient palettes (dbt-seq-blue, dbt-seq-green)
  • Avoid: Too many colors (5-7 max for clarity)

Performance

  • Styling has minimal performance impact
  • Use global style when possible (more efficient)
  • Override only when necessary

  • Charts - Chart styling options
  • Boards - Layout and organization