Amounts by category or by period: a ranked list lying down, or months standing up, with negatives at a zero line.

Bar, line or stat tile

Pick the chart by the question it answers.

  • A bar chart compares amounts across categories or periods: where the month's money went, merchant by merchant.
  • A line chart shows a change over continuous time: spending day by day.
  • A stat tile says one figure, with a sparkline at most.

A ranked list

Lay the bars down with orientation="horizontal" to rank categories, merchants or accounts. Each row has its name at the start, its bar from the zero line, and its amount at the end, in a column as wide as the widest amount, so the amounts align.

  • Order: the chart draws the rows in your order. Sort them by amount, show the top eight to twelve, and sum the rest as "Other", last.
  • Amounts: on by default for a horizontal chart. valueLabels="none" drops the column, and a list of row indexes shows only those rows' amounts.
  • Gridlines: valueTicks draws a line at each value, with its label under the last row. The zero line is always drawn.

Example

Spending by merchant, October

Follows the page theme and corners
import { BarChart } from '@snapshot-ui/charts';
import { Card, CardHeader, CardTitle } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
import { BY_MERCHANT, MERCHANTS } from './insights-data';

export default function BarChartRanked(): ReactElement {
  return (
    <div className="chart-sample">
      <Card as="section" aria-labelledby="bar-chart-ranked-title">
        <CardHeader>
          <CardTitle id="bar-chart-ranked-title" level={3}>
            Spending by merchant, October
          </CardTitle>
        </CardHeader>
        <BarChart
          aria-labelledby="bar-chart-ranked-title"
          orientation="horizontal"
          categories={MERCHANTS}
          series={[BY_MERCHANT]}
        />
      </Card>
    </div>
  );
}

Below zero

A row can net below zero, such as a category whose refunds outweigh its spending. Its bar runs from the zero line toward the start, and its amount stays in the end column, so a sorted list's negatives at the bottom still line up.

  • Zero line: in the boundary color when the bars cross zero, so the reader finds it. With no negatives it is a quiet edge.
  • Color: a negative bar is drawn in its series' color, ink here. Never red: error means a failure, and a refund is not one. The sign and the figure say which way it went.
  • Domain: the value axis always includes zero, since a bar's length is its value. A valueDomain that leaves zero out is widened to include it, with a warning in development.

Example

Follows the page theme and corners
import { BarChart } from '@snapshot-ui/charts';
import { Card } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
import { BY_CATEGORY, CATEGORIES, CATEGORY_TICKS } from './insights-data';

export default function BarChartRefunds(): ReactElement {
  return (
    <div className="chart-sample">
      <Card>
        <BarChart
          aria-label="Spending by category, October, with refunds"
          orientation="horizontal"
          categories={CATEGORIES}
          series={[BY_CATEGORY]}
          valueTicks={CATEGORY_TICKS}
        />
      </Card>
    </div>
  );
}

Months

Bars stand up by default: one band per month, oldest at the start, each bar from the zero line. A bad month hangs below the line by itself, since the value axis always includes zero.

  • Time bands: bands="time" labels the first, middle and last month, on one line, and Tab lands on the newest month first. categoryTicks names the months to label instead.
  • Thickness: a bar takes 60% of its band at most and is never thicker than 24px, so twelve months in a phone's card are 12px each and thirty-one days about 5px, with nothing measured.
  • Value labels: off by default, since the table, the readout and each bar's name carry every figure. valueLabels with band indexes labels the months the story is about, outside each bar's end: above a bar above zero, below one below it. Under 30rem valueLabels="all" keeps only the first and the last band's.
  • Gridline labels: valueTicks labels each line at the start of the plot, in a column of its own as wide as the widest label, so no bar ever covers one.

Example

Net cash flow, the last twelve months

Follows the page theme and corners
import { BarChart } from '@snapshot-ui/charts';
import { Card, CardHeader, CardTitle } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
import { MONTHS, NET_BY_MONTH, NET_TICKS } from './insights-data';

export default function BarChartMonths(): ReactElement {
  return (
    <div className="chart-sample">
      <Card as="section" aria-labelledby="bar-chart-months-title">
        <CardHeader>
          <CardTitle id="bar-chart-months-title" level={3}>
            Net cash flow, the last twelve months
          </CardTitle>
        </CardHeader>
        <BarChart
          aria-labelledby="bar-chart-months-title"
          bands="time"
          categories={MONTHS}
          series={[NET_BY_MONTH]}
          valueTicks={NET_TICKS}
          valueLabels={[1, 7, 11]}
        />
      </Card>
    </div>
  );
}

Two series

Two series sit side by side in each band, 2px apart, with a legend that names both. Without color the first is ink and the second slate, a story and its context. The month view pins its colors by meaning instead: Income is color: 'gain', money that arrived, and Spending is color: 'ink'. Spending has no color of its own, red or otherwise, since nothing went wrong. Pinned colors also keep each series' color when your control shows one of them alone.

The types take two grouped series at most, so a third is a type error rather than a crowded band.

Example

Income and spending by month

  • Income
  • Spending
Follows the page theme and corners
import { BarChart } from '@snapshot-ui/charts';
import { Card, CardHeader, CardTitle } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
import { HALF_YEAR, INCOME, INCOME_TICKS, SPENDING } from './insights-data';

export default function BarChartIncomeSpending(): ReactElement {
  return (
    <div className="chart-sample">
      <Card as="section" aria-labelledby="bar-chart-income-spending-title">
        <CardHeader>
          <CardTitle id="bar-chart-income-spending-title" level={3}>
            Income and spending by month
          </CardTitle>
        </CardHeader>
        <BarChart
          aria-labelledby="bar-chart-income-spending-title"
          bands="time"
          categories={HALF_YEAR}
          categoryTicks={[0, 1, 2, 3, 4, 5]}
          series={[INCOME, SPENDING]}
          valueTicks={INCOME_TICKS}
        />
      </Card>
    </div>
  );
}

