> ## Documentation Index
> Fetch the complete documentation index at: https://docs.graphy.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Styling

Everything the chart draws — marks, grid lines, tick marks, the panel border, the graph background,
axis and tick and data-label type — is painted from a **stylesheet** on the spec. You add one by
piping a `styles(...)` part in, the same way you pipe `config(...)`.

```tsx theme={null}
import {
  createSpec,
  pipe,
  geom,
  scale,
  styles,
  style,
  token,
} from '@graphysdk/react';

const spec = pipe(
  createSpec({ x: 'month', y: 'sales' }),
  geom.bar(),
  scale.x(),
  scale.y(),
  styles({
    tokens: { brand: { light: '#0B5FFF', dark: '#6AA1FF' } },
    defaults: [style.geom({ fill: token('brand'), stroke: token('brand') })],
    overrides: [
      style.geom(
        { fill: '#E5484D', stroke: '#E5484D' },
        { where: { variable: 'sales', lt: 0 } }
      ),
    ],
  })
);
```

Because it lives on the spec, a stylesheet is serializable and travels with the chart — the same as
mappings, scales and config.

<Note>
  Theme tokens (the built-in light/dark chrome, selected by `colorScheme`) are a
  separate surface covering the HTML chrome *around* the plot. See
  [Theming](/sdk-next/rendering/theming).
</Note>

## The four keys

<ParamField path="defaults" type="StyleRule[]">
  Applied where no mapped aesthetic decided the value — the look when nothing
  else speaks.
</ParamField>

<ParamField path="overrides" type="StyleRule[]">
  Applied over whatever a mapping decided.
</ParamField>

<ParamField path="tokens" type="Record<string, StyleTokenValue>">
  Named colours that entries reference with `token('name')`.
</ParamField>

<ParamField path="extends" type="Stylesheet[]">
  Composes other stylesheets underneath this one. Tokens merge name by name,
  lists concatenate, later wins. This is how a house style ships as a reusable
  preset.
</ParamField>

Piping several `styles(...)` parts stacks them in order, each sitting above everything piped before
it. Within a single list, order is specificity: the last matching entry that declares a property wins.

## How a value is resolved

Each property resolves through three tiers, in order:

| Tier | Source |
| - | - |
| **override** | a `overrides` entry whose conditions match |
| **data** | the encoding — a mapped aesthetic (`color`, `size`, `alpha`, …) through its scale |
| **default** | a `defaults` entry, with the engine's built-in stylesheet behind it |

The practical consequence: **`defaults` never fight your mappings, and `overrides` always do.** To
recolour a series that is mapped to `color`, you need an `overrides` entry — a `defaults` entry loses
to the scale.

Entries scoped to a state (`{ state: 'hovered' | 'dimmed' }`) sit above the whole stateless cascade.

## Targets

Call `style.<target>(declarations, options?)`. Geom targets accept conditions; chrome and annotation
targets are chart-scoped and take declarations only.

