Skip to content

Build a Dashboard

A dashboard is the page your team opens — but it is the last object in a chain. Every dashboard in Visivo is assembled from the same five pieces, connected with ${ref()}:

Source  →  Model  →  Insight  →  Chart  →  Dashboard
(where     (a SQL     (data +     (wraps      (rows of
data       query)     plotly      insights)   items)
lives)                props)

This guide walks the whole path once, then covers the layout system — rows, items, widths, heights, and markdown — in depth.

There are two ways to author everything on this page:

  • YAML — edit project.visivo.yml directly in your editor. Save, and visivo serve hot-reloads the browser.
  • The in-browser workspace — run visivo serve and build visually: create sources with the wizard, explore models, and drag objects onto the dashboard canvas. The workspace writes the same YAML back to your project, so the two routes are always interchangeable.

Step 1 — a Source

A Source is where your data lives — a database or file:

sources:
  - name: my_db
    type: duckdb
    database: target/example.duckdb

This guide uses DuckDB from here to the end: it is a single file, so there is nothing to start and every step below runs as written. See Source Types for PostgreSQL, Snowflake, BigQuery, SQLite, CSV, and the rest.

Step 2 — a Model

A Model is a SQL query saved against a Source:

models:
  - name: monthly_revenue
    source: ${ref(my_db)}
    sql: |
      SELECT date_trunc('month', order_date::DATE) AS month,
             SUM(amount) AS revenue
      FROM orders
      GROUP BY 1

Write the SQL in the source's own dialect

Visivo hands sql to the source unchanged — it does not translate between databases. date_trunc and the ::DATE cast above are DuckDB (and PostgreSQL) syntax; against a type: sqlite source the same rollup is strftime('%Y-%m', order_date), and date_trunc fails outright with no such function: DATE_TRUNC. Change a source's type and its models' SQL has to move with it.

The ::DATE cast is here because this walkthrough's orders table arrives as CSV text (see the complete project at the end of Step 5). Drop it when order_date is already a DATE column.

Step 3 — an Insight

An Insight binds a Model's columns to plotly props with query strings:

insights:
  - name: revenue_by_month
    props:
      type: bar
      x: ?{ ${ref(monthly_revenue).month} }
      y: ?{ sum(${ref(monthly_revenue).revenue}) }

An insight knows what to draw and which data feeds it — but it does not know where it belongs on a page. That is the next step's job.

Step 4 — wrap it in a Chart

A Chart wraps one or more Insights and carries the presentation-level layout (title, axes, legend):

charts:
  - name: revenue_chart
    insights:
      - ${ref(revenue_by_month)}
    layout:
      title:
        text: Monthly Revenue

The Chart is the composition boundary

A dashboard item never takes a bare insight. Items place charts (or tables, markdown, and inputs) — so every insight must be wrapped in a chart before it can appear on a dashboard. chart: ${ref(revenue_by_month)} will not validate; chart: ${ref(revenue_chart)} will.

In the in-browser workspace this happens for you: dropping an insight onto the canvas auto-wraps it in a new chart.

Why the extra layer? Two things an insight alone cannot do:

  • Combine — a chart can render several insights on shared axes: a bar series plus a line on a second y-axis, or an indicator layered over a trend. See the dual-axis example.
  • Reuse — the same insight can appear in many charts, each with its own title, axis labels, and styling. The underlying query still runs once.
charts:
  - name: revenue_dual_axis
    insights:
      - ${ref(revenue_by_month)}      # bar, left axis
      - ${ref(orders_by_month)}       # line, yaxis: 'y2'
    layout:
      title:
        text: Revenue vs Orders
      yaxis2:
        overlaying: 'y'
        side: right

Step 5 — place it on a Dashboard

A Dashboard arranges charts into rows of items:

dashboards:
  - name: my_dashboard
    rows:
      - height: medium
        items:
          - width: 1
            chart: ${ref(revenue_chart)}

That is the whole path. Here it is end to end, as one runnable project — every object is the one you just built, and the source seeds its own orders table, so you can paste this into a project.visivo.yml and run visivo serve:

name: revenue-example

sources:
  - name: my_db
    type: duckdb
    database: target/example.duckdb
    seeds:
      - table_name: orders
        args:
          - echo
          - |-
            order_date,amount
            2025-01-05,100
            2025-01-20,50
            2025-02-11,150
            2025-03-02,200

