Plot

Axis

Control every part of a chart's axes — tick marks, label rows, gridlines, domain lines, shaded bands and titles — with one line-oriented grammar.

AI assisted, human approved — novem uses AI to review and keep our documentation up to date.

Note: the axis grammar is early access. You can explore it interactively in the axis playground and the plot playground, which implement the grammar described on this page.

The axis grammar lives in one file directly under your plot's config (margins have their own file beside it):

plot_name
├── config
│   ├── axis                    => The axis grammar (this page)
│   ├── margins                 => Surface margins (own format)

An axis is a stack of lanes over one scale. Each non-empty line of the axis file is one lane: a filter that selects domain values, plus properties that say what to draw for them — marks, labels, a gridline, a shaded band. Months and years under a time axis are two lanes; a grid is a lane; a recession band is a lane.

<axis>[+] [domain] [selection] [key:value ...]

Two positional filter slots, like d3's domain/range split: the domain declares the axis's coordinate span, the selection picks values within it for this lane's furniture. One filter token is always the selection; the domain slot exists only in the two-token form:

x 2000:2030 ::5y  f:%Y      -- domain: 30 years; label every 5 across it
y 0:100 ::25      f:,.0f    -- domain 0-100, ticks every 25
x ::5y            f:%Y      -- selection only; domain stays at its default
  • Lines are whitespace-tokenized. -- starts a comment. Blank lines are ignored. A lane dies only with its axis token: an invalid property is dropped while the rest of its line survives, and so is an invalid filter, whose slot falls back to auto. A half-typed key reports as one, so adding a property to a line never blanks the axis mid-keystroke.
  • Values containing whitespace are quoted — '…' or "…", the opener naming the closer: f:"%b %Y", l:'Net flow'.
  • Keys and axis tokens are case-insensitive; formats are case-sensitive. Category names match case-insensitively with spaces as underscores.
  • The filter is optional and defaults to : (everything / auto).
Axis tokenMeaning
xxNx-family rails (bare xx1); default side bottom
yyNy-family rails (bare yy1); default side left
othe outer frame (domain lines on all four sides)
+ xN + yN (or glued +xN)amend the previous label lane of that rail (see Amending)

Numbers are rail ordinals, not sides — a rail is a full axis, and there is no upper bound. The side is the e: property on each line (e:l|r on y, e:b|t on x; defaults left/bottom): lanes partition by (family, side) before any rail rule applies, so e:r on one line never moves another line's furniture, and ordinals count per side (y2 e:r is the second rail of the right side). Rails sharing a side stack visibly outward in ordinal order. The primary rail — the innermost rail of the default side, or of the only side present — owns the plot scale; every other rail inherits it (ruler-style stacking) or declares a numeric domain of its own — a reading rail that relabels the same pixels (°F beside °C) without ever re-windowing the plot.

Special forms: y e:r mirror duplicates the opposite side's innermost rail on this side (crossed-axis offsets swap, so outward space stays outward); x none owns the axis and renders nothing (suppresses the default axes, see Defaults); cut turns a lane's marks into holes instead of ink (see Cut lanes).

cut makes the lane's marks paint holes through everything the plot has drawn below the lane — series, gridlines, chrome — instead of ink. A hole reads as the page background with no colour named, so a cut divider is correct in every theme, in dark mode, and over an unknown host page by construction, where a c:-painted line would need the right colour picked for each context.

Unlike none and mirror, cut is a mode, not a whole-lane claim: it is a bare keyword on an ordinary rail line, written anywhere among the filters (x cut ::2 and x ::2 cut are the same lane), and the lane's filters, slices and geometry properties compose exactly as on a painted lane — ta: makes a dashed hole, tw: sets its width, and the lane's labels still render normally. The shipped price/volume divider is one line:

y2 ts:-100% z:over cut tw:1   -- a full-bleed divider that cuts through
  • By default the cut removes from the entire stack below the lane; z:over states that intent explicitly. An explicit z:under scopes the holes to bands and the under-plot gridlines only — legal, but nearly invisible since the series sits above it, and diagnosed as such.
  • Mark colours (tc:, dc:, fill:) have no ink to colour on a cut lane and drop with a diagnostic; c: survives, since it still colours the lane's labels.
  • cut is not amendable (a + x line drops it), does not combine with none or mirror, and is not supported on the frame o.