| Target | Partitions | Declarations |
| - | - | - |
| `style.geom` | `.bar` `.line` `.area` `.point` `.rule` `.tile`, plus `{ layer }` | `fill`, `stroke`, `alpha`, `fillAlpha`, `strokeAlpha`, `saturation`, `blur`, `brightness`, `contrast`, `shadow`, `blendMode` (a bar, area, point or tile fills; a line or rule strokes) |
| `style.geom.bar` | | ↑ plus `cornerRadius`, `stroke`, `strokeWidth` |
| `style.geom.line` | | ↑ plus `strokeWidth`, `dashArray`, `lineCap`, `lineJoin`, `fillAlpha` |
| `style.geom.area` | | ↑ plus `strokeWidth`, `dashArray`, `lineCap`, `lineJoin`, `strokeAlpha` |
| `style.geom.point` | | ↑ plus `size`, `symbol`, `stroke`, `strokeWidth` |
| `style.geom.rule` | `.label` | `stroke`, `strokeAlpha`, `strokeWidth`, `dashArray`, `lineCap`, `lineJoin`, `shadow`, `blendMode` |
| `style.geom.rule.label` | | `fontFamily`, `fontSize`, `fontWeight`, `lineHeight`, `textColor` |
| `style.geom.tile` | | ↑ plus `cornerRadius`, `stroke`, `strokeWidth` |
| `style.gridLine` | `.x` `.y` | `stroke`, `strokeWidth`, `dashArray` |
| `style.tickLine` | `.x` `.y` | `stroke`, `strokeWidth`, `dashArray`, `length` |
| `style.axisLabel` | `.x` `.y`; `.top` `.right` `.bottom` `.left` | `fontFamily`, `fontSize`, `fontWeight`, `fontStyle`, `letterSpacing`, `textTransform`, `textDecoration`, `textOutlineColor`, `textOutlineWidth`, `lineHeight`, `textColor`, `margin` |
| `style.tickLabel` | `.x` `.y`; `.top` `.right` `.bottom` `.left` | ↑ plus `offset`, `margin` |
| `style.dataLabel` | `.observation` / `.category`, each `.inside` `.outside`; `.aggregate` | ↑ plus `paddingInline`, `paddingBlock`, `fill`, `stroke`, `strokeWidth`, `cornerRadius`, `shadow`, `alpha` |
| `style.panelBorder` | `.top` `.right` `.bottom` `.left` | `stroke`, `strokeWidth`, `dashArray`; `cornerRadius` on the bare builder |
| `style.graph` | | `fill`, `stroke`, `strokeWidth`, `cornerRadius`, `shadow`, `alpha`, `fontFamily`, `fontSize`, `fontWeight`, `lineHeight`, `textColor`, `textScale`, `padding`, `blendMode` |
| `style.heading` | `.h1` `.h2` | `fontFamily`, `fontSize`, `fontWeight`, `fontStyle`, `letterSpacing`, `textTransform`, `textDecoration`, `textOutlineColor`, `textOutlineWidth`, `lineHeight`, `textColor` |
| `style.caption` | | ↑ |
| `style.source` | `.link` | ↑ |
| `style.header` | | `margin` only |
| `style.footer` | | `margin` only |
| `style.hoverGuide` | | `stroke` (the crosshair or spoke), `fill` (the band or wedge) |
| `style.editOutline` | `.hover` `.selected` | `stroke`, `strokeWidth`, `offset`, `cornerRadius` on the outline drawn in `point-and-edit` mode: `.hover` around what a click would select, `.selected` around what is selected |
| `style.headline` | | `gap`, `margin` |
| `style.headlineItem` | `.number` / `.number.center`, `.caption`, `.label`, `.swatch`, `.trend` plus `.up` `.down` `.flat` | type on text parts; `size` on swatch; `.trend` colours the reference text, `.up` `.down` `.flat` the percentage |
| `style.legend` | `.popover`; `.top` `.right` `.bottom` `.left` | `gap`, `margin` on a bare or edge entry; `focusStroke` on a bare entry, the overflow button's focus outline; `.popover`: `fill`, `stroke`, `strokeWidth`, `cornerRadius`, `paddingInline`, `paddingBlock`, `shadow`, `alpha` |
| `style.legendItem` | `.swatch` | type plus `paddingInline`, `paddingBlock`, `fill`, `stroke`, `strokeWidth`, `cornerRadius`, `shadow`, `alpha`; `size` and `strokeWidth` on the swatch |
| `style.directLabel` | | type plus `strokeWidth`, `dashArray` |
| `style.tooltip` | `.heading` `.label` `.value` `.primaryRow` | box: `fill`, `stroke`, `strokeWidth`, `cornerRadius`, `paddingInline`, `paddingBlock`, `gap`, `shadow`, `alpha`; `gap` is the padding above and below each row, default 4; type on the text parts; `fill` on the primary row |
| `style.annotation` | `.shape` `.arrow` `.differenceArrow` `.text` `.image` `.pinnedNumber` `.comment`, plus `{ annotation: id }` | `fill`, `alpha` |
| `style.annotation.shape` | | ↑ plus `stroke`, `strokeWidth` |
| `style.annotation.arrow` | `.outline` | `stroke`, `strokeWidth`, `dashArray`, `stroke`, `strokeWidth`, `shadow` |
| `style.annotation.differenceArrow` | `.label` | `stroke`, `strokeWidth` |
| `style.annotation.differenceArrow.label` | | the `dataLabel` words: type, paddings, `fill`, `stroke`, `strokeWidth`, `cornerRadius` |
| `style.annotation.text` | | the `dataLabel` words plus `fillAlpha` |
| `style.annotation.image` | | `alpha`, `cornerRadius`, `shadow` |
| `style.annotation.pinnedNumber` / `.comment` | `.label` | `fill`, `size`, `stroke`, `strokeWidth`, `shadow` |
| their `.label` part | | the `dataLabel` words plus `shadow` |