Against the previous period

Give the previous period's series look: 'hatched' and its period's name as its label. Without a color it takes the first series' color, drawn as stripes inside a border of that color at full strength, and its amounts are slate: the same thing, last time, never a second category. Each bar's name says its period, so the stripes are never the only cue, and the keys step from this period's bar to the last one's in each band. Put the previous period second, so it stands after the current bar, or under it when the bars lie down. The example sets five months beside the same months a year before: standing up, a phone gives each band about 2rem, room for a month and not for a category's name, so a comparison of categories lies down, as the Insights recipe's does.

Why stripes and not a lighter shade: a lighter fill must clear 3:1 against the card to be seen and stay apart from the solid bar beside it. Ink has a band that does (half strength measures 3.45 against the card and 5.17 against solid ink in light), but gain has none in either mode: at 50% it is 2.33 and 3.09 in light. The hatched bar's border is the color at full strength, 17.84 for ink and 7.20 for gain in light, and its stripes are a texture. In forced colors the previous period stays striped against the solid current bars. A bar under 4px across, such as a month of days compared in a phone's card, is its outline alone, since stripes that thin are noise; a comparison of more than about fifteen bands reads better lying down.

Example

  • 2026
  • 2025
Follows the page theme and corners
import { BarChart } from '@snapshot-ui/charts';
import { Card } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
import { COMPARE_MONTHS, COMPARE_TICKS, LAST_YEAR_BY_MONTH, THIS_YEAR_BY_MONTH } from './insights-data';

export default function BarChartCompare(): ReactElement {
  return (
    <div className="chart-sample">
      <Card>
        <BarChart
          aria-label="Spending by month, this year against last year"
          categories={COMPARE_MONTHS}
          series={[THIS_YEAR_BY_MONTH, LAST_YEAR_BY_MONTH]}
          valueTicks={COMPARE_TICKS}
        />
      </Card>
    </div>
  );
}

Stacked

Give layout="stacked" to put up to five series end to end in each band, such as a month's top four categories and the "Other" you sum. Positives stack up from the zero line and negatives down, each sign in your series' order, so a category whose refunds net it below zero hangs under the line while the rest stand over it.

  • Colors: without color, the series take the chart palette in order, teal, violet, olive and magenta, and the fifth is slate, the remainder. Pin each series to its slot, such as color: 'chart-2', so a category keeps its color when a filter takes another away. A sixth series warns in development and draws slate too: fold it into "Other". A series with look: 'hatched' warns and draws as a fill, since stripes in a stack are a texture, not a previous period.
  • Other: pin it to color: 'slate', as the example does. A series' texture follows its color, so a slate "Other" is always the fifth look, solid slate, even when a month has fewer than five series and "Other" stands fourth or third; unpinned, it would take the next slot's color and texture and read as one more category.
  • Order: the legend, the readout and the table list the series in your order, first to last, while a standing stack draws them from the bottom up, so your first series is at the foot of each stack and "Other" on top.
  • Segments: 2px of the card between segments, and only the outermost segment of each sign keeps the rounded end; the joins are square. The gap is inside each segment's length, so a stack is as tall as its total. A segment on another is at least 3px, its gap and 1px of its color, so a small amount never vanishes; the extra reaches into the segment under it, and the stack's end stays at its total.
  • Totals: pass totals, one string per band, summed and formatted by you, since the chart never adds a figure it must say. With valueLabels each total stands at the end of the side its band nets to; lying down it fills the amount column. The readout lists every segment of the band and the total, and the table gains a Total column. Asked for figures without totals, the chart warns and labels nothing.
  • Textures: hasTexture draws each series' texture as well as its color, for readers who cannot tell the colors apart and for a printout without color: the first solid, the second striped at 45 degrees, the third at 135, the fourth both, and the fifth solid slate, each inside a 1px border of its color, the legend's keys with them. Forced colors always show them.
  • Keys: along a stack, Up and Down standing up, or Left and Right lying down, move segment by segment by what is drawn; the other pair moves to the same category in the band beside, or the nearest category it has.

Example

Spending by category, the last six months

  • Groceries
  • Dining
  • Transport
  • Bills
  • Other
Follows the page theme and corners
import { BarChart } from '@snapshot-ui/charts';
import { Card, CardHeader, CardTitle } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
import { CATEGORY_MIX, HALF_YEAR, MIX_TICKS, MIX_TOTALS } from './insights-data';

export default function BarChartStacked(): ReactElement {
  return (
    <div className="chart-sample">
      <Card as="section" aria-labelledby="bar-chart-stacked-title">
        <CardHeader>
          <CardTitle id="bar-chart-stacked-title" level={3}>
            Spending by category, the last six months
          </CardTitle>
        </CardHeader>
        <BarChart
          aria-labelledby="bar-chart-stacked-title"
          layout="stacked"
          bands="time"
          categories={HALF_YEAR}
          categoryTicks={[0, 1, 2, 3, 4, 5]}
          series={CATEGORY_MIX}
          totals={MIX_TOTALS}
          valueLabels="all"
          valueTicks={MIX_TICKS}
        />
      </Card>
    </div>
  );
}

A month with three categories and "Other" keeps "Other" slate and its fifth texture:

<BarChart
  aria-label="Spending by category, October"
  layout="stacked"
  categories={['Oct']}
  series={[
    { label: 'Groceries', color: 'chart-1', values: [{ value: 640, valueLabel: '$640.00' }] },
    { label: 'Dining', color: 'chart-2', values: [{ value: 312.45, valueLabel: '$312.45' }] },
    { label: 'Transport', color: 'chart-3', values: [{ value: 188.1, valueLabel: '$188.10' }] },
    { label: 'Other', color: 'slate', values: [{ value: 376.8, valueLabel: '$376.80' }] },
  ]}
  totals={['$1,517.35']}
  valueLabels="all"
/>

Color