models:
  - name: monthly_revenue
    source: ${ref(my_db)}
    sql: |
      SELECT date_trunc('month', order_date::DATE) AS month,
             SUM(amount) AS revenue
      FROM orders
      GROUP BY 1

insights:
  - name: revenue_by_month
    props:
      type: bar
      x: ?{ ${ref(monthly_revenue).month} }
      y: ?{ sum(${ref(monthly_revenue).revenue}) }

charts:
  - name: revenue_chart
    insights:
      - ${ref(revenue_by_month)}
    layout:
      title:
        text: Monthly Revenue

dashboards:
  - name: my_dashboard
    rows:
      - height: medium
        items:
          - width: 1
            chart: ${ref(revenue_chart)}

The layout system

A dashboard is a vertical stack of rows; each row is a horizontal band of items. Items are placed left to right in the order they are listed.

Items

An Item holds exactly one of:

Field Places
chart A chart (which wraps your insights)
table A table, defined inline or by reference
markdown Formatted text — see Markdown items
input An input widget that drives interactivity
rows A nested stack of rows — see Nested rows

An item may also be empty (none of the five set) to reserve intentional whitespace in a row, sized by its width.

Relative widths

Each item has an integer width (default 1). Widths are relative, not absolute: within a row, each item gets width ÷ (sum of all widths in the row) of the horizontal space. There is no fixed column count — the row's total is whatever its widths sum to.

rows:
  - height: medium
    items:
      - width: 1              # 1/4 of the row
        markdown:
          content: "**Q1** commentary"
      - width: 1              # 1/4 of the row
        table:
          name: revenue_table
          data: ${ref(monthly_revenue)}
      - width: 2              # 2/4 = half the row
        chart: ${ref(revenue_chart)}

So 1 / 1 / 2 renders the same as 2 / 2 / 4 or 25 / 25 / 50 — only the ratios matter, and only within that row. Rows do not need to agree with each other: one row can split 1 / 1 while the next splits 5 / 3 / 4.

Row heights

Each row has a height — either a named token or a positive integer pixel value:

Height Pixels
compact wraps to content
xsmall 128
small 256
medium 396 (default)
large 512
xlarge 768
xxlarge 1024
<int> that many pixels exactly

compact is the natural fit for rows holding only markdown or inputs — the row shrinks to its content instead of reserving chart-sized space:

rows:
  - height: compact
    items:
      - input: ${ref(region_filter)}
  - height: 450
    items:
      - chart: ${ref(revenue_chart)}

Markdown items

Markdown adds titles, commentary, and section breaks between your charts. Inline it directly on an item:

rows:
  - height: compact
    items:
      - markdown:
          content: |
            ## Revenue
            All figures in **USD**, updated nightly.

Or define a named markdown object once and reference it from any dashboard — align controls horizontal alignment and justify controls vertical distribution:

markdowns:
  - name: welcome_note
    content: |
      # Welcome to Visivo
      This is **formatted** text.
    align: center
    justify: start

dashboards:
  - name: my_dashboard
    rows:
      - height: compact
        items:
          - markdown: ${ref(welcome_note)}

Markdown content supports CommonMark and GitHub Flavored Markdown, including raw HTML.

Nested rows

An item can hold rows instead of a leaf object, turning it into a row-container: the nested rows render as a vertical stack inside the slot the parent row reserved. Use this for layouts like "one big chart beside a stack of three small ones":

rows:
  - height: large
    items:
      - width: 2
        chart: ${ref(big_chart)}
      - width: 1
        rows:
          - height: small
            items: [{ chart: "${ref(small_a)}" }]
          - height: small
            items: [{ chart: "${ref(small_b)}" }]
          - height: small
            items: [{ chart: "${ref(small_c)}" }]

Inside a row-container, nested row heights act as relative weights within the parent slot rather than absolute pixel heights.

Iterating on a layout

Run visivo serve and keep it open while you work:

  • In YAML — edit heights and widths, save, and the browser hot-reloads. Because the layout is plain YAML, layout changes show up in pull requests like any other code.
  • In the workspace — drag items between rows, resize them on the canvas, and drop project objects (insights included — they auto-wrap in charts) straight onto the page. Saving from the workspace commits the same YAML back to your project files.

Next steps