Three things worth knowing up front:

* A bar's `cornerRadius` is a token, `'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full'`, defaulting
  to `'sm'`, or a number of pixels: `style.geom.bar({ cornerRadius: 3 })`. A box rounds with
  `cornerRadius` in pixels, one number or one per corner: `dataLabel`, the tooltip, a legend pill, a
  text or image annotation and a callout label take
  `style.tooltip({ cornerRadius: { topLeft: 0, topRight: 8, bottomRight: 8, bottomLeft: 8 } })`;
  `panelBorder`, `style.graph` and the edit outline take one number.

* A box is painted with `fill`, and its border with `stroke` and `strokeWidth`: the graph frame, the
  tooltip, a data label, a legend pill, a text annotation and a callout label all take the same words.

* `style.graph({ fontFamily })` is the chart's base family: every text region without a family of its
  own follows it, in paint and in measurement. `fontSize`, `fontWeight`, `lineHeight` and `textColor`
  on `graph` are the same type words; content-region targets declare their own.

* `style.heading`, `style.heading.h1` and `style.heading.h2` are the title and the line under it.
  A bare heading entry addresses both levels. Colour, family and size on individual runs paint over
  this base type. `style.caption` and `style.source` are the footer type; `style.source.link` is the URL.

* `style.graph({ textScale })` multiplies every text size at resolve time. `1` is the built-in; a
  preset can set a house scale through `extends`. `gap`, `offset`, `margin`, padding and stroke widths do not
  scale.

* `style.graph({ padding })` is the outer frame, in pixels, and does not follow `textScale`. A number
  applies to every side; an object sets sides, and omitted edges keep the builtin 24.

* `margin` is air around a chrome target that occupies a layout slot (`header`, `footer`, `legend`, `headline`, `axisLabel`, `tickLabel`).
  A number sets every side; omitted object sides stay absent so they do not overwrite earlier rules. `0` closes a side. `gap` is items inside a chrome target. See [Layout](/sdk-next/config/layout).
  Builtin tick `offset` is 10, left and right 14.

* Every geom takes a `shadow` (`'none'` or `{ offsetX, offsetY, blur, color }`) and a `blendMode`
  (`'normal' | 'multiply' | 'screen' | 'overlay' | 'darken' | 'lighten'`); so does `style.graph` for
  the frame. A line, an area and the polar kinds read both once per layer, from the first observation.
  The built-in `hovered` state lifts the geom under the pointer with a soft shadow, bars with a larger
  one; `style.geom.point({ shadow: 'none' }, { state: 'hovered' })` drops it.

* Every text target takes the same type words: `fontFamily`, `fontSize`, `fontWeight`, `fontStyle`
  (`'normal' | 'italic' | 'oblique'`), `letterSpacing` (pixels, negative allowed), `textTransform`
  (`'none' | 'uppercase' | 'lowercase' | 'capitalize'`), `textDecoration` (`'none' | 'underline' |
  'line-through'`), `textOutlineColor` and `textOutlineWidth` (the width visible outside the glyphs),
  `textShadow` (the geom `shadow` value), `lineHeight` and `textColor`. Layout measures the string with
  its case and letter spacing applied, so a label reserves the space its glyphs take. Tick labels carry a
  built-in outline in the graph background, so a label
  stays legible where it crosses a grid line: `style.tickLabel({ textOutlineWidth: 0 })` removes it.