A series' color says what it is, by meaning first and then by identity. color takes 'ink', 'slate', 'gain' or one of the chart palette's four, 'chart-1' to 'chart-4', and pins the series to it.

  • One series: ink.
  • Two series side by side: ink and slate, a story and its context, unless you pin them.
  • Money that arrived: gain, labelled so, beside an ink series and never beside a palette color, so a meaning and an identity never share a chart.
  • Peers with no meaning of their own, such as a month's top categories: the palette's teal, violet, olive and magenta in that order, and an "Other" you sum and label in slate. Pin each series to its slot, so it keeps its color when a filter removes another.

No series takes error, success, warning, clay, signal orange or taupe; the type has no name for them. Amounts, labels and the legend's names stay ink and slate whatever a bar's color, and the legend's key beside each name carries the color.

Long names

A long name wraps in the label column, which takes the longest name's width up to 40% of the chart. Names are never cut short or rotated. Set --bar-chart-label-size on the chart to give the column another share.

When the chart is narrower than 24rem, each name moves to a line of its own above its bar, and the bar and its amount share the line below. The chart reads its own width, not the viewport's, so it behaves the same in a narrow card on a wide screen.

Standing up, a category label is as wide as its band and wraps to two lines, then ends with an ellipsis; under 30rem it keeps to one line, and under 24rem a chart of more than eight bands shows every third label. The whole name is always in the readout, the bar's name and the table. Names longer than about twelve characters, or more than eight categories, read better lying down.

The chart is a size container, so it takes its width from what holds it and none from its content: a block gives it the whole line, and in a flex row its item needs a basis, such as flex: 1 1 20rem, or a grid column a size. Given none, it is no wider than nothing.

States

status draws a state in the rows' place, at the height of the rows the chart expects: pass the categories you will show, or the chart keeps room for four.

  • Loading: still bars in Skeleton's fill, with no legend, toggle or table. The chart says "Loading" once, politely.
  • Empty: a sentence in slate over the zero line, or your statusContent, such as an EmptyState.
  • Error: a sentence in the error color, which means a load failed, or your statusContent with a way to try again. The chart says it once.

Example

No spending yet

November's spending shows here once a transaction arrives.

The chart could not be loaded

Check your connection, then try again.

Follows the page theme and corners
'use client';

import { BarChart } from '@snapshot-ui/charts';
import { Button, Card, EmptyState } from '@snapshot-ui/react';
import { ChartColumn, CloudOff } from 'lucide-react';
import { useState, type ReactElement } from 'react';
import { BY_CATEGORY, CATEGORIES } from './insights-data';

export default function BarChartStates(): ReactElement {
  // A failed load the reader can retry: Try again draws the chart, as a fetch that succeeded would.
  const [hasFailed, setFailed] = useState(true);
  return (
    <div className="chart-sample stack">
      <Card>
        <BarChart
          aria-label="Spending by category, loading"
          orientation="horizontal"
          categories={CATEGORIES}
          series={[BY_CATEGORY]}
          status="loading"
        />
      </Card>
      <Card>
        <BarChart
          aria-label="Spending by category, November"
          orientation="horizontal"
          categories={[]}
          series={[{ label: 'November', values: [] }]}
          status="empty"
          statusContent={
            <EmptyState
              headingLevel={3}
              icon={<ChartColumn />}
              title="No spending yet"
              description="November's spending shows here once a transaction arrives."
            />
          }
        />
      </Card>
      <Card>
        <BarChart
          aria-label="Spending by category, October"
          orientation="horizontal"
          categories={CATEGORIES}
          series={[BY_CATEGORY]}
          status={hasFailed ? 'error' : 'ready'}
          statusContent={
            <EmptyState
              headingLevel={3}
              icon={<CloudOff />}
              title="The chart could not be loaded"
              description="Check your connection, then try again."
              action={
                <Button
                  variant="secondary"
                  onPress={() => {
                    setFailed(false);
                  }}
                >
                  Try again
                </Button>
              }
            />
          }
        />
      </Card>
    </div>
  );
}

Give a bar an href to open what it stands for, such as its transactions for the period shown. Each bar of each series takes its own, so a comparison's previous period opens its own month. The bar becomes a link, and so does its cell in the table: the category's when the chart has one series, each figure's when it has two or more. The hrefs are data, so a server page passes them with the figures, and the links work before the page hydrates and with scripts off.

  • Pointer: a press anywhere in the bar's band follows its link, not only a press on the bar: lying down the whole row from its name to its amount, standing up the band's column above its label. In a band of two each bar takes the half nearer it, and in a stack each segment takes its own run, the outermost on to the plot's edge. The readout names the bar under the pointer, so it says what a press opens, and the band shows the link cursor. A bar with no href beside or on a linked one keeps its own part of the band, where a press opens nothing.
  • A new tab: the band is a real link, so a click with Command, Control or Shift, a middle click and the context menu open it in a new tab or window as any link does. The chart routes none of them.
  • Touch: a tap follows the link. A hold of half a second or more shows the readout and follows nothing, and the platform's link menu stays away so the readout can be read.
  • Keys: the chart stays one tab stop, moved by the arrows. Enter follows the focused bar's link, and Space does nothing, so it never scrolls the page from under the chart.
  • Your router: the charts carry no React Aria, so they cannot read your RouterProvider. In a client component, pass its navigate to the chart as well, and a plain click or Enter on a bar or a table link goes through it; a modified click stays the browser's, and a link of your own in statusContent opens its own way.
  • A press: onBarPress(category, series) is called with the band's and the series' index on every press, a link's before it is followed. A bar with no href in a chart with onBarPress is a button, which Enter and Space press, and a press anywhere in its band presses it.

Example

Follows the page theme and corners
import { BarChart, type BarSeries } from '@snapshot-ui/charts';
import { Card, CardHeader, CardTitle } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
import { BY_CATEGORY, CATEGORIES } from './insights-data';

