Axes
Axis types: category (bands), number, time, log. Binding is by position: bottom/top — the X axis, left/right — the Y axis.
Default look
Out of the box axes stay quiet, and the two directions split the work: the category axis keeps its line and has no grid, the value axis drops its line and is read off a dashed grid instead. Ticks are off on both. In a horizontal chart the value axis is the horizontal one, so the dashes turn vertical along with it.
All of this chrome is light grey — the axisColor theme token, overridable per axis through line.stroke, tick.stroke and gridLine.stroke. The defaults sit underneath your options, so anything comes back on request: tick: { enabled: true }, line: { enabled: true }, gridLine: { enabled: true, lineDash: [] } for a solid grid along the categories.
Line, ticks and grid
The axis line takes the same three style options as the grid — colour, width and dash pattern — and the ticks take their length and colour beside them:
axes: [
{
position: 'bottom',
line: { stroke: '#0f766e', width: 2, lineDash: [6, 3] },
tick: { enabled: true, size: 10, width: 2, color: '#0f766e' },
gridLine: { stroke: '#e2e8f0', width: 1, lineDash: [2, 4] },
},
],line.lineDash and gridLine.lineDash read like the CSS-side dash arrays: [on, off] in pixels, and an empty array [] draws a solid line even when the theme dashes it. tick.size is the length of the mark, always drawn outwards from the plot, and tick.color is an alias of tick.stroke — either sets the tick colour and wins over the theme's tickColor.
Two value axes
Quantities of different sizes — euros and per cent, requests and latency — share a chart badly on one scale: the smaller one flattens into the baseline. Declare a second value axis on the opposite side, and let each axis say with keys which series it carries:
series: [
{ type: 'bar', xField: 'month', yField: 'revenue', name: 'Revenue' },
{ type: 'line', xField: 'month', yField: 'margin', name: 'Margin' },
],
axes: [
{ type: 'category', position: 'bottom' },
{ type: 'number', position: 'left', keys: ['revenue'], title: { text: 'Revenue, k€' } },
{ type: 'number', position: 'right', keys: ['margin'], title: { text: 'Margin, %' } },
],import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';
export function createOptions(): ChartOptions {
return {
data: getData(),
title: { text: 'Revenue and Margin' },
subtitle: { text: 'thousands of euro and per cent, on their own scales' },
series: [
{ type: 'bar', xField: 'month', yField: 'revenue', name: 'Revenue' },
{ type: 'line', xField: 'month', yField: 'margin', name: 'Margin' },
],
axes: [
{ type: 'category', position: 'bottom' },
{ type: 'number', position: 'left', keys: ['revenue'], title: { text: 'Revenue, k€' } },
{ type: 'number', position: 'right', keys: ['margin'], label: { format: ',.1f' }, title: { text: 'Margin, %' } },
],
};
}export function getData() {
return [
{ month: 'Jan', revenue: 420, margin: 12.4 },
{ month: 'Feb', revenue: 465, margin: 13.1 },
{ month: 'Mar', revenue: 510, margin: 12.8 },
{ month: 'Apr', revenue: 495, margin: 14.6 },
{ month: 'May', revenue: 580, margin: 15.2 },
{ month: 'Jun', revenue: 640, margin: 16.9 },
{ month: 'Jul', revenue: 705, margin: 16.1 },
{ month: 'Aug', revenue: 690, margin: 17.4 },
];
}keys lists value fields — yField, or the low/high and OHLC fields of the multi-field series — and a series id matches too, which is how two series over one field end up on different axes. Everything unclaimed goes to the first axis without keys, so a chart with a single value axis behaves exactly as before.
Each axis then scales itself to its own series only, and picks its own nice bounds; hiding a series through the legend rescales its axis alone. The two grids would never line up, so only the first value axis keeps its grid — turn it on for the second one with gridLine: { enabled: true } if you want both.
The same works in a horizontal chart, where the value axes are bottom and top. What still reads off the first value axis: annotations, the crosshair's value label and the Y zoom window.
Time axis
time accepts a Date, a timestamp or an ISO string; ticks snap to calendar boundaries, and the label format depends on the step (hours → days → months → years).
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';
export function createOptions(): ChartOptions {
return {
data: getData(),
title: { text: 'Metric over Time' },
series: [{ type: 'line', xField: 'date', yField: 'value', name: 'Value', marker: { enabled: false } }],
axes: [
{ type: 'time', position: 'bottom' },
{ type: 'number', position: 'left' },
],
legend: { enabled: false },
};
}export function getData() {
const start = Date.UTC(2025, 0, 1);
const day = 24 * 60 * 60 * 1000;
const values = [41, 43, 47, 45, 49, 53, 51, 56, 58, 55, 61, 64, 62, 67, 70, 68, 73, 71, 76, 79, 77, 82, 85, 83, 88, 86, 91, 94, 92, 97];
return values.map((value, index) => ({ date: new Date(start + index * 3 * day), value }));
}Bars on a time axis
Bars, ranges, boxes and candles stand on a time axis as readily as on a band one — the difference is that the axis places them by their real distance apart, so a month with no data leaves its place empty instead of vanishing:
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';
export function createOptions(): ChartOptions {
return {
data: getData(),
title: { text: 'Sales by month' },
subtitle: { text: 'February has no data — a continuous axis keeps its place' },
series: [
{ type: 'bar', xField: 'date', yField: 'plan', name: 'Plan' },
{ type: 'bar', xField: 'date', yField: 'actual', name: 'Actual' },
],
axes: [
{ type: 'time', position: 'bottom' },
{ type: 'number', position: 'left', title: { text: 'M₽' } },
],
legend: { position: 'bottom' },
};
}/** Monthly sales — February is missing, and a continuous axis leaves its place empty. */
export function getData() {
const month = (index: number) => new Date(Date.UTC(2025, index, 1));
return [
{ date: month(0), plan: 120, actual: 108 },
{ date: month(2), plan: 130, actual: 141 },
{ date: month(3), plan: 135, actual: 129 },
{ date: month(4), plan: 140, actual: 152 },
{ date: month(5), plan: 145, actual: 138 },
{ date: month(6), plan: 150, actual: 163 },
];
}A band axis knows how wide a band is; a continuous one is told. The width comes from the step of the data — the smallest distance between neighbouring values, measured across every visible series so that grouped bars keep sharing one band. The smallest rather than the average: months are of unequal length, and a mean step would have November overlap December.
bandSpan overrides it, in axis units — milliseconds on a time axis, so a bar keeps covering its own period through a zoom, where a width in pixels would not:
axes: [
// hourly readings with the odd gap: a bar is an hour wide whatever the gaps say
{ type: 'time', position: 'bottom', bandSpan: 60 * 60 * 1000 },
{ type: 'number', position: 'left' },
]The same option is on the number axis, in its own units. A single point says nothing about a step, so its bar falls back to a tenth of the plot.
Where the dates line up evenly — trading sessions, weeks without weekends — the ordinal-time axis is the other answer: bands of equal width with calendar labels above them, so the gaps close and nothing is placed by distance.
Logarithmic axis
log — for data growing by orders of magnitude; ticks at powers of base (10 by default).
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';
export function createOptions(): ChartOptions {
return {
data: getData(),
title: { text: 'Exponential Growth' },
subtitle: { text: 'logarithmic Y axis' },
series: [{ type: 'line', xField: 'year', yField: 'users', name: 'Users' }],
axes: [
{ type: 'category', position: 'bottom' },
{ type: 'log', position: 'left' },
],
legend: { enabled: false },
};
}export function getData() {
return [
{ year: '2018', users: 120 },
{ year: '2019', users: 540 },
{ year: '2020', users: 2400 },
{ year: '2021', users: 9800 },
{ year: '2022', users: 41000 },
{ year: '2023', users: 165000 },
{ year: '2024', users: 720000 },
{ year: '2025', users: 2900000 },
];
}Hierarchical categories
grouped-category: data values are [group, item] arrays (deeper tuples are fine too); a row of groups with separators appears below the item labels:
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';
export function createOptions(): ChartOptions {
return {
data: getData(),
title: { text: 'Revenue by Product Category' },
subtitle: { text: '$B, grouped categories year → quarter' },
series: [
{ type: 'bar', xField: 'period', yField: 'iphone', name: 'iPhone' },
{ type: 'bar', xField: 'period', yField: 'mac', name: 'Mac' },
{ type: 'bar', xField: 'period', yField: 'services', name: 'Services' },
],
axes: [
{ type: 'grouped-category', position: 'bottom' },
{ type: 'number', position: 'left' },
],
};
}export function getData() {
return [
{ period: ['2018', 'Q1'], iphone: 140, mac: 16, services: 20 },
{ period: ['2018', 'Q2'], iphone: 124, mac: 20, services: 30 },
{ period: ['2018', 'Q3'], iphone: 112, mac: 20, services: 36 },
{ period: ['2018', 'Q4'], iphone: 118, mac: 24, services: 36 },
{ period: ['2019', 'Q1'], iphone: 124, mac: 18, services: 26 },
{ period: ['2019', 'Q2'], iphone: 108, mac: 20, services: 40 },
{ period: ['2019', 'Q3'], iphone: 96, mac: 22, services: 42 },
{ period: ['2019', 'Q4'], iphone: 104, mac: 22, services: 40 },
];
}In horizontal charts the category axis is vertical, and the group column with separators appears to the left of the item labels:
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';
export function createOptions(): ChartOptions {
return {
data: getData(),
title: { text: 'Revenue by Product Category' },
subtitle: { text: '$B, grouped categories year → quarter' },
series: [
{ type: 'bar', xField: 'period', yField: 'iphone', name: 'iPhone', direction: 'horizontal' },
{ type: 'bar', xField: 'period', yField: 'mac', name: 'Mac', direction: 'horizontal' },
{ type: 'bar', xField: 'period', yField: 'services', name: 'Services', direction: 'horizontal' },
],
axes: [
{ type: 'grouped-category', position: 'left' },
{ type: 'number', position: 'bottom' },
],
};
}export function getData() {
return [
{ period: ['2018', 'Q1'], iphone: 140, mac: 16, services: 20 },
{ period: ['2018', 'Q2'], iphone: 124, mac: 20, services: 30 },
{ period: ['2018', 'Q3'], iphone: 112, mac: 20, services: 36 },
{ period: ['2018', 'Q4'], iphone: 118, mac: 24, services: 36 },
{ period: ['2019', 'Q1'], iphone: 124, mac: 18, services: 26 },
{ period: ['2019', 'Q2'], iphone: 108, mac: 20, services: 40 },
{ period: ['2019', 'Q3'], iphone: 96, mac: 22, services: 42 },
{ period: ['2019', 'Q4'], iphone: 104, mac: 22, services: 40 },
];
}As many rows as the tuple has levels
The tuple is not limited to two: every element but the last gets a row of its own. ['2024', 1, 'Q1'] labels the ticks with quarters, puts the halves in a row above them and the years in a row above those — the outermost level furthest from the plot, the way a pivot table stacks its headers. A separator belongs to the outermost level that has it, so a year boundary is drawn once, running the full depth of the rows.
Groups are runs of neighbouring categories with equal values, not with equal text: null and 'null', 1 and '1' stay two groups the same way they are two categories. Two Date objects standing for the same moment are one group.
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';
export function createOptions(): ChartOptions {
return {
data: getData(),
title: { text: 'Revenue by quarter' },
subtitle: { text: '$B, three levels on the axis: year → half → quarter' },
series: [{ type: 'bar', xField: 'period', yField: 'revenue', name: 'Revenue', cornerRadius: 2 }],
axes: [
{
type: 'grouped-category',
position: 'bottom',
// the formatter gets the raw level value: the halves arrive as 1 and 2
groupLabel: { formatter: ({ value, level }) => (level === 0 ? `FY ${String(value)}` : `H${String(value)}`) },
},
{ type: 'number', position: 'left', label: { format: '$,d' } },
],
legend: { enabled: false },
};
}/** Three levels on the axis: year → half (raw number) → quarter. */
export function getData() {
return [
{ period: ['2023', 1, 'Q1'], revenue: 117 },
{ period: ['2023', 1, 'Q2'], revenue: 94 },
{ period: ['2023', 2, 'Q3'], revenue: 82 },
{ period: ['2023', 2, 'Q4'], revenue: 120 },
{ period: ['2024', 1, 'Q1'], revenue: 126 },
{ period: ['2024', 1, 'Q2'], revenue: 91 },
{ period: ['2024', 2, 'Q3'], revenue: 88 },
{ period: ['2024', 2, 'Q4'], revenue: 134 },
];
}Styling and formatting the group rows
The rows have their own block of options, groupLabel — font, colour and format of their own, because a group name answers a different question than the tick under it. Its formatter is handed the raw value of its own level, the row number and the run of categories the group covers; a group has no tick index, it stands over a range of them:
axes: [
{
type: 'grouped-category',
groupLabel: {
fontSize: 12,
color: '#334155',
// level 0 is the outermost row
formatter: ({ value, level }) => (level === 0 ? `FY ${value}` : `H${value}`),
},
},
];groupLabel.format is the serializable half of the same thing ('%b %Y', ',.0f'), applied to the level value. Without either, a group prints the way a tick number does — millions and thousands shortened.
label.formatter stays with the item labels: it is handed the whole tuple and a tick index, so the two rows keep formatting for their own question. groupLabel: { enabled: false } drops the rows altogether — the item labels stay, and the axis stops reserving room for groups.
Labels always fit
Labels are placed by an anchor: a tick label is centred on its tick, a value label hangs off its bar. Both therefore reach past the plot rect — by half the width of the outermost tick label, by the whole width of a label sitting to the right of the longest bar. The layout measures that reach and takes it off the plot, so nothing is ever clipped by the edge of the canvas: the plot gives way to the labels rather than the other way round.
That room is shared with the axis zones instead of being added to them — a label hanging 12 px over the left edge costs nothing when the Y axis already reserves 40 px there. Which is why the effect only shows up where it is needed: a percentage axis whose last tick sits on the right edge, a horizontal bar chart whose value labels follow the bars out.
The area the chart is fitted into is the one left after padding, the title/subtitle and the legend, so your padding stays yours — labels do not creep into it.
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';
export function createOptions(): ChartOptions {
return {
data: getData(),
title: { text: 'Value Labels on Bars' },
subtitle: { text: "outer ('top') and inner ('inner-top') placements" },
series: [
{
type: 'bar',
xField: 'team',
yField: 'done',
name: 'Done',
label: { enabled: true, placement: 'inner-top', fontWeight: 'bold' },
},
{
type: 'bar',
xField: 'team',
yField: 'planned',
name: 'Plan',
label: { enabled: true, placement: 'top' },
},
],
};
}export function getData() {
return [
{ team: 'Alpha', done: 34, planned: 42 },
{ team: 'Beta', done: 27, planned: 30 },
{ team: 'Gamma', done: 41, planned: 38 },
{ team: 'Delta', done: 22, planned: 35 },
];
}Polar charts are fitted the same way: the grid radius is chosen so that the category names around the rim stay inside the area, and a long name on one side only slides the centre across instead of shrinking the whole web. Where the spokes crowd together, labels that would collide are dropped while the grid itself stays whole (see Radar). For pie and donut, outside callout labels cap the radius the same way.
Labels that do not fit
A horizontal axis has one step of room per label. When the names are longer than that, the default is to thin them out: every other label — or every third — is dropped, and the ones left are drawn whole. That reads well for dates and numbers, where the ones in between can be inferred; it reads badly for categories, where a missing name is a missing category.
A grouped-category axis thins run by run instead of across the axis as a whole: each run of categories keeps as many labels as fit between its own separators, taken from its middle outwards, so a name never sits under the group next door. A run too narrow for a label of its own is left to its group name.
label.overflow: 'ellipsis' chooses the other trade: every label stays on the axis and is cut to the room between two ticks, with label.ellipsis — '..' by default, '…' if you prefer — standing where the text was cut. Nothing then runs into its neighbour or into the tick line between them.
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';
export function createOptions(): ChartOptions {
return {
data: getData(),
title: { text: 'Revenue by department' },
subtitle: { text: '$M, long category names cut to the room between the ticks' },
series: [{ type: 'bar', xField: 'department', yField: 'revenue', name: 'Revenue', cornerRadius: 2 }],
axes: [
{
type: 'grouped-category',
position: 'bottom',
// every name stays on the axis: the ones that do not fit are cut instead of dropped
label: { overflow: 'ellipsis' },
},
{ type: 'number', position: 'left', label: { format: '$,d' } },
],
legend: { enabled: false },
};
}export function getData() {
return [
{ department: ['Retail', 'Kitchen appliances'], revenue: 48 },
{ department: ['Retail', 'Home entertainment'], revenue: 36 },
{ department: ['Retail', 'Personal computing'], revenue: 29 },
{ department: ['Wholesale', 'Building materials'], revenue: 41 },
{ department: ['Wholesale', 'Industrial fittings'], revenue: 33 },
{ department: ['Wholesale', 'Agricultural supplies'], revenue: 22 },
];
}On a grouped-category axis the group rows follow the same rule: a name is held to the run of categories it stands over, so it stops short of the separators on either side. groupLabel.maxWidth caps it further, label.ellipsis supplies its mark; a name the cut would eat down to the mark alone is dropped instead. Group names live inside their runs the same way labels do: a name wider than the run it stands over goes, rather than reaching over the separator into its neighbour.
label.maxWidth is the cap on its own — it applies whether or not the axis is crowded, and on a vertical axis it also decides how much of the canvas the labels may take from the plot: long category names on the left stop pushing the plot to the right once they are cut.
axes: [
{ type: 'category', position: 'bottom', label: { overflow: 'ellipsis' } },
// a left axis has no step to fit into: the cap is what bounds the names
{ type: 'category', position: 'left', label: { maxWidth: 90, ellipsis: '…' } },
];Labels inside the plot
label.placement: 'inside' moves the tick labels into the plot area, and the axis stops reserving space for them. On a vertical category axis every label sits above its bar: a label row is reserved above the first band, and the gap between bands grows to fit it — set paddingInner explicitly to keep the bar thickness under your own control. Inside labels are drawn over the series.
An inside label keeps two distances of its own, 4 px each: label.insideSpacing — the indent from the axis into the plot, and label.insideGap — the clearance to its own element and to the one before it (that gap is what sets the reserved row height). label.spacing is for outside labels only and does not reach here.
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';
export function createOptions(): ChartOptions {
return {
data: getData(),
title: { text: 'Traffic by channel' },
subtitle: { text: 'category labels sit above their bars' },
series: [
{
type: 'bar',
xField: 'channel',
yField: 'share',
name: 'Share',
direction: 'horizontal',
cornerRadius: 2,
label: { enabled: true, placement: 'right', fontWeight: 'bold', formatter: ({ value }) => `${Math.round(value * 100)}%` },
},
],
axes: [
{
type: 'category',
position: 'left',
label: { placement: 'inside', fontWeight: 'bold', insideSpacing: 0, insideGap: 4 },
line: { enabled: false },
},
{
type: 'number',
position: 'bottom',
label: { format: '.0%' },
gridLine: { lineDash: [2, 3] },
},
],
legend: { enabled: false },
};
}export function getData() {
return [
{ channel: 'Organic search', share: 0.6 },
{ channel: 'Direct', share: 0.2 },
{ channel: 'Referral', share: 0.3 },
{ channel: 'Email', share: 0.1 },
{ channel: 'Social', share: 0.05 },
{ channel: 'Paid ads', share: 0.4 },
];
}With grouped-category the two levels split up: the group column stays outside and keeps its own thickness, the item labels move inside above their bars, and the group separator shifts above the labels.
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';
export function createOptions(): ChartOptions {
return {
data: getData(),
title: { text: 'Revenue share by channel' },
subtitle: { text: 'group column outside, item labels inside' },
series: [
{
type: 'bar',
xField: 'channel',
yField: 'revenue',
name: 'Share',
direction: 'horizontal',
cornerRadius: 2,
label: { enabled: true, placement: 'right', fontWeight: 'bold', formatter: ({ value }) => `${Math.round(value * 100)}%` },
},
],
axes: [
{
type: 'grouped-category',
position: 'left',
label: { placement: 'inside', fontWeight: 'bold' },
line: { enabled: false },
},
{
type: 'number',
position: 'bottom',
max: 1,
label: { format: '.0%' },
gridLine: { lineDash: [2, 3] },
},
],
legend: { enabled: false },
};
}export function getData() {
return [
{ channel: ['Europe', 'Web'], revenue: 0.48 },
{ channel: ['Europe', 'Retail'], revenue: 0.31 },
{ channel: ['Europe', 'Partners'], revenue: 0.17 },
{ channel: ['Americas', 'Web'], revenue: 0.62 },
{ channel: ['Americas', 'Retail'], revenue: 0.24 },
{ channel: ['Americas', 'Partners'], revenue: 0.09 },
];
}On a horizontal axis the labels run along the inner edge of the plot rect, and on a value axis they sit above their own grid line.
CrossLines
Reference lines and ranges in axis coordinates — with labels:
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';
export function createOptions(): ChartOptions {
return {
data: getData(),
title: { text: 'Latency p95' },
series: [{ type: 'line', xField: 'month', yField: 'latency', name: 'p95, ms' }],
axes: [
{
type: 'category',
position: 'bottom',
crossLines: [{ type: 'range', range: ['Apr', 'May'], label: { text: 'incident' } }],
},
{
type: 'number',
position: 'left',
crossLines: [{ value: 200, stroke: '#e5484d', label: { text: 'SLO 200 ms', color: '#e5484d' } }],
},
],
legend: { enabled: false },
};
}export function getData() {
return [
{ month: 'Jan', latency: 182 },
{ month: 'Feb', latency: 174 },
{ month: 'Mar', latency: 196 },
{ month: 'Apr', latency: 230 },
{ month: 'May', latency: 218 },
{ month: 'Jun', latency: 187 },
{ month: 'Jul', latency: 171 },
{ month: 'Aug', latency: 165 },
];
}Polar axes
A radar, a rose or a radial bar chart is drawn on a web, and the web says the same things a pair of cartesian axes says: these are the categories, these are the values. It takes its settings as a pair rather than as a list — angle for the categories around the rim, radius for the value rings:
axes: {
angle: { title: { text: 'Month' }, gridLine: { lineDash: [3, 3] }, line: { stroke: '#64748b' } },
radius: { title: { text: 'Incidents' }, min: 0, max: 60, ringCount: 3, label: { format: ',.0f' } },
},import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';
export function createOptions(): ChartOptions {
return {
data: getData(),
title: { text: 'Polar axes' },
series: [{ type: 'nightingale', angleField: 'month', radiusField: 'incidents', name: 'Incidents', fillOpacity: 0.7 }],
axes: {
angle: {
title: { text: 'Month' },
gridLine: { lineDash: [3, 3], opacity: 0.5 },
line: { stroke: '#64748b' },
},
radius: {
title: { text: 'Incidents' },
min: 0,
max: 60,
ringCount: 3,
label: { format: ',.0f', fontWeight: 'bold' },
},
},
legend: { enabled: false },
};
}export function getData() {
return [
{ month: 'Jan', incidents: 14 },
{ month: 'Feb', incidents: 11 },
{ month: 'Mar', incidents: 17 },
{ month: 'Apr', incidents: 9 },
{ month: 'May', incidents: 13 },
{ month: 'Jun', incidents: 21 },
{ month: 'Jul', incidents: 18 },
{ month: 'Aug', incidents: 12 },
];
}The grid inside the web and the outlines around it are two different things, and they take two different settings. angle.gridLine is the spokes and radius.gridLine the rings — chrome, so they read as the theme's grid does: dashed, faint, behind the data. The outlines are angle.line, the rim that closes the web, and radius.line, the vertical the ring values are read along — each with its own stroke, width and dash, both solid and both there by default, wherever the theme keeps axis lines. The outermost ring gives way to the rim, so the two never stroke the same circle. An empty lineDash draws a solid grid line where the theme dashes it, and enabled: false takes any of the four away.
The value scale is labelled from the centre outwards, the centre included — that is where the scale begins, whether the floor is zero or a min the options set. Labels take a format or a formatter, and the titles stand outside the chart: the category one under it, the value one along the left edge. The room they take is gone before the grid is fitted, so a title never covers a label.
The radial-bar chart inverts the layout — its categories are the rings and its values are the spokes — but the options follow the meaning rather than the shape: angle still settles the categories, radius still settles the values. Its bars sweep part of the circle rather than all of it, so there the rim is a decision — angle.line: { enabled: true } draws it — while radius.line is the line the bars stand on, there by default as everywhere else.
| Option | Type | Default | Description |
|---|---|---|---|
angle.gridLine | enabled, stroke, width, lineDash, opacity | theme, dashed | the spokes |
angle.line | enabled, stroke, width, lineDash | on (rim) | the rim around the web |
angle.label | enabled, font, format, formatter | on | the category names |
angle.title | enabled, text, font | — | title under the chart |
radius.gridLine | as above | theme, dashed | the rings |
radius.line | as above | on | the vertical the values are read along |
radius.label | enabled, font, format, formatter | on | the ring values |
radius.title | enabled, text, font | — | title along the left edge |
radius.min / max | number | from the data | bounds of the value scale |
radius.nice | boolean | true | round the bounds out to whole steps |
radius.ringCount | number | 4 | how many rings the values are read off |
Every option at once
The whole table spelled out, with every option set away from what it would default to — the same block on a rose and on a radar. Nothing in it is about the shape of the web: the rings come out as circles on one and as polygons on the other because of the series, not the axes.
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';
/** Every polar axis option on a rose, each one set away from its default. */
export function createOptions(): ChartOptions {
return {
data: getData(),
title: { text: 'Every axis option — rose' },
series: [{ type: 'nightingale', angleField: 'month', radiusField: 'incidents', name: 'Incidents', fillOpacity: 0.55 }],
legend: { enabled: false },
axes: {
angle: {
// the spokes
gridLine: { enabled: true, stroke: '#0ea5e9', width: 1, lineDash: [1, 3], opacity: 0.7 },
// the rim around the web
line: { enabled: true, stroke: '#0f172a', width: 2, lineDash: [8, 3] },
// the category names
label: {
enabled: true,
fontSize: 12,
fontFamily: 'system-ui, sans-serif',
fontWeight: 'bold',
color: '#0f172a',
formatter: ({ value }) => String(value).toUpperCase(),
},
title: { enabled: true, text: 'Month', fontSize: 14, fontFamily: 'system-ui, sans-serif', fontWeight: 'normal', color: '#0ea5e9' },
},
radius: {
// bounds of the value scale, taken from the options rather than the data
min: 0,
max: 45,
nice: false,
ringCount: 3,
// the rings
gridLine: { enabled: true, stroke: '#f43f5e', width: 1, lineDash: [], opacity: 0.25 },
// the vertical the ring values are read along
line: { enabled: true, stroke: '#f43f5e', width: 2, lineDash: [4, 2] },
// the ring values
label: {
enabled: true,
fontSize: 11,
fontFamily: 'system-ui, sans-serif',
fontWeight: 'bold',
color: '#f43f5e',
format: ',.0f',
},
title: {
enabled: true,
text: 'Incidents',
fontSize: 14,
fontFamily: 'system-ui, sans-serif',
fontWeight: 'normal',
color: '#f43f5e',
},
},
},
};
}export function getData() {
return [
{ month: 'Jan', incidents: 12 },
{ month: 'Feb', incidents: 7 },
{ month: 'Mar', incidents: 19 },
{ month: 'Apr', incidents: 25 },
{ month: 'May', incidents: 34 },
{ month: 'Jun', incidents: 41 },
{ month: 'Jul', incidents: 28 },
{ month: 'Aug', incidents: 16 },
];
}import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';
/** Every polar axis option on a radar, each one set away from its default. */
export function createOptions(): ChartOptions {
return {
data: getData(),
title: { text: 'Every axis option — radar' },
series: [{ type: 'radar-area', angleField: 'skill', radiusField: 'score', name: 'Score', fillOpacity: 0.15 }],
legend: { enabled: false },
axes: {
angle: {
// the spokes
gridLine: { enabled: true, stroke: '#0ea5e9', width: 1, lineDash: [1, 3], opacity: 0.7 },
// the rim around the web
line: { enabled: true, stroke: '#0f172a', width: 2, lineDash: [8, 3] },
// the category names
label: {
enabled: true,
fontSize: 12,
fontFamily: 'system-ui, sans-serif',
fontWeight: 'bold',
color: '#0f172a',
formatter: ({ value, index }) => `${index + 1}. ${String(value)}`,
},
title: { enabled: true, text: 'Skill', fontSize: 14, fontFamily: 'system-ui, sans-serif', fontWeight: 'normal', color: '#0ea5e9' },
},
radius: {
// bounds of the value scale, taken from the options rather than the data
min: 0,
max: 12,
nice: false,
ringCount: 6,
// the rings
gridLine: { enabled: true, stroke: '#f43f5e', width: 1, lineDash: [], opacity: 0.25 },
// the vertical the ring values are read along
line: { enabled: true, stroke: '#f43f5e', width: 2, lineDash: [4, 2] },
// the ring values
label: {
enabled: true,
fontSize: 11,
fontFamily: 'system-ui, sans-serif',
fontWeight: 'bold',
color: '#f43f5e',
format: ',.1f',
},
title: { enabled: true, text: 'Score', fontSize: 14, fontFamily: 'system-ui, sans-serif', fontWeight: 'normal', color: '#f43f5e' },
},
},
};
}export function getData() {
return [
{ skill: 'Speed', score: 7.5 },
{ skill: 'Quality', score: 8.2 },
{ skill: 'Reliability', score: 6.4 },
{ skill: 'Support', score: 5.1 },
{ skill: 'Price', score: 8.8 },
{ skill: 'Docs', score: 4.6 },
];
}Worth reading off the pair: nice: false keeps max exactly where it was put, so the outermost ring stops short of the rim on the rose and lands on it on the radar — where it does land, the rim keeps the line and the ring gives way. format on one axis and formatter on the other; a formatter wins over a format string wherever both are given.
Axis options
| Block | Options |
|---|---|
| number/log | min, max, nice, base (log), bandSpan (number) |
| time | min, max (Date/timestamp), bandSpan (ms) |
| category | paddingInner, paddingOuter |
polar angle / radius | see Polar axes; radius adds min, max, nice, ringCount |
Full option list
| Option | Type | Default | Description |
|---|---|---|---|
type | 'number' | 'category' | 'time' | 'log' | 'ordinal-time' | 'grouped-category' | based on series | axis type |
position | 'bottom' | 'left' | 'top' | 'right' | based on type | axis side |
title.enabled | boolean | true when text is set | axis title |
title.text | string | — | title text |
title.fontSize | Pixels | 12 | title font size |
title.color | ColorValue | foreground | title color |
line.enabled | boolean | category axis only | axis line |
line.stroke | ColorValue | theme axis (light grey) | line color |
line.width | Pixels | 1 | line width |
line.lineDash | Pixels[] | solid | axis line dash pattern ([] forces a solid line) |
tick.enabled | boolean | false | ticks |
tick.size | Pixels | 6 | tick length |
tick.width | Pixels | 1 | tick width |
tick.stroke | ColorValue | theme axis (light grey) | tick color (tick.color is an alias) |
tick.lineDash | Pixels[] | solid | tick dash pattern |
label.enabled | boolean | true | tick labels |
label.fontSize | Pixels | 11 | label font size |
label.fontFamily | string | theme font | typeface |
label.color | ColorValue | theme muted | label color |
label.spacing | Pixels | 8 | outside labels: gap from the tick or the axis |
label.insideSpacing | Pixels | 4 | inside labels: indent from the axis |
label.insideAlign | 'element' | 'gap' | 'element' | inside labels: hug the element or centre in the gap |
label.insideGap | Pixels | 4 | inside labels: clearance to their element |
label.placement | 'outside' | 'inside' | 'outside' | labels beside the axis or inside the plot |
label.format | string | — | format string (',.2f', '.0%', '%d %b') |
label.formatter | ({ value, index }) => string | — | programmatic formatting |
label.avoidCollisions | boolean | true | skip overlapping labels |
label.overflow | 'thin' | 'ellipsis' | 'thin' | crowded labels: drop them, or keep and cut them |
label.maxWidth | Pixels | — | widest a label may be; longer text is cut |
label.ellipsis | string | '..' | the mark standing where the text was cut |
gridLine.enabled | boolean | value axis only | grid lines |
gridLine.stroke | ColorValue | theme axis (light grey) | grid color |
gridLine.width | Pixels | 1 | width |
gridLine.lineDash | Pixels[] | [4, 4] | grid dash pattern |
interval.values | unknown[] | auto | explicit tick values |
interval.minSpacing | Pixels | 8 | minimum label spacing |
crossLines[].type | 'line' | 'range' | 'line' | line or range |
crossLines[].value | value | — | line coordinate |
crossLines[].range | [from, to] | — | fill range |
crossLines[].stroke | ColorValue | theme muted | line color |
crossLines[].strokeWidth | Pixels | 1 | line width |
crossLines[].lineDash | Pixels[] | — | dash pattern |
crossLines[].fill | ColorValue | theme muted | range fill |
crossLines[].fillOpacity | Fraction | 0.12 | fill opacity |
crossLines[].label.text | string | — | label text |
crossLines[].label.color | ColorValue | theme muted | label color |
crossLines[].label.fontSize | Pixels | 11 | label font size |
min (number, log) | number | data domain | lower bound |
max (number, log) | number | data domain | upper bound |
nice (number) | boolean | true | round the domain to “nice” bounds |
base (log) | number | 10 | logarithm base |
paddingInner (category, ordinal-time, grouped-category) | Fraction | 0.2 (ordinal-time 0.25) | gap between elements, share of the step |
gap (category, ordinal-time, grouped-category) | Pixels | — | gap between elements in px; wins over paddingInner |
paddingOuter (category, ordinal-time, grouped-category) | Fraction | 0.1 | outer band padding |
bandSpan (time, number) | number | step of the data | width of a bar in axis units (ms on a time axis) |
groupSpacing (grouped-category) | Pixels | 8 | gap between item labels and the group row, and between rows |
groupLabel.enabled (grouped-category) | boolean | true | rows of group names |
groupLabel.fontSize (grouped-category) | Pixels | 11 | group name font size |
groupLabel.fontFamily (grouped-category) | string | theme font | typeface |
groupLabel.fontWeight (grouped-category) | FontWeight | 'bold' | group name weight |
groupLabel.color (grouped-category) | ColorValue | foreground | group name color |
groupLabel.format (grouped-category) | string | — | format string for the level value |
groupLabel.formatter (grouped-category) | ({ value, level, start, end }) => string | — | programmatic formatting of a group name |
groupLabel.maxWidth (grouped-category) | Pixels | the run the group covers | widest a group name may be |
Horizontal axis labels are automatically thinned out when crowded (label.avoidCollisions: false disables this), or cut instead — see Labels that do not fit.
Overlays
The “no data” and “loading” states are enabled by default: empty data shows overlays.noData.text, and loading: true shows overlays.loading.text.