* A line, an area and a rule take `lineCap` (`'butt' | 'round' | 'square'`) and `lineJoin`
  (`'miter' | 'round' | 'bevel'`). Neither has a built-in: a path keeps its rounded caps until an
  entry says otherwise.

* Every geom takes `blur` (pixels) and `brightness`, `contrast` and `saturation` (multiples, `1`
  unchanged). A filter declared without a state draws on the geom itself. A filter declared with
  `{ state: 'dimmed' }` draws on the whole layer over the geoms already painted:
  `style.geom({ blur: 1, saturation: 0.3 }, { state: 'dimmed' })` blurs and greys a dimmed layer.

* `fill` is a paint, not only a colour. Beside a colour, a token or a light-dark pair it takes:

  * a **gradient**: `{ gradient: 'linear', angle?, stops: [{ offset, color }, …] }` or `{ gradient: 'radial', stops }`, with at least two stops;
  * a **pattern**: `{ pattern: 'diagonal' | 'dots' | 'crosshatch' | 'lines', color, background?, size? }`, repeating every `size` screen pixels. `lines` draws one level line across the top third of each tile;
  * an **image**: `{ image, fit?, alpha?, size?, fallback }`, a data URI only. `fit: 'tile'`, the default, repeats it every `size` screen pixels wide at its own proportions; `fit: 'stretch'` fills the shape with one copy. `fallback` always paints behind the image; `alpha` affects only the image.

  Every colour inside takes tokens. `stroke` stays a colour.

  Bars, tiles, polar bars, polar areas, the graph's frame and HTML boxes draw the paint. The swatches of bars, tiles and polar bars draw it too when it covers the swatch's whole colour group: declared for the layer, or by a `where` that reads only the colour variable. A `where` on any other variable picks out observations, so it stays off the swatch.

  Everywhere else only one colour can be drawn — a contrast pick, a hover marker, a line's wash, a cartesian area, a point, any other swatch — so it is picked from the paint: a gradient's first stop, a pattern's colour, an image's fallback.

* Every geom and `style.graph` take an `overlay`: a paint drawn over what the target already drew. It takes what `fill` takes, a list of those with the first on top, or `'none'`:

  ```ts theme={null}
  const scanlines = { pattern: 'lines', color: 'rgba(0, 0, 0, 0.3)', size: 3 };
  const grain = { image: grainDataUri, alpha: 0.2, fallback: 'transparent' };

  styles({
    defaults: [
      style.graph({ overlay: scanlines }), // over the whole graph at full strength, its text included
      style.geom.bar({ overlay: scanlines }), // over each bar, whatever colour it is mapped to
      style.geom.point({ overlay: grain }), // a texture over each point, its colour showing through
    ],
  });
  ```

  Use `fallback: 'transparent'` to keep the chart's colours visible beneath an image overlay. An opaque fallback covers them even when the image's `alpha` is low.

  A geom's overlay is drawn over everything its layer painted and nowhere else, a geom from a package included. It is as strong as the paint under it, so over a bar whose `fillAlpha` is `0.3` it is drawn at `0.3` too. It is drawn for the layer as one: a `where` entry is decided by the layer's first observation, which the compiler warns about, and `{ state: 'hovered' }` sets the overlay of the shape under the pointer. A marker drawn on the layer for a hover elsewhere keeps the layer's resting overlay. The graph's overlay does not reach the tooltip, which is a box of its own.

  A geom's overlay is painted through a mask of the layer, so the browser draws that layer twice. On a layer with many thousands of shapes, the graph overlay costs less than a geom overlay.

  Geom overlays support SVG renderers only. A portal-rendered geom ignores a non-empty overlay and reports `INVALID_STYLE_RULE`. An `overlay` declared with `{ state: 'dimmed' }` also reports `INVALID_STYLE_RULE` and is ignored; other properties in the entry still apply, and the resting overlay fades with the layer.