/**
 * Each category's bar opens its transactions for the month shown. The hrefs are data, so a server page passes
 * them as it passes the figures, and every bar is a link in the HTML it sends.
 */
const LINKED: BarSeries = {
  ...BY_CATEGORY,
  values: BY_CATEGORY.values.map((value, at) =>
    value
      ? { ...value, href: `#transactions?category=${(CATEGORIES[at] ?? '').toLowerCase()}&month=2026-10` }
      : null,
  ),
};

export default function BarChartLinks(): ReactElement {
  return (
    <div className="chart-sample">
      <Card as="section" aria-labelledby="bar-chart-links-title">
        <CardHeader>
          <CardTitle id="bar-chart-links-title" level={3}>
            October&apos;s spending by category
          </CardTitle>
        </CardHeader>
        <BarChart
          aria-labelledby="bar-chart-links-title"
          orientation="horizontal"
          categories={CATEGORIES}
          series={[LINKED]}
        />
      </Card>
    </div>
  );
}

On a client page, the chart takes the router's navigate, the function your RouterProvider has:

'use client';

import { BarChart, type BarChartProps } from '@snapshot-ui/charts';
import { useRouter } from 'next/navigation';

export function LinkedChart(props: BarChartProps) {
  const router = useRouter();
  return <BarChart {...props} navigate={(href) => router.push(href)} />;
}

The Insights recipe

An insights page in the Next.js App Router: the month's figures, one chart card that regroups, a table that links to the transactions, and the month's report. The page is a server component. What changes with the URL is one client component, and the report is rendered on the server beside it.

  • Figures: a row of stat tiles, spent, income and net. While the page compares, each draws its change against September as its change line, in words: "Up $415 vs September".
  • Filters: a filter bar with the period.
  • The card: its header holds a segmented control for the grouping and a switch to compare; its body is one BarChart, the card's last child, so the table toggle stands clear of the card's curve.
  • Month: twelve months standing up, newest last: Income in gain beside Spending in ink, one of them alone, net cash flow with its months below zero, or the category mix stacked. A second control picks which.
  • Category, Merchant and Account: lying down, the top ten and "Other", with a refund below zero. Each bar opens its transactions for the period it shows, such as #transactions?category=rent, and a compared bar September's, through the same navigate the page's RouterProvider has.
  • Compare: the previous period, hatched, beside each bar: September for October, the twelve months before for the months. A band holds two bars, so the switch is off while income and spending stand together, or the mix is stacked.
  • One chart: the card keeps the same BarChart and gives it new props, so a bar that held focus hands it to the new first bar, never to the page. Changing the grouping with the control keeps focus on the control.
  • The URL: ?by=category&compare=1, read with useSearchParams() inside a Suspense whose fallback draws the first view, and written with router.replace, so a reload or a shared link keeps the view.
  • Under the chart: a data table of the same bands, each row a link to its transactions through RouterProvider.

Example

Spent

$4,220.00

October

Income

$5,200.00

October

Net

$980.00

October

Income and spending

The last twelve months

Changed
Choose Spending, Income or Net to compare.
  • Income
  • Spending
MonthSpendingIncomeNet
Nov$4,560.00$5,200.00$640.00
Dec$6,020.00$5,200.00-$820.00
Jan$3,990.00$5,200.00$1,210.00
Feb$3,720.00$5,200.00$1,480.00
Mar$4,295.00$5,200.00$905.00
Apr$4,320.00$5,450.00$1,130.00
May$4,440.00$5,200.00$760.00
Jun$5,510.00$5,200.00-$310.00
Jul$4,745.00$5,200.00$455.00
Aug$4,180.00$5,200.00$1,020.00
Sep$3,805.00$5,200.00$1,395.00
Oct$4,220.00$5,200.00$980.00
Nov$4,560.00
Dec$6,020.00
Jan$3,990.00
Feb$3,720.00
Mar$4,295.00
Apr$4,320.00
May$4,440.00
Jun$5,510.00
Jul$4,745.00
Aug$4,180.00
Sep$3,805.00
Oct$4,220.00
Follows the page theme and corners
'use client';

import { BarChart, type BarChartProps, type BarChartSeries, type BarSeries } from '@snapshot-ui/charts';
import {
  Card,
  CardHeader,
  CardNote,
  CardTitle,
  DataTable,
  FilterBar,
  FilterChip,
  PageHeader,
  PickerOption,
  RouterProvider,
  SegmentedControl,
  SegmentedControlItem,
  StatTile,
  Switch,
  type DataTableColumn,
  type Key,
} from '@snapshot-ui/react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useId, useState, type ReactElement } from 'react';
import {
  dollars,
  money,
  MONTH_FIGURES,
  MONTH_KEYS,
  MONTH_MIX,
  MONTH_MIX_TOTALS,
  MONTH_NET_TICKS,
  MONTH_TICKS,
  MONTHS,
  RANKED,
} from './insights-data';

type Grouping = 'month' | 'category' | 'merchant' | 'account';

/** What the month view draws: income beside spending, one of them, net cash flow, or the category mix. */
type Show = 'both' | 'spending' | 'income' | 'net' | 'mix';

/** The page's view, all of it in the URL: `?by=category&compare=1`. The defaults write no parameter. */
interface View {
  by: Grouping;
  show: Show;
  compare: boolean;
}

const GROUPINGS: Grouping[] = ['month', 'category', 'merchant', 'account'];
const SHOWS: Show[] = ['both', 'spending', 'income', 'net', 'mix'];

function viewOf(params: URLSearchParams): View {
  const by = GROUPINGS.find((each) => each === params.get('by')) ?? 'month';
  const show = SHOWS.find((each) => each === params.get('show')) ?? 'both';
  return { by, show, compare: params.get('compare') === '1' };
}