The domain slot is a single stepless range (a:b, _, or :); endpoints are literals, value tokens, or _ (data-anchored). It is axis-level (last declaration wins; all rails of a family share rail 1's plot domain), and declaring one replaces the default — the zero-config domain is the data extent, zero-clamped on value axes, so y i:x : gives a terminal-style data-tight scale and y 50:200 : is the zoom. Data outside the domain clips; a domain wider than the data is legal and central (projection space). Selections can never re-window a chart — only domains can.

Bars and areas truncate at the window edge: under a domain that excludes zero the base rests on the edge instead of projecting toward raw zero, so y _ : over strictly positive data gives truncated bars.

A selection is a comma-separated union of atoms, written in the axis's own domain units — numbers on value axes, dates on time axes, category names or positions on categorical axes.

AtomExampleSelects
: (or absent)xeverything — nice ticks, or every category
~Ny ~4about N automatic ticks
literaly 0one value
uniony 0,50,100several values
x 2026-03-15,2026-11-01explicit dates
x equities,bondsnamed categories
rangey 50:90automatic ticks within 50–90
range + stepy 42:96:2542, 67, 92 — explicit start anchors the phase
y ::25step-25 multiples across the whole domain
x 2024-01:2026-07:qquarterly boundaries between two dates
x ::qquarterly across the whole range
y ::*10multiplicative: 1, 10, 100, … (from the domain start)
bare stepx qshorthand for ::q

Steps are numbers on value axes, frequencies on time axes (s, min, h, d, w, m, q, y, with multiples like 15min, 6h, 2w, and 2q, and anchors like y@oct for fiscal years or w@mon for week start), and integer positions on categorical axes (0:_:5 = every 5th category; negatives count from the end, so 0:_:5,-1 adds the last).

sp: controls the spacing independently of the ticks. Use the matching period spacing when each second, minute, hour, or week should occupy one equal-width slot (including empty periods between data points):

x s f:%H:%M:%S sp:s
x min f:%H:%M sp:min
x h f:"%b %d %H:%M" sp:h
x w f:"%b %d" sp:w

Value tokens stand in for data-derived values anywhere a value can appear: i (min), x (max), e (median), a (mean), f (first row), l (last row), _ (contextual endpoint). y i,x labels only the data extremes.

Filters select from your data, not from the calendar. A monthly filter over business-day data snaps to real rows and never emits a weekend tick; periods with no data emit nothing.

Period selections partition the domain; each boundary snaps to a real row when its period contains one. Dataless periods inside the data still emit nothing (a daily filter never ticks a weekend), but periods entirely outside the data extent emit their calendar boundaries — so x 2000:2030 ::5y f:%Y stays labelled across an axis far wider than the data. Range amends pair with this: + x i:x tx:b emphasizes the labels where the data actually lives, and x x:_ fill:gray-100 shades from the data max to the domain end.

Note: f and l cannot start a range — f:…/l:… read as the format and title properties. Use i:x (data min to max), which selects the same values on a sorted axis.

Line-local unless marked axis-level (last one wins across the axis). Prefixes name the thing (t tick, tl tick label, d domain, l the axis's own label); suffixes name the property (size, width, color, dash-array, position, format, overflow, rotation, backing, extent). c: and tx: are generic: they style whatever the line draws.

KeyValuesMeaning
an:value, token, edge, rail(value)Anchor: where the lane sits, as a value in the cross axis. an:0 = the zero line (x default), an:100 = rebased charts, an:e = the median. The rail form translates through a named rail's scale — x y(0) dw:2 is an explicit zero rule, an:y2(32) anchors in a reading rail's units; bare y(…) in a filter position is shorthand for an:.
af:auto a 0Tick label stagger policy — the labels grammar's lane key: af:a forces the alternate two-row stagger (tlo:a is the compact alias, same wire), af:0 turns a standing stagger off, auto is the default ladder.
att:start endWhich real datum represents a period boundary: Jan 1 (start) or Dec 31 (end).
bp:inner[,outer[,outer-end]]axis. Band spacing on ordinal scales (categories, or time with sp:index): the gap between bands/groups, then outer padding at the ends. Values are % of the step or bare px; one outer value is symmetric, two are start,end (bp:10%,0,25% is flush left, padded right). Bar defaults: 7%,20%.
bpi:valueaxis. Within-group gap for grouped bars — the spacing between one group's members. % of the group slot or px: bpi:25%, bpi:2px.
c:color[,color]Base color for whatever the line draws (labels; the fallback for tc:/dc:). Optional dark-mode variant after the comma.
da:px listDomain dash array: da:2,2. (Tick dashes are ta:.)
dc:color[,color]Domain line color for this side; falls back to c:.
de:window dataDomain line extent: plot edge to edge, or first to last datum.
dir:revaxis. Reverse the domain (rank 1 at the top).
ds:px or %, signedDomain edge-tick size: end caps at the two ends of the domain span, sign and % form as ts: (ds:-100% spans the plot). Caps render whenever ds: is set — no domain line required. Caps are domain furniture: they grow the margin when needed but never push the label rows — labels stay attached to the ticks.
dw:pxDomain line width for this side.
fill:color[,color]Makes the line a shaded band across the plot (see Bands).
l:quoted textaxis. The axis's own label — the title.
lp:start mid end topaxis. Axis label placement. top (y default) is the horizontal unit line above the tick labels.
mb:signed px or emOffset: extra space below what this lane draws.
ml:signed px or emOffset: extra space left of what this lane draws. On an x label lane it nudges the labels along the axis (right); mr: nudges left.
mr:signed px or emOffset: extra space right of what this lane draws.
mt:signed px or emOffset: extra space above what this lane draws — on a label lane it moves the labels only (tick padding); on a mark or line lane it moves the marks or line. On a y label lane mt:/mb: nudge the labels along the axis. Negative pulls closer.
p:px or %axis. Inner padding, both ends of this line's family — x p:8 is pl:8 pr:8, y p:8 is pt:8 pb:8.
pl: pr:px (default) or % of the spanaxis. Inner padding of the x scale's left/right end: the numerical mapping insets, so marks AND ticks move in together while the frame and domain lines stay put (m* is the furniture tool). An unstated end already reserves whatever the marks at that end need — the radius, its outline, and a pixel of daylight — so a scatter point on the domain edge keeps its whole dot; write a value to override that side. Category x keeps bp:; under sp: spacing the padding insets the whole slot span and bp: composes within it.
pt: pb:px (default) or %axis. Same for the y scale's top/bottom end, including the mark-extent default.
s:col-slicerrail. Claim series onto this rail's scale: y2 e:r s:-1 0:_ plots the last series against its own right-hand axis. Column vocabulary from the color grammar (indices, ranges, lists, headers); last claim wins, later rails steal, unclaimed series stay on the primary rail. On the x family a claim reads the series slots instead: x s:: f:auto ts:6 dw:1 ticks every band's slot centers with the series headers — the natural axis for a single-band grouped chart, such as a by 0 facet panel. Stacked series share a slot and tick once; scalar x has no slots and diagnoses.
sc:lin logaxis. Scale transform.
sp:time index freqaxis. Time spacing. time (default): calendar-continuous. index: one step per ROW — trading-day charts lose their weekend gaps. A frequency (sp:s, sp:min, sp:h, sp:d, sp:w, sp:m, sp:q, sp:y) is period-normalized: every period one equal-width slot, data positioned within it — months render the same width regardless of day counts, and a lone point a year out still sits twelve slots away. This is also the expected x domain used by marks na:: index expects only observed/shared rows, period modes provide exact calendar slots, and continuous time derives the regular observation cadence. Both ordinal modes put bars on band geometry, so bp:/bpi: work on time axes.
ta:px listTick dash array: ta:1,3 for a dotted grid.
tc:color[,color]Tick/mark color; falls back to c:.
lbc:auto, 0, colorTick-label background color (tlb: remains an alias). Tick labels, including tls:in, are bare by default; a value explicitly opts into the plate.
tlf:format, autoTick label format (alias: f:, the everyday shorthand); presence makes the line a label lane. Number specs (,.0f, +,.1f, .0%, .2s), strftime plus the novem date tokens (%o ordinal day — "1st", "22nd"; %oq/%ow ordinal quarter and ISO week; %q quarter; %fy fiscal year), and templates like f:"{,.0f}bn".
tlo:e t w o a rTick label overflow: ellipsis, truncate, wrap to the band, overflow, alternate across two staggered rows (af:a is the canonical stagger spelling; tlo:a stays the compact alias), or r — auto-rotate on overlap: labels stay horizontal while they fit and rotate to the smallest angle (30/45/60/90°) that does.
tlp:start mid endTick label position along the domain — which side of its mark or span the text sits. On a y lane this is vertical: tlp:end is "just above the gridline".
tlr:degreesTick label rotation, −90 to 90 (also chosen automatically by tlo:r). The attachment reuses tlp:start attaches the reading start at the tick and grows down-right (the auto-rotate default), end grows up-right into the tick, mid centers on it; without tlp: the sign controls direction.
tls:in out[,px]Tick label side across the axis: outside the plot (default) or inside the plot area.
tp:on betweenMarks on the datum, or at span/band boundaries.
ts:px or %, signedTick size. Marks exist only when ts: is declared — a bare label lane draws labels and nothing else. Positive grows away from the plot, negative grows into it; ts:-100% is a full-bleed gridline.
tw:pxTick stroke width.
tx:style/size/family, any orderText style for whatever the line draws: style chars rusbin or a weight 100900, named sizes xxs7xl or px, family mono/sans/serif — comma-separated tokens, each optional, any order.
sub:25 2 5 0Sub-decade log minors — the conventional 2s/5s furniture in one key (25 = both families); meaningful on sc:log lanes.
z:over underPaint a gridline lane above the data (grid-over-bars styles) or below it; under spells the default explicitly. On a cut lane an explicit z:under scopes the holes to bands and the under-grid (see Cut lanes).

Labels have two positioning dimensions. tlp: moves text along the domain; tls: moves it across the axis:

y   ::20  ts:-100% c:gray-200          -- gridlines
y2  e:r ::20  f:,.0f  ts:0  tls:in,3  tlp:end
--  labels on the right, inside the plot, sitting just above their line

Labels placed inside the plot get an automatic background plate so they stay legible over gridlines and bars (tlb:0 disables it). The first and last labels of a lane snap inward automatically when centring would hang past the plot edge; an explicit tlp: opts out.

Every line with a format and tls:out claims the next label row outward, so two label lanes make a two-row axis:

x m   f:%b
x y   f:%Y  tlp:start
--   Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
--   2026

An axis token with + edits labels inside the previous label lane's row instead of opening a new one. Where the base lane has a label at the selected value the amend overrides it; elsewhere it inserts. Within an amend filter, f and l mean the base lane's first and last label:

x   y  f:'%y  tlp:start
+ x f  f:%Y            -- first year in full: 2019 20 21 22

y   0:_:200  f:,.0f  ts:0
+ y l  f:"{$,.0f} billion"   -- unit on the top label only

+ x 2020,2024  tx:b    -- no format: just bold those two labels

Use : to match the base lane's complete rendered set, then a second positional token to slice those results. Negative positions count from the end:

y 160:240 ~5 f:$,.0f ts:0
+ y : -1 c:red-500 f:"{$,.0f} per share"

l: sets the axis title, lp: places it. lp:top (the y default) renders a horizontal unit line above the labels; start/mid/end run along the axis:

y  l:"Percent of GDP"  lp:top
x  l:"GDP per capita →"  lp:end

The title takes the labels grammar's letters as satellites — the same keys place a data label, a strip label and a title, so you learn them once:

y  l:"Streamflow"  lp:mid lr:-90      -- the classic vertical y title
y  l:"Index"  lp:mid lr:-90 le:r      -- ...docked on the right edge
y  l:"per capita"  lp:end li:in       -- overlaid inside the plot
y  l:"Price"  le:tl li:in lm:5        -- inside the top-left corner
x  l:"Month"  le:t lm:6               -- above the plot, 6px off
keyvaluesmeaning
le:l r (y) / b t (x), corners tl tr bl brthe edge the title docks; a corner fuses in the along-position, spelled with the axis's own lp: (le:tl on y is le:l lp:end); lp:top is a position, not an edge, and keeps its spot
li:in outinside the plot area (overlay, claims no margin) or outside the furniture
lm:px or %, pair across[,along]the offset from the default spot; unsigned is exact, explicit +/- adjusts the zero default
lml: lmr: lmt: lmb:pxper-side margins on the title box — unsigned exact, +/- relative to zero; they compose with lm:
lmx: lmy: lma:pxside bundles under the same absolute/relative rule; a later single side overrides its half
lo:e t w o h f, full wordsoverflow vs the axis span: ellipsis, truncate, wrap, overflow, hide, flip
lw: lwi: lwx:pxthe size trio on the title (CSS clamp, min beats max): lwx:/lw: cap the title below the axis span, with lo: deciding how the excess dies; lwi: floors it
lr:degrees −90..90rotation; lr:-90 reserves one line of margin instead of the text width
lb:auto, 0, colora legibility plate behind the title

A gridline is a tick that crosses the plot: ts:-100%. When two lines of the same axis put a mark at the same value, the later line's mark replaces the earlier one — restyle a single gridline by overpainting it:

y ::25  ts:-100%  c:gray-200
y 0     ts:-100%  c:gray-500     -- the zero line wins

Paint order is fixed: bands, then gridlines, then your data, then z:over gridlines, then ticks, domain lines and the frame, then labels and titles.

fill: shades the filtered interval(s) of the domain across the plot:

x 2020-02:2020-04,2022-06:2022-10  fill:gray-100,gray-800   -- two bands
y 2:3        fill:green-100,green-900                       -- a target range
x ::10y      fill:gray-100                                  -- alternating decades

Explicit ranges shade exactly those spans; stepped filters shade alternating periods (zebra). Bands paint below everything and never widen the window.

An empty axis file behaves as this implicit program — a sketch, not a paste-able file: ~auto stands in for the automatically chosen tick count, whereas in a real file ~ always takes a number (~5):

x :      f:auto  an:0     ts:6  dw:1
y ~auto  f:auto  an:edge  ts:6  dw:1

Touch an axis, own it: your first line for an axis replaces that axis's defaults entirely — x m f:%b gives month labels and nothing else on x; add dw:1 back if you want the domain line. Other axes keep their defaults. f:auto keeps the automatic format while you change placement or style.

FT-style index chart:

y   60:140 ::20      ts:-100%  c:#ccc1b7
y   100              ts:-100%  c:#66605c        -- index base line
y2  e:r ::20        f:,.0f  ts:0  tls:in,3  tlp:end
y2  l:"Index, 2019 = 100"  lp:top
x   q                f:%b  ts:3
x   y                f:'%y  tlp:start
+ x f                f:%Y
x   2020-02:2020-04  fill:#e8dcd2

Fiscal-year briefing chart:

y   -8:_ ::4   f:,.0f  ts:0  tx:600,2xl
y   0          ts:-100%  c:inverse
y   l:"Percent of GDP"  lp:top
x   2000:2036:4y@oct  f:%fy  tx:600,2xl
x   2026-10    ts:-100%  ta:6,3  c:gray-500
x   2026-10:_  fill:gray-100

Extremes-only minimal axis:

y  i,x   f:,.1f  ts:4  dw:1  de:data
x  f,l   f:"%b %Y"  ts:0

Log decades:

y  1:_ ::*10  f:,.0f  sc:log

Rankings, best on top:

y  1:10 ::1  f:,.0f  dir:rev

Explicit plot margins live in their own file, config/margins — see the

margins page. What an axis author needs to know: an explicit margin is exact and doubles as the wrap budget for tlo:w labels; unstated sides are auto, the measured autocompute.