* Every box takes a `shadow` and an `alpha` beside its `fill`: a data label, the tooltip, a legend
  pill and the legend popover, a text or image annotation, a callout or difference-arrow label, and
  `style.graph` for the frame. `style.tooltip({ shadow: { offsetX: 0, offsetY: 4, blur: 12, color:
  'rgba(0, 0, 0, 0.2)' }, alpha: 0.95 })` lifts and fades the popover; the frame's `alpha` thins its
  background alone, so the plot keeps its opacity.

* A point takes a `symbol` (`'circle' | 'square' | 'diamond' | 'triangle' | 'cross' | 'star' | 'wye'`), built in
  `circle`. Every symbol covers the area of the circle its `size` names, so
  `style.geom.point({ symbol: 'diamond' })` keeps the layer's weight; the hover marker, the highlight
  dot and the swatch draw the same symbol.

* **Hide a panel-border edge with `strokeWidth: 0`.**

* A shape annotation's border wears its `fill` unless `stroke` is declared; `strokeWidth: 0` draws
  none, and `fill: 'transparent'` leaves a stroke-only outline. `{ annotation: id }` addresses one
  annotation.

* An arrow annotation's sticker look is its own part, `style.annotation.arrow.outline({ stroke, strokeWidth,
  shadow })` — an outline around the line and a drop shadow; both are off by default.

* A difference arrow is two parts: the line (`strokeWidth` also sizes the route around the observations)
  and its label, `style.annotation.differenceArrow.label`. A colour no entry names falls to the series
  colour both ends share; the label's box border takes the arrow's colour unless `stroke` is declared.

* A text annotation's `fill` is its box; with none declared there is no box. The content's own
  marks paint over the base type. An image's `cornerRadius` clips its corners, in pixels.

* A pinned number and a comment are a marker dot plus the label bubble beside it (`.label`). A marker
  whose `fill` no entry names borrows its observation's colour; the swatch inside a pinned-number
  bubble always keeps it.

* Stack totals (`.aggregate`) always sit outside the mark, so they take no `.inside` / `.outside`.

* `style.headlineItem` is not callable — pick a part. `.number.center` is the donut-hole figure; without
  it the hole uses `.number`. `gap` on `style.headline` and `style.legend` is pixels and does not
  follow `textScale`. A bare `style.legend` entry is `gap`, `margin`, and `focusStroke`; the overflow popover box is
  `style.legend.popover` (`fill`, `stroke`, `strokeWidth`, `cornerRadius`, `paddingInline`,
  `paddingBlock`, `shadow`).

* Swatch opacity and bar corner radius follow the layer geom, including size-legend bubbles;
  `style.legendItem.swatch` and `style.headlineItem.swatch` only size the square box.

## Colours

Any colour-valued property takes one of three forms:

```tsx theme={null}
style.geom.bar({ fill: '#E5484D' }); // literal
style.geom.bar({ fill: { light: '#E5484D', dark: '#FF6369' } }); // one per scheme
style.geom.bar({ fill: token('alert') }); // token reference
```

`{ light, dark }` and `token(...)` resolve against the provider's
[`colorScheme`](/sdk-next/rendering/theming). Prefer them to literals — a literal looks the same in
both schemes.

## Conditions

Geom entries take a `where` predicate (the same language as [highlights](/sdk-next/emphasis/highlights)),
a `state`, and a `layer`:

```tsx theme={null}
styles({
  overrides: [
    style.geom(
      { fill: token('alert'), stroke: token('alert') },
      { where: { variable: 'sales', lt: 0 } }
    ),
    style.geom.line({ strokeWidth: 4 }, { layer: 'total' }),
    style.geom({ alpha: 0.15 }, { state: 'dimmed' }),
  ],
});
```

`layer` scopes an entry to one authored layer `id` — the way to style a single series of a
[combo chart](/sdk-next/graph-types/combo) without touching the others.

Every entry, chrome included, takes a `coord`, which keeps it to graphs drawn in that coordinate
system. This stylesheet rules off the left edge of a bar graph and leaves a pie without one:

```tsx theme={null}
styles({
  defaults: [
    style.panelBorder.left({ strokeWidth: 2, dashArray: [] }),
    style.panelBorder.left({ strokeWidth: 0 }, { coord: 'polar' }),
  ],
});
```

`coord` is `'cartesian'` or `'polar'`; a flipped graph is cartesian.

## Re-skinning through tokens

The engine's built-in stylesheet sits behind every chart, and its defaults are written in terms of
tokens. Redefining a built-in token name restyles the default it backs, with no entries at all:

```tsx theme={null}
styles({
  tokens: {
    geom: '#1D1D1B',
    gridLine: '#E9E9E9',
    graphBackground: { light: '#FFFFFF', dark: '#141414' },
  },
});
```

| Token | Backs |
| - | - |
| `geom` | every mark's fill when nothing is mapped to `color` |
| `geomBorder` | bar outlines |
| `ruleLine` | reference, goal and average lines |
| `hoverAffordance` | point outlines, and the hovered outline on bars and points |
| `hoverGuideLine` | the hover guide's crosshair line |
| `hoverGuideFill` | the hover guide's band fill |
| `editOutlineHover` | the `point-and-edit` outline around what a click would select |
| `editOutlineSelected` | the `point-and-edit` outline around what is selected |
| `legendFocus` | the legend overflow button's focus outline |
| `gridLine` | grid lines and the panel border |
| `graphBackground` | the graph plate, and the box behind a difference-arrow label |
| `graphBorder` | the graph frame |
| `textPrimary` | axis labels, data labels, direct labels, tooltip heading and labels, annotation text, headline numbers |
| `textSecondary` | tick labels, legend items, tooltip values, headline captions |
| `tooltipBackground` | the tooltip box, and a callout's label and marker outline |
| `tooltipBorder` | the tooltip box border, and a callout label's border |
| `tooltipPrimaryRow` | the highlighted tooltip row |
| `annotationShape` | shape annotations |
| `annotationArrow` | arrow and difference-arrow annotations |
| `trendPositive` | headline trend up |
| `trendNegative` | headline trend down |
| `trendNeutral` | headline trend flat |

The built-in defaults these produce: bars `cornerRadius: 'sm'` with a 1px border; lines and areas
`strokeWidth: 2`, `dashArray: []`; areas `alpha: 0.3`; points `size: 8`; rules dashed; the y grid
lines dashed; the panel border dashed with `cornerRadius: 6`; the graph frame with `cornerRadius: 8`;
tick labels offset 10px; the `dimmed` state `alpha: 0.4`.

## Presets with `extends`

`extends` composes stylesheets, which is how a house style becomes reusable:

```tsx theme={null}
const houseStyle = {
  tokens: { geom: '#1D1D1B', gridLine: '#C9C6BE' },
  defaults: [
    style.gridLine({ dashArray: [] }),
    style.panelBorder({ strokeWidth: 0 }),
    style.tickLabel({ fontSize: 12 }),
  ],
};