/** The query for a view, with no bare `?` and nothing for a default. */
function queryOf({ by, show, compare }: View): string {
  const params = new URLSearchParams();
  if (by !== 'month') params.set('by', by);
  if (by === 'month' && show !== 'both') params.set('show', show);
  if (compare) params.set('compare', '1');
  return params.toString();
}

/**
 * A grouped bar holds two series at most, so the comparison draws one series beside its previous period: it is
 * off while the month view shows income and spending together, or the stacked mix.
 */
const canCompare = ({ by, show }: View): boolean => by !== 'month' || !(show === 'both' || show === 'mix');

/** A row of the table under the chart: a band's name, its figures and the key of its transactions link. */
interface Row {
  key: string;
  name: string;
  figures: string[];
}

/** What a view draws: the card's words, the chart's props and the table's rows. */
interface Drawn {
  title: string;
  note: string;
  chart: BarChartSeries &
    Pick<BarChartProps, 'categories' | 'orientation' | 'bands' | 'valueTicks' | 'totals'>;
  /** The link's parameter, such as `category=rent`. */
  linkBy: string;
  columns: string[];
  rows: Row[];
}

const MONTH_TITLES: Record<Show, string> = {
  both: 'Income and spending',
  spending: 'Spending by month',
  income: 'Income by month',
  net: 'Net cash flow',
  mix: 'Spending by category, by month',
};

/** The month view: twelve months standing up, the newest last, and Tab lands on the newest. */
function monthView(show: Show, compare: boolean): Drawn {
  const { income, spending, net } = MONTH_FIGURES;
  const rows = MONTHS.map((name, index) => ({
    key: MONTH_KEYS[index] ?? name,
    name,
    figures: [spending, income, net].map(({ year }) => dollars(year[index] ?? 0)),
  }));
  const shared = { categories: MONTHS, bands: 'time', orientation: 'vertical' } as const;
  const table = { linkBy: 'month', columns: ['Month', 'Spending', 'Income', 'Net'], rows };
  const title = MONTH_TITLES[show];
  if (show === 'both' || show === 'mix') {
    return {
      ...table,
      title,
      note: 'The last twelve months',
      chart:
        show === 'both'
          ? {
              ...shared,
              valueTicks: MONTH_TICKS,
              // Money that arrived in gain beside money that went out in ink: never a color for spending.
              series: [
                { label: 'Income', color: 'gain', values: income.year.map(money) },
                { label: 'Spending', color: 'ink', values: spending.year.map(money) },
              ],
            }
          : {
              ...shared,
              valueTicks: MONTH_TICKS,
              layout: 'stacked',
              series: MONTH_MIX,
              totals: MONTH_MIX_TOTALS,
            },
    };
  }
  const figures = MONTH_FIGURES[show];
  const label = show === 'net' ? 'Net' : show === 'income' ? 'Income' : 'Spending';
  const current: BarSeries = {
    label,
    color: show === 'income' ? 'gain' : 'ink',
    values: figures.year.map(money),
  };
  // The previous period takes the current series' color, striped: the same thing, a year before.
  const previous: BarSeries = { label: 'A year before', look: 'hatched', values: figures.before.map(money) };
  return {
    ...table,
    title,
    note: compare ? 'The last twelve months, against the twelve before' : 'The last twelve months',
    chart: {
      ...shared,
      valueTicks: show === 'net' ? MONTH_NET_TICKS : MONTH_TICKS,
      series: compare ? [current, previous] : [current],
    },
  };
}

/**
 * A ranked view: rows lying down, amounts at their end, refunds below zero and "Other" last. Each bar links to
 * its transactions for the period it shows, October's or, compared, September's: the page's drill-down.
 */
function rankedView(by: Exclude<Grouping, 'month'>, compare: boolean): Drawn {
  const rows = RANKED[by];
  const october: BarSeries = {
    label: 'October',
    values: rows.map(({ key, october: value }) => ({ ...money(value), href: `#transactions?${by}=${key}` })),
  };
  const september: BarSeries = {
    label: 'September',
    look: 'hatched',
    values: rows.map(({ key, september: value }) => ({
      ...money(value),
      href: `#transactions?${by}=${key}&period=2026-09`,
    })),
  };
  const name = by.charAt(0).toUpperCase() + by.slice(1);
  return {
    title: `Spending by ${by}`,
    note: compare ? 'October, against September' : 'October',
    chart: {
      categories: rows.map((each) => each.name),
      // A ranked list reads by its amounts at the end, so it draws no gridlines.
      orientation: 'horizontal',
      series: compare ? [october, september] : [october],
    },
    linkBy: by,
    columns: compare ? [name, 'October', 'September'] : [name, 'October'],
    rows: rows.map((each) => ({
      key: each.key,
      name: each.name,
      figures: (compare ? [each.october, each.september] : [each.october]).map(dollars),
    })),
  };
}

/**
 * The table's columns: the band's name as the row header, then its figures, aligned at the end. On a phone each
 * row is an item with its first figure at the end; the chart above it carries the rest.
 */
const columnsOf = (names: string[]): DataTableColumn<Row>[] =>
  names.map((header, index) =>
    index === 0
      ? { id: 'name', header, cell: (row) => row.name, isRowHeader: true }
      : {
          id: header,
          header,
          cell: (row) => row.figures[index - 1],
          align: 'end',
          listSlot: index === 1 ? 'end' : 'hidden',
        },
  );

interface InsightsProps {
  view: View;
  onView?: (view: View) => void;
  /** The router's navigate, the RouterProvider's own, for the chart's links: the charts cannot read the provider. */
  navigate?: (href: string) => void;
}

/** The page above the report: header, tiles, filters, the chart card and the table under it. */
function Insights({ view, onView, navigate }: InsightsProps): ReactElement {
  const titleId = useId();
  const noteId = useId();
  // The period is your query's; the fixtures hold October against September, so the chip only keeps its choice.
  const [period, setPeriod] = useState<Key | null>('2026-10');
  const comparing = view.compare && canCompare(view);
  const drawn = view.by === 'month' ? monthView(view.show, comparing) : rankedView(view.by, comparing);
  const change = (patch: Partial<View>): void => {
    onView?.({ ...view, ...patch });
  };
  return (
    <div className="stack" style={{ width: '100%', minWidth: 0 }}>
      {/* An example on a docs page, which has its own h1; the app's own header keeps the default, 1. */}
      <PageHeader
        headingLevel={2}
        title="Insights"
        description="Where October's money went, account by account."
      />
      {/* The summary: each figure's change against September is drawn while the page compares. */}
      <div className="tile-row">
        <StatTile
          label="Spent"
          value={dollars(4220)}
          caption="October"
          change={comparing ? { direction: 'up', label: 'Up $415 vs September' } : undefined}
        />
        <StatTile
          label="Income"
          value={dollars(5200)}
          caption="October"
          change={comparing ? { direction: 'flat', label: 'Same as September' } : undefined}
        />
        <StatTile
          label="Net"
          value={dollars(980)}
          caption="October"
          change={comparing ? { direction: 'down', label: 'Down $415 vs September' } : undefined}
        />
      </div>
      <FilterBar>
        <FilterChip label="Period" selectionMode="single" value={period} onChange={setPeriod}>
          <PickerOption id="2026-10">October 2026</PickerOption>
          <PickerOption id="2026-09">September 2026</PickerOption>
          <PickerOption id="2026-q3">July to September 2026</PickerOption>
        </FilterChip>
      </FilterBar>
      <Card as="section" aria-labelledby={titleId}>
        <CardHeader>
          <CardTitle id={titleId} level={3}>
            {drawn.title}
          </CardTitle>
          <CardNote id={noteId}>{drawn.note}</CardNote>
          {/* The two controls on one line, from the start, wrapping where the card is narrow. */}
          <div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--spacing-part)' }}>
            <SegmentedControl
              aria-label="Group by"
              size="sm"
              value={view.by}
              onChange={(by) => {
                change({ by: by as Grouping });
              }}
            >
              <SegmentedControlItem value="month">Month</SegmentedControlItem>
              <SegmentedControlItem value="category">Category</SegmentedControlItem>
              <SegmentedControlItem value="merchant">Merchant</SegmentedControlItem>
              <SegmentedControlItem value="account">Account</SegmentedControlItem>
            </SegmentedControl>
            {view.by === 'month' ? (
              <SegmentedControl
                aria-label="Show"
                size="sm"
                value={view.show}
                onChange={(show) => {
                  change({ show: show as Show });
                }}
              >
                <SegmentedControlItem value="both">Both</SegmentedControlItem>
                <SegmentedControlItem value="spending">Spending</SegmentedControlItem>
                <SegmentedControlItem value="income">Income</SegmentedControlItem>
                <SegmentedControlItem value="net">Net</SegmentedControlItem>
                <SegmentedControlItem value="mix">Mix</SegmentedControlItem>
              </SegmentedControl>
            ) : null}
          </div>
          <Switch
            isSelected={comparing}
            isDisabled={!canCompare(view)}
            description={canCompare(view) ? undefined : 'Choose Spending, Income or Net to compare.'}
            onChange={(compare) => {
              change({ compare });
            }}
          >
            Compare to previous period
          </Switch>
        </CardHeader>
        {/* One chart for every grouping: new props, never a new chart, so a focused bar hands focus on. */}
        <BarChart {...drawn.chart} aria-labelledby={`${titleId} ${noteId}`} navigate={navigate} />
      </Card>
      <DataTable
        aria-label={`Transactions by ${view.by}`}
        items={drawn.rows}
        columns={columnsOf(drawn.columns)}
        getKey={(row) => row.key}
        getRowHref={(row) => `#transactions?${drawn.linkBy}=${row.key}`}
      />
    </div>
  );
}

/** The view the URL names, kept in state so a control answers at once while the URL catches up. */
function InsightsFromUrl({ navigate }: Pick<InsightsProps, 'navigate'>): ReactElement {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const key = searchParams.toString();
  const [view, setView] = useState(() => viewOf(new URLSearchParams(key)));
  const [seen, setSeen] = useState(key);
  // The URL changed under the page, by Back or Forward: draw its view.
  if (key !== seen) {
    setSeen(key);
    setView(viewOf(new URLSearchParams(key)));
  }
  return (
    <Insights
      view={view}
      navigate={navigate}
      onView={(next) => {
        const query = queryOf(next);
        setView(next);
        setSeen(query);
        router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
      }}
    />
  );
}

/** The first view: months, income beside spending, as the static page's server renders it. */
const FIRST = viewOf(new URLSearchParams());

/**
 * The Insights page's client part: everything but the month's report, which a server component renders beside
 * it. The grouping and the comparison live in the URL, so a link or a reload keeps them.
 */
export default function InsightsRecipe(): ReactElement {
  const router = useRouter();
  const navigate = (href: string): void => {
    router.push(href, { scroll: false });
  };
  return (
    // An app wraps its whole tree once; the example wraps only itself. The chart takes the same navigate.
    <RouterProvider navigate={navigate}>
      <Suspense fallback={<Insights view={FIRST} navigate={navigate} />}>
        <InsightsFromUrl navigate={navigate} />
      </Suspense>
    </RouterProvider>
  );
}

The month's report is what a model wrote, so it is rendered as Prose on the server with skipHtml: its link becomes text, its image its alt text, the HTML it wrote is dropped, and its table scrolls in a named region on a phone. Rendering Markdown safely has the map.

Example

Your October

You spent $4,220.00 in October, $415.00 more than in September, and kept $980.00 of your income. Most of the rise was shopping and gifts, ahead of the holidays.

What changed

  • Shopping rose to $368.30, from $212.40.
  • Gifts rose to $165.00, from $40.00.
  • Travel netted -$310.00: Delta Air Lines refunded the cancelled flight.