const spec = pipe(
  createSpec({ x: 'month', y: 'sales' }),
  geom.bar(),
  scale.x(),
  scale.y(),
  styles({
    extends: [houseStyle],
    defaults: [style.geom.bar({ cornerRadius: 'full' })],
  })
);
```

## Two token namespaces

`textPrimary` and `textSecondary` name a stylesheet token **and** a theme token.
They are different values in different namespaces: the stylesheet tokens above drive the plot, while
the theme tokens of the same name drive the chrome around it.

## When an entry is invalid

An entry the engine can't use is reported as an `INVALID_STYLE_RULE` warning and skipped — the chart
still renders. Compile a spec headlessly to see warnings before wiring it into React.

## Custom geoms

A [custom geom renderer](/sdk-next/extending/geom-renderers) reads paint through the value accessors
on its render input (`getColor`, `getAlpha`, `getSize`, …), which expose the **data** tier. To resolve
the full cascade, read `styleReaders` on the render input (or `useGeomStyleReader(layer)`):

```tsx theme={null}
styleReaders.get('color', observation);
```

<Warning>The render input already carries the graph's colour scheme.</Warning>

A geom whose mark paints more than one thing declares each part, and the kit adds a builder for it — `kit.style.geom.lollipop.stem({ stroke: token('brand') })`. See [what the mark paints](/sdk-next/extending/custom-geoms#what-the-mark-paints).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.