CategorySeptemberOctoberChangeShareBudget
Rent$1,450.00$1,450.00$0.0034%$1,450.00
Groceries$698.15$742.60+$44.4518%$750.00
Dining$455.00$412.45-$42.5510%$400.00
Shopping$212.40$368.30+$155.909%$250.00
Transport$301.75$288.10-$13.657%$300.00

See every transaction for the detail.

Image: Spending by category in October, rent first

Next month, move the refund to the travel fund before it is spent.

Export the month as a file with:

snapshot export --month 2026-10 --format csv
Follows the page theme and corners
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { Prose } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
import { ModelMarkdown } from './prose-markdown';

/**
 * The Insights page's server part: the month's report, which a model wrote in Markdown, rendered by a server
 * component with no client JavaScript. `skipHtml` drops the HTML it wrote, its link becomes text and its image
 * its alt text, and its table scrolls inside a named region on a phone.
 */
export default async function InsightsReport(): Promise<ReactElement> {
  // Your server's query for the month's report goes here. The docs make no request, so it is a file.
  const report = await readFile(path.join(process.cwd(), 'examples', 'insights-report.md'), 'utf8');
  return (
    <Prose as="article" style={{ width: '100%' }}>
      <ModelMarkdown>{report}</ModelMarkdown>
    </Prose>
  );
}

The page puts the two together. The docs make no request, so here the figures and the report are fixtures.

// app/insights/page.tsx, a server component
import { Prose } from '@snapshot-ui/react';
import { InsightsBoard } from './insights-board'; // 'use client': the figures, filters, chart card and table
import { ModelMarkdown } from './markdown'; // react-markdown with skipHtml and the map
import { monthlyReport } from './queries';

export default async function InsightsPage() {
  const report = await monthlyReport();
  return (
    <>
      <InsightsBoard />
      <Prose as="article">
        <ModelMarkdown>{report}</ModelMarkdown>
      </Prose>
    </>
  );
}

Usage

pnpm add @snapshot-ui/charts @snapshot-ui/core

It needs the same 0.x minor of @snapshot-ui/core (0.10 for charts 0.2.1), whose styles.css styles it. BarChart is a client part, and its server HTML is the final picture: every bar's place and size, the labels, the amounts, the legend and the table. The client adds the tab stop, the keys, the pointer and the readout. Nothing is measured, so the page does not move when it hydrates.

  • Formatting: every figure is your string. valueLabel is what the reader sees, and value only places the bar. Currency, compact figures and Persian digits are yours, and the chart never sums, sorts or formats.
  • labels and statusLabels: in another language, pass all six strings the chart writes itself and all three of its states, so no English is left on the page.
import { BarChart } from '@snapshot-ui/charts';

<BarChart
  aria-label="هزینه بر اساس فروشگاه، مهر"
  orientation="horizontal"
  categories={['هایپراستار', 'اسنپ', 'سایر']}
  series={[
    {
      label: 'مهر',
      values: [
        { value: 412.3, valueLabel: '۴۱۲٫۳۰ دلار' },
        { value: 186.75, valueLabel: '۱۸۶٫۷۵ دلار' },
        { value: 64.1, valueLabel: '۶۴٫۱۰ دلار' },
      ],
    },
  ]}
  labels={{
    showTable: 'نمایش جدول',
    showChart: 'نمایش نمودار',
    noScript: 'اسکریپت‌ها خاموش‌اند، پس داده‌های نمودار در جدول زیر است.',
    roleDescription: 'نمودار',
    bar: 'میله',
    total: 'جمع',
  }}
  statusLabels={{ loading: 'در حال بارگذاری', empty: 'برای این دوره چیزی نیست', error: 'نمودار بارگذاری نشد' }}
/>;

Props

A BarChart accepts every HTML attribute, applied to its root figure.

aria-label
string

A name for assistive technology. Give it, or aria-labelledby, where no label or text is shown.

aria-labelledby
string

The id of an element that holds the name, such as a label shown elsewhere.

bands
"category" | "time"

Whether the bands are periods in order or peers. Time bands start the keys at the newest.

Default: 'category'

categories
readonly string[]

The bands' labels, formatted: "Oct", "Groceries", "Whole Foods Market", "Other". In the app's order: a ranked list is sorted by the app.

categoryTicks
readonly number[]

Which category labels show, by index.

Default: every one for 'category'; first, middle and last for 'time' in a vertical chart

className
string

hasTexture
boolean

Draws each stacked series' texture as well as its color: solid, stripes at 45 and 135 degrees, both, then solid slate. Always on in forced colors.

Default: false

height
number

The plot's height in px, vertical only; a horizontal chart is as tall as its rows.

Default: 240

labels
BarChartLabels

Default: { showTable: 'Show as table', showChart: 'Show as chart', noScript: "Scripts are off, so the chart's data is in the table below.", roleDescription: 'chart', bar: 'bar', total: 'Total' }

layout
"grouped" | "stacked"

navigate
((href: string) => void)

Opens a link's href through your router, as RouterProvider's navigate does: a plain click or Enter on a bar or a table link calls it in place of the browser's navigation, and a click with a modifier key stays the browser's. The charts import no React Aria, so they cannot read RouterProvider; pass it the same function.

onBarPress
((category: number, series: number) => void)

Called with a bar's band and series index when it is pressed: a click or a tap anywhere in its band, or Enter, or Space on a bar with no href, which makes that bar a button. A linked bar's press goes on to its link. A click with a modifier key or the middle button is the browser's, a new tab or window, never a press.

orientation
"horizontal" | "vertical"

Default: 'vertical'

series
readonly BarSeries[] | readonly [BarSeries] | readonly [BarSeries, BarSeries]

status
ChartStatus

Default: 'ready'

statusContent
ReactNode

Drawn in the bars' place for 'empty' and 'error', such as an EmptyState with a Retry button.

statusLabels
ChartStatusLabels

Default: { loading: 'Loading', empty: 'Nothing to show for this period', error: 'The chart could not be loaded' }

totals
readonly (string | null)[]

Each band's total, formatted by the app, for the readout, a Total column in the table and a stack's labels. The chart never sums.

valueDomain
readonly [number, number]

The value domain. Always widened to include zero.

Default: from the least to the greatest value, zero included, rounded out

valueLabels
"all" | "none" | readonly number[]

Amounts: none, every bar, or the bands at these indexes. A stack shows its band's total from totals.

Default: 'all' for horizontal, 'none' for vertical

valueTicks
readonly { value: number; label: string; }[]

The value gridlines and their labels, formatted. Absent, no gridlines but the zero line and no value axis labels.

color
ChartColor

Pins the series to a color, so it keeps it when another series is filtered out.

Default: ink, then slate; stacked, chart-1 to chart-4, then slate

label
string

"Groceries", "October", "Income". Names the series in the legend, the readout, each bar's name and the table.

look
"fill" | "hatched"

'hatched' draws the series as the previous period: the color of the first filled series, striped, with its amounts in slate. Grouped charts only: a stack draws it as a fill, with a warning.

Default: 'fill'

values
readonly (BarValue | null)[]

One per category, aligned with categories. null is no value: no bar, skipped by the keys, an empty cell.

href
string

Makes the bar a link to what it stands for, such as its transactions for the period shown: an anchor that works before hydration, from a server page and with a modifier key, and its table cell links too. Data, so a server component can pass it.

value
number

valueLabel
string

Formatted: "$420.00", "-$1,240.00", "۴۲۰ دلار".

bar
string

Each bar's aria-roledescription: "bar".

noScript
string

Shown with scripts off, above the table: "Scripts are off, so the chart's data is in the table below."

roleDescription
string

The bars' group's aria-roledescription: "chart".

showChart
string

showTable
string

total
string

The table's header for totals, and the readout's: "Total".

empty
string

Drawn in the chart when status is 'empty' and no statusContent is given: "Nothing to show for this period".

error
string

Drawn in the chart when status is 'error' and no statusContent is given: "The chart could not be loaded".

loading
string

The polite status while loading: "Loading".

Accessibility

The chart is a figure named by aria-label or aria-labelledby, which the types require. Its bars are a group named like the figure, with the role description "chart" (labels.roleDescription), and the table is the full alternative.

  • One stop: the chart is a single tab stop, the active bar, so eleven rows are never eleven stops. It becomes focusable once scripts run, and Tab comes back to the bar last active: first the newest band for time bands, the first band otherwise.
  • Names: each bar is an image named by its table cells, the band's category and its value, and its series when there are two or more, such as "Groceries September $598.30", or a stack's segment, "Oct Dining $312.45". A screen reader says it on every move, with no live region, and with your strings only.
  • Links: a bar with an href is a link named the same way, and says it is a link rather than a bar; with onBarPress and no href, a button. Its target over the band is hidden from assistive technology and is never a stop, so a reader meets each link once, on the bar. Before the page hydrates every link is a stop of its own, in drawn order; once it hydrates they are one stop, and a link focused before then keeps focus and becomes the stop. A table link or the toggle that a status or new data takes from under focus hands it to the chart, never the page: to the toggle while the table shows, else to the chart.
  • Keys: the arrows move to the bar drawn in their direction, through a pair bar by bar. Standing up, Left and Right move and follow the page's direction; lying down, Up and Down do. The other pair is left to the page, except in a stack, where it moves along the stack, segment by segment.
  • Readout: standing up it sits above the band's highest bar when it fits and below its lowest otherwise; lying down, above or below the active row. It never covers a bar of its band, and it repeats the bar's name, so it is hidden from assistive technology.
  • Pointer: the whole band or row is the target, not only the bar, and in a band of two the bar nearer the pointer; the others step back to 40% while a pointer holds one. A pointer never moves focus.
  • Table: the Show as table toggle under the chart swaps in a table of the same strings, for everyone who would rather read the numbers. With scripts off it is shown under the chart.
  • Forced colors: the bars take the reader's text color and the active band Highlight, and a previous period stays striped against them; a stack keeps its textures, four looks in the text color and the fifth in GrayText, with the page's own color between segments; the zero line between the signs is the text color, and the gridlines GrayText.
TabMoves to the active bar, one stop for the whole chart, then to the table toggle.
LeftRightStanding up: the bar beside, through a pair bar by bar, then the next band, skipping bands with no value; in a stack, the same category in the band beside. Lying down, in a stack: the segment beside along the bar. Mirrored in right to left.
UpDownLying down: the bar above or below, through a pair bar by bar, then the next row, skipping rows with no value; in a stack, the same category in the row beside. Standing up, in a stack: the segment above or below.
HomeEndThe first or last band with a value, in the same series where it has one, else the nearest.
PageUpPageDownSeven bands back or forward.
EscapeHides the readout until the next move. A second Escape goes on to the dialog or sheet around the chart.
EnterOn a bar with an href, follows its link; on a bar that is a button, presses it.
SpaceOn a bar that is a button, presses it; on a link, nothing, so the page does not scroll.
EnterSpaceOn the toggle, swaps the chart and the table.

Design rules

What keeps bar charts in the system.

Sort a ranked list by amount, and put Other last.

Keep every amount in your own format, the one the rest of the page uses.

Lay the bars down when the names are long or there are more than eight.

Draw the previous period hatched in the current series' color, named by its period.

Stack five series at most, the fifth your Other, and pass the totals you sum.

Link a bar to what it stands for with its href, as data, and pass your router's navigate on a client page.

Never draw a direction in red or green. A refund below zero is ink, like the rest.

Never pass a domain without zero. A bar that starts anywhere else misstates its value.

Never rotate a label. Let it wrap, or lay the chart down.

Never give money out a color of its own. Spending is ink; only money that arrived is gain.

esc

↑ ↓ to move↵ to open