---
id: "packages/active-grid/styling"
title: "Styling"
description: "The stylesheet, the --grid-* tokens, the cascade layer, and how a host brands the grid."
url: "/packages/active-grid/styling"
locale: "en"
revision: "7f9296ec0ed4183a"
---

# Styling

> [!NOTE]
> **The package's own guide**
>
> This guide is the package's `docs/guides/styling.md`, included unchanged from
> the package as it is in Foundation. Links to the package's internal design
> documents keep their text.

Active Grid is a styled standalone React widget. It ships its own stylesheet,
its own `--grid-*` tokens with a neutral light and dark baseline, and its own
internal primitives (buttons, menus, popovers, dialog, select, calendar).
It needs no Tailwind and no design system. A host brands it by redefining
tokens, never by out-specifying its rules. There is no JavaScript theme API.

## Loading the stylesheet

The stylesheet is a separate entry and is not imported by the package root:

```ts
import '@workspace/active-grid/styles.css';
```

All of it sits in the cascade layer `active-grid`. A layer's place in the
cascade is fixed where the layer first appears, so a host with layers of its
own declares its order **before** loading the grid, with the grid after its
reset and before its utilities:

```css
@layer theme, base, active-grid, components, utilities;
```

Then the grid's rules beat the host reset, and a utility class a host puts on
a grid element (through `className`, `cellClassName`, `rowClassRules`, ...)
beats the grid's rule. A host without layers imports the stylesheet as is; its
own unlayered rules then always win. The stylesheet is plain CSS: no
`@tailwind`, `@apply` or `@theme`, and no import outside the package
(`lint-portable-closure` guards both).

## Tokens

Three kinds, all declared in `src/styles/tokens.css`:

1. **Role tokens**, what a host maps: surfaces and text
   (`--grid-background`, `--grid-foreground`, `--grid-surface`,
   `--grid-surface-foreground`, `--grid-popover`,
   `--grid-popover-foreground`), emphasis (`--grid-muted`, `--grid-accent`,
   `--grid-primary`, `--grid-secondary`, `--grid-destructive`, each with its
   `-foreground`), lines (`--grid-border`, `--grid-separator`,
   `--grid-input`, `--grid-input-border`, `--grid-ring`), the status tones
   (`--grid-status-neutral|info|success|warning|danger`, each with a
   `-text` colour), the radius scale (`--grid-radius`, `-sm`, `-md`, `-lg`,
   `-xl`) and the chrome type sizes (`--grid-text-xs`, `-sm`, `-base`).
2. **Derived tokens**, the grid's own composition of the roles
   (`--grid-header-bg`, `--grid-column-separator-color`,
   `--grid-filter-trigger-*`, `--grid-range-outline`, `--grid-range-fill`,
   `--grid-scrollbar-thumb`, `--grid-popup-border`, `--grid-shadow-*`, ...).
   Override one for one grid through that grid's `className`; brand through
   the roles.
3. **Geometry tokens**, structure: heights, paddings, body and header font
   sizes, the scrollbar lane, the edge clearance and the z-order (listed
   below). Several mirror a JavaScript constant and must not be redefined.

### Mapping a host's design tokens

One unlayered rule, tokens only:

```css
:root,
.grid-root,
.grid-portal {
  --grid-surface: var(--card);
  --grid-foreground: var(--foreground);
  --grid-border: var(--border);
  --grid-primary: var(--primary);
  --grid-ring: var(--ring);
  --grid-status-success: var(--success);
  /* ... every role your design system has an answer for */
}
```

The monorepo's design system does exactly this in `@workspace/active-ui`'s
`grid-theme.css`, and nothing else; `data-grid-theme="baseline"` on an
element (or on `<html>`, popups included) switches that mapping off.

### Where tokens are declared, and why

Role and derived tokens are declared on `:root`, on the grid's outer element
`.grid-root`, and on every popup the grid renders (`.grid-portal`). A
`var()` inside a custom property resolves where the property is declared, so
declared on `:root` alone it would resolve against `<html>` and miss a theme
set lower in the tree (an accent class on `<body>`, a `.dark` scope on a
panel). Declared on `.grid-root`, each grid follows the theme it sits in; a
popup, rendered under `<body>`, follows the document's theme. A token
override on an ancestor therefore does not reach inside a grid: set it on
`.grid-root` (`.my-scope .grid-root { ... }`) or through the grid's
`className`.

### Geometry tokens

```css
:root {
  /* Grid dimensions */
  --grid-row-height: 42px;
  --grid-header-height: 48px;
  --grid-group-header-height: 32px;
  --grid-filter-bar-height: 36px;
  --grid-footer-height: 48px;
  --grid-cell-padding-x: 12px;
  --grid-cell-padding-y: 0.5rem;
  --grid-cell-line-height: 1.5;
  --grid-header-letter-spacing: 0.01em;
  --grid-body-font-size: 0.8125rem;
  --grid-transition-speed: 150ms;

  /* Pagination bar */
  --grid-pagination-height: 48px;
  --grid-pagination-font-size: 0.8125rem;
  --grid-pagination-padding-x: 1rem;

  /* Scrollbar. Two quantities, two tokens: `--grid-scrollbar-thickness` is what
     the grid ASKS the scrollbar to be, `--grid-scrollbar-width` is what the
     browser THEN RESERVED. The second is the stylesheet's starting value only;
     the grid overwrites it at runtime with the gutter it measured, and derives
     `--grid-edge-clearance` from that. Do not set either yourself — see
     "Scrollbar Styling" below. */
  --grid-scrollbar-thickness: 10px;
  --grid-scrollbar-width: 8px;

  /* Header, separators, resize handle, icons */
  --grid-header-font-size: 0.8125rem;
  --grid-header-font-weight: 600;
  --grid-column-separator-display: block;
  --grid-column-separator-height: 50%;
  --grid-column-separator-width: 1px;
  --grid-resize-handle-width: 2px;
  --grid-resize-handle-height: 50%;
  --grid-icon-size: 14px;

  /* Z-index architecture */
  --grid-z-base: 0;
  --grid-z-cell-pinned: 5;
  --grid-z-resize-handle: 20;
  --grid-z-row-pinned: 25;
  --grid-z-footer: 30;
  --grid-z-header: 40;
  --grid-z-header-pinned: 45;
  --grid-z-overlay: 50;
  --grid-z-popover: 50;
  --grid-z-drop-indicator: 60;
}
```

Under `@media (pointer: coarse)` rows, headers, the filter bar and the
pagination bar grow to a 44px tap target and the icons to 18px.

### Focus of filter and editor fields

Every field in the grid (the inline filter bar, the popover filter inputs and
selects, the filter-builder fields and the toolbar search) carries one focus
expression: a border at 50% of `--grid-ring`, no ring, no glow, no outline, in
light and dark. The popover and builder fields share the class
`grid-filter-field`; the inline filter bar follows
`--grid-filter-trigger-focus-border`. Overriding that token to a stronger
value, or adding a ring to a grid field, breaks the calibration;
`field-focus-contract.test.ts` guards the source. Buttons, cells and the grid
container are not fields and keep their own focus; cells and the container use
the same subdued expression (see [Focus and Selection](#focus-and-selection)).

### Density Adjustment

Create a compact grid by reducing row heights and padding:

```css
.compact-grid {
  --grid-row-height: 32px;
  --grid-header-height: 36px;
  --grid-cell-padding-x: 0.5rem;
  --grid-cell-padding-y: 0.25rem;
  --grid-body-font-size: 0.75rem;
}
```

```tsx
<ActiveGrid className="compact-grid" />
```

Create a spacious grid:

```css
.spacious-grid {
  --grid-row-height: 56px;
  --grid-header-height: 56px;
  --grid-cell-padding-x: 1.5rem;
}
```

## Token Comparison: Active Grid vs AG Grid Quartz

| Property | Active Grid Token | AG Grid Quartz Default | Active Grid Default |
| --- | --- | --- | --- |
| Header height | `--grid-header-height` | `48px` | `48px` |
| Row height | `--grid-row-height` | `42px` | `42px` |
| Cell horizontal padding | `--grid-cell-padding-x` | `16px` | `12px` |
| Cell vertical padding | `--grid-cell-padding-y` | *(derived)* | `8px (0.5rem)` |
| Footer height | `--grid-footer-height` | `48px` | `48px` |
| Pagination height | `--grid-pagination-height` | `48px` | `48px` |
| Pagination font size | `--grid-pagination-font-size` | `13px` | `13px (0.8125rem)` |
| Header background | `--grid-header-bg` | `#f8f8f8` | `var(--grid-surface)` |
| Header text color | `--grid-header-fg` | `rgba(255,255,255,0.9)` | `var(--grid-foreground)` |
| Header font weight | `--grid-header-font-weight` | `600` | `600` |
| Header font size | `--grid-header-font-size` | `14px` | `13px (0.8125rem)` |
| Header hover background | `--grid-header-hover-bg` | `rgba(255,255,255,0.05)` | *None (no hover effect)* |
| Header moving background | `--grid-header-moving-bg` | `rgba(255,255,255,0.1)` | `color-mix(foreground 10%)` |
| Column separator display | `--grid-column-separator-display` | `block` | `block` |
| Column separator color | `--grid-column-separator-color` | `rgba(255,255,255,0.1)` | `color-mix(--grid-border 70%)` |
| Column separator height | `--grid-column-separator-height` | `50%` | `50%` |
| Column separator width | `--grid-column-separator-width` | `1px` | `1px` |
| Resize handle color | `--grid-resize-handle-color` | `rgba(255,255,255,0.2)` | `var(--grid-border)` |
| Resize handle hover color | `--grid-resize-handle-hover-color` | `var(--primary)` | `var(--grid-primary)` |
| Resize handle width | `--grid-resize-handle-width` | `2px` | `2px` |
| Resize handle height | `--grid-resize-handle-height` | `50%` | `50%` |
| Icon size | `--grid-icon-size` | `14-16px` | `14px` |
| Sort icon color | `--grid-sort-icon-color` | `foreground` | `var(--grid-muted-foreground)` |
| Filter active icon color | `--grid-filter-active-icon-color` | `var(--primary)` | `var(--grid-primary)` |
| Menu icon color | `--grid-menu-icon-color` | `muted` | `var(--grid-muted-foreground)` |
| Filter trigger radius | `--grid-filter-trigger-radius` | *N/A* | `var(--grid-radius-sm)` |
| Filter trigger background | `--grid-filter-trigger-bg` | *N/A* | `color-mix(foreground 6%)` |
| Filter trigger border | `--grid-filter-trigger-border` | *N/A* | `color-mix(foreground 10%)` |
| Filter trigger hover bg | `--grid-filter-trigger-hover-bg` | *N/A* | `var(--grid-accent)` |
| Filter trigger focus border | `--grid-filter-trigger-focus-border` | *N/A* | `color-mix(--grid-ring 50%)` |
| Filter trigger focus bg | `--grid-filter-trigger-focus-bg` | *N/A* | `var(--grid-background)` |
| Cell font size | `--grid-body-font-size` | `14px` | `13px (0.8125rem)` |
| Border color | `var(--grid-border)` | `#babfc7` | Theme-dependent |
| Row hover | `var(--grid-accent)` | `rgba(33,150,243,.1)` | Theme-dependent |
| Selected row | `color-mix(--grid-primary 10%)` | `rgba(33,150,243,.2)` | Theme-dependent |
| Scrollbar thickness | `--grid-scrollbar-thickness` | `8px` | `10px` (lane, thumb as the house ScrollArea) |

## Theme Integration

The grid reads only its own role tokens. Which grid element paints with
which role:

| Grid element | Role token | Usage |
| --- | --- | --- |
| Grid container background | `--grid-surface` | Main grid background |
| Header background | `--grid-surface` | Header row and pinned header cells (via `--grid-header-bg`) |
| Header text | `--grid-foreground` | Header text colour (via `--grid-header-fg`) |
| All borders | `--grid-border` | Row borders, cell dividers, container border, column separators |
| Row hover | `--grid-accent` | Background colour on row hover |
| Selected row | `--grid-primary` | Mixed at 10% for the selection background |
| Focus | `--grid-ring` | Active cell and field focus, at 50% |
| Secondary text | `--grid-muted-foreground` | Empty state, aggregation labels, sort icons |
| Popups | `--grid-popover` | Menus, popovers, dialog and select list (with `-foreground`) |
| Fields | `--grid-input`, `-border` | Filter fields, the toolbar search, checkboxes |
| Status cells | `--grid-status-<tone>` | `GridStatusTone` badges: tinted fill and border, `-text` colour |
| Destructive | `--grid-destructive` | Validation error borders, destructive menu items |
| Primary indicators | `--grid-primary` | Filter active icon, resize handle active state, selection |

A design system built on the common shadcn token names maps these one to one
(`--grid-surface: var(--card)`, `--grid-border: var(--border)`, ...).

## Z-index Architecture

All z-index values are controlled by CSS custom properties for predictable
stacking:

| Token | Default | Usage |
| --- | --- | --- |
| `--grid-z-base` | `0` | Default layer |
| `--grid-z-cell-pinned` | `5` | Pinned body cells (sticky) |
| `--grid-z-resize-handle` | `20` | Resize handle overlay |
| `--grid-z-row-pinned` | `25` | Pinned rows (top/bottom sticky) |
| `--grid-z-footer` | `30` | Aggregation footer (sticky bottom) |
| `--grid-z-header` | `40` | Header wrapper (sticky top) |
| `--grid-z-header-pinned` | `45` | Pinned header cells |
| `--grid-z-overlay` | `50` | Loading/error/refetching overlays |
| `--grid-z-popover` | `50` | Filter popovers |
| `--grid-z-drop-indicator` | `60` | Column reorder drop indicators |

Portalled UI (menus, popovers, tooltips) renders outside the grid DOM and
uses its own stacking context — it is not controlled by these tokens.

## Column Separators

Column separators are thin vertical lines between header and body cells.
They are enabled by default via `--grid-column-separator-display: block`.
Header cells use a `::before` pseudo-element; body cells with
`showColumnBorders` use a `::after` pseudo-element — both centered at 50%
height for a clean, AG Grid-like appearance.

Customize the appearance:

```css
.grid-root {
  --grid-column-separator-display: block; /* block | none */
  --grid-column-separator-color: color-mix(
    in oklch,
    var(--grid-border) 70%,
    transparent
  );
  --grid-column-separator-height: 50%; /* of cell height — AG Grid style */
  --grid-column-separator-width: 1px;
}
```

Disable header separators via settings:

```tsx
<ActiveGrid settings={{header: {showColumnSeparators: false}}} />
```

### Body/Header/Footer Column Borders

The `showColumnBorders` setting controls vertical borders between cells in
the body, header, and footer rows. It accepts three modes:

| Mode | Description |
| --- | --- |
| `'none'` (default) | No vertical separators |
| `'partial'` | 50% height, centered (AG Grid style) |
| `'full'` | Full-height separators, top to bottom |

`true` is an alias for `'partial'`, `false` for `'none'`
(backwards-compatible).

```tsx
<ActiveGrid settings={{showColumnBorders: 'partial'}} />
```

The filter row never shows column borders, regardless of this setting.

Separators are automatically hidden on the last column in each row, on
pinned-boundary columns (where pin shadows provide the visual separation),
and in the filter row.

## Header Hover & Moving

Header cells do **not** have a hover effect — this matches AG Grid's clean
appearance. During column drag, the source cell uses
`--grid-header-moving-bg`:

```css
.grid-root {
  --grid-header-moving-bg: color-mix(
    in oklch,
    var(--foreground) 10%,
    transparent
  );
}
```

The moving background uses `color-mix()` with `var(--foreground)` so it
adapts to light and dark themes automatically.

## Dark Mode

The baseline has a dark set under the `.dark` ancestor convention it shares
with active-charts: put `.dark` on `<html>` (or on a panel) and the grid's
role tokens switch. A host that maps its own tokens gets its own dark mode for
free, because its tokens switch themselves. No dark-mode configuration exists
on the grid.

Shadows use `color-mix()` with `var(--grid-foreground)` so they stay visible
on a dark surface:

```css
box-shadow: 4px 0 4px -2px
  color-mix(in oklch, var(--grid-foreground) 8%, transparent);
```

## Conditional Row Styling

Apply CSS classes to rows based on data:

```tsx
<ActiveGrid
  settings={{
    rowClassRules: {
      'bg-destructive/5': (data: Order) => data.status === 'overdue',
      'opacity-50': (data: Order) => data.status === 'cancelled',
      'font-medium': (data: Order) => data.isPriority,
    },
  }}
/>
```

Multiple rules can match simultaneously — all matching classes are applied.

## Conditional Cell Styling

### CSS Class Rules

Apply classes to individual cells:

```typescript
{
  accessorKey: 'balance',
  header: 'Balance',
  meta: {
    cellClassRules: {
      'text-destructive font-semibold': (value: number) => value < 0,
      'text-success': (value: number) => value > 100000,
    },
  },
}
```

### Inline Styles

Apply dynamic inline styles:

```typescript
{
  accessorKey: 'progress',
  header: 'Progress',
  meta: {
    cellStyle: (value: number) => ({
      backgroundColor: value >= 100
        ? 'hsl(var(--success) / 0.1)'
        : value >= 50
          ? 'hsl(var(--warning) / 0.1)'
          : undefined,
    }),
  },
}
```

## Striped Rows

Enable alternating row colors:

```tsx
<ActiveGrid settings={{stripedRows: true}} />
```

The stripe color uses `color-mix(in oklch, var(--muted) 40%, transparent)`
for a subtle effect that works in both light and dark modes.

Custom stripe styling:

```tsx
<ActiveGrid
  settings={{
    stripedRows: true,
    stripeClassName: 'bg-blue-50/30 dark:bg-blue-950/10',
  }}
/>
```

## Scrollbar Styling

The grid draws its own scrollbars. The scroll surface itself is an ordinary
native scroll container — the browser still does the scrolling — but the
scrollbar you see is a DOM element the grid renders, so it looks the same in
Chromium, Firefox and WebKit and has no start/end arrow buttons anywhere.

### Why not style the platform's scrollbar

Because it cannot be made to look the same twice. Chromium and WebKit let a page
replace the scrollbar through `::-webkit-scrollbar`; Firefox offers exactly two
properties, `scrollbar-width` and `scrollbar-color`, and no way to say lane,
inset, radius or hover; and an engine that implements both abandons the
pseudo-elements the moment either standard property carries a non-initial value.

Measured across the three engines with one stylesheet: an 8px Firefox gutter
against a 10px Chromium one, different thumb shapes, and a hover state in one
engine only. A capability query does not close that gap — the gap is not in the
query, it is in what Firefox can express. (And the obvious query is a trap:
Firefox parses unknown `::-webkit-` pseudo-elements on purpose, so
`CSS.supports('selector(::-webkit-scrollbar)')` answers **true** there while a
width set on that pseudo-element changes nothing at all.)

### How it works

The grid's scroll-area adapter (`components/grid-scroll-area.tsx`) wraps Base
UI's Scroll Area around the grid's existing scroll element:

```text
ScrollArea.Root                     .grid-pagination / grid body wrapper
  ScrollArea.Viewport               .grid-scroll-container  ← the ONLY scroller
    ScrollArea.Content              .grid-scroll-content
      .grid-container               rows, header cells, footer
  ScrollArea.Scrollbar (vertical)   overlays the right edge
    ScrollArea.Thumb
  ScrollArea.Scrollbar (horizontal) in flow, below the viewport
    ScrollArea.Thumb
  ScrollArea.Corner
```

Three things are worth knowing about that tree:

- **The viewport IS the scroll container.** It is not a wrapper around it. The
  element handed to the grid is the same one it has always measured, observed,
  scrolled and virtualized, so `scrollTop`, `scrollLeft`, wheel, trackpad,
  keyboard, programmatic scrolling and the header's scroll synchronisation are
  untouched. There is exactly one native scroller per surface.
- **The platform scrollbar is hidden, not styled.** Base UI puts
  `scrollbar-width: none` and `::-webkit-scrollbar { display: none }` on the
  viewport. Scrolling stays native; only the painting is replaced. That is also
  why no arrow buttons can appear: nothing paints a platform scrollbar.
- **The content part exists for a reason.** Base UI watches the viewport for
  resizes, and a grid viewport is a fixed-height flex child whose box never
  changes — only its content does, as rows arrive or a page turns. The content part
  observes the content box, which is the size that actually moves.

Base UI supplies the mechanics: thumb sizing, scroll-to-thumb mapping, thumb
dragging, track paging, RTL, resize observation, per-axis auto-hide, touch
modality and overscroll. The grid supplies the tokens, the lane and the
geometry.

### The lane

The vertical scrollbar overlays the viewport's right edge, so the space it needs
has to be kept clear. That is the trailing clearance the grid already reserved
(`--grid-edge-clearance`), now floored at the declared lane — which is why the
last column, its resize line and the aggregation footer never sit under the
thumb.

The horizontal scrollbar is deliberately **not** an overlay: it sits in flow
below the viewport, exactly where the platform's used to. One override instead
of a family of compensations — an overlaid horizontal bar would have covered the
last row and the sticky aggregation footer, and putting that right would have
taken a bottom clearance, a footer offset and a pagination adjustment.

Two widths, and they are not the same quantity:

| Token | Meaning |
| --- | --- |
| `--grid-scrollbar-thickness` | the lane the grid DECLARES and draws in. One source: the lane constant in `css-var-utils.ts` |
| `--grid-scrollbar-width` | the gutter the platform RESERVED, measured at runtime. Now 0 |

### Thickness

The lane is one declared width, 10px: the lane the application's own
ScrollArea paints. It reaches CSS as `--grid-scrollbar-thickness` and floors
the trailing clearance with the same number, so the geometry cannot drift from
the lane. There is no per-grid thickness setting; a surface with no visible
scrollbar is deliberately not offered.

### Geometry: the same control as the sidebar (UI-SCROLLBAR-02)

**Parity with the platform scrollbar is a requirement, not a similarity.** A
grid stands next to the sidebar on almost every screen, and the two must read
as one control (active-ui ADR-026, amendment of 2026-09-18). Tokens alone did
not achieve that: the grid lane used a symmetric 1.5px inset (a 7px thumb
centred in the lane) while the house ScrollArea uses `p-px` plus a
transparent 1px border on the side facing the content (a thumb 1px from the
outer edge, 7.36px wide at DPR 1.5625). The grid lane is now built exactly like
the stock bar:

| Part | Rule (`styles/surfaces.css`) |
| --- | --- |
| lane | `display: flex; padding: 1px`, `--grid-scrollbar-thickness` wide |
| vertical | `border-left: 1px solid transparent` |
| horizontal | `flex-direction: column; border-top: 1px solid transparent` |
| thumb | `flex: 1 1 0%`, `border-radius: 9999px`, the contract colours |
| corner | transparent |

Measured side by side in the shared scroll-area development fixture:
lane, thumb width, inset, corner, rest, hover and the `data-scrolling` state are
identical on both axes, in light and dark. Changing any of these on one side
only is a defect; `scrollbar-presentation.test.ts` pins the construction. The
adapter, the lane width, virtualization and the scroll math did not change.

### Colours

The thumb, its hover and the track are grid tokens
(`--grid-scrollbar-thumb`, `--grid-scrollbar-thumb-hover`,
`--grid-scrollbar-track`) with a neutral baseline. A host that has a platform
scrollbar contract maps them onto it, so the grid and the host's own scroll
areas read one set of colours; the monorepo's design system maps them onto its
`--scrollbar-thumb`, `--scrollbar-thumb-hover` and `--scrollbar-track`
(active-ui ADR-026). The thumb is painted over `--grid-background`, so a
translucent token gives one colour on every surface. Under `forced-colors`
the thumb takes the system text colour, because the browser would otherwise
replace its background with the colour of the lane behind it.

### Surfaces

| Surface | Scrolls | Presentation |
| --- | --- | --- |
| Grid body | x and y | the scroll-area adapter |
| Pagination bar | x | the scroll-area adapter (the bar is the content) |
| Column chooser | y | the scroll-area adapter |
| Select filter list | y | the scroll-area adapter |
| Header wrapper | never | `overflow: hidden`; follows the body by `scrollLeft` |
| Grid menus/popups | y | the grid's own popup primitives (`components/ui/`) |

The last row is a boundary, not an omission. A grid menu is a popup rendered
under `<body>`; its overflow uses the platform scrollbar the host styles for
every popup on the page, and the grid does not restyle it.

### Colours

Colours are ordinary tokens and can be overridden globally:

```css
.grid-root {
  --grid-scrollbar-thumb: color-mix(
    in oklab,
    var(--grid-primary) 30%,
    transparent
  );
  --grid-scrollbar-thumb-hover: color-mix(
    in oklab,
    var(--grid-primary) 50%,
    transparent
  );
  --grid-scrollbar-track: transparent;
}
```

> **Do not set `--grid-scrollbar-width` yourself.** Since 7.7.0 that variable is
> written by the grid at runtime and holds the gutter width it *measured*. A
> `:root` override is ignored for layout purposes — the grid overwrites it on
> each of its containers — and earlier versions produced a phantom horizontal
> scrollbar when you set it (measured: 4px at `12px`, 8px at `16px`).

### Right-edge layout

Two quantities live at the grid's right edge and they are deliberately not the
same number:

| Quantity | Source | What it drives |
| --- | --- | --- |
| **Scrollbar gutter** | measured at runtime (`offsetWidth − clientWidth`) | `--grid-scrollbar-width`, and the header's matching padding |
| **Trailing clearance** | the largest of that gutter, the lane, and an 8px floor | `--grid-edge-clearance`, and the width the flex fill holds back |

The gutter is a measurement, and since the grid draws its own scrollbar the
platform reserves nothing, so it measures **0** — which is exactly why the
header needs no padding to stay aligned with the body. The clearance is a design
choice: how much room the last column's boundary keeps from the card edge. It
now also has to clear the lane the grid paints its own scrollbar in, because
that scrollbar overlays the content instead of taking layout space from it.

Both sides of INV-18 come from one call to `trailingClearanceFor`, so the CSS
clearance and the width the flex fill holds back cannot drift apart.

## Aggregation Footer

The `.grid-footer` aggregation row is `position: sticky; bottom: 0` with
`z-index: var(--grid-z-footer)`. It uses
`min-width: calc(var(--grid-total-width, 0) * 1px)` to guarantee alignment
with body columns even in push-to-right (fixed) mode when the grid exceeds
the scroll container width.

## Pagination Footer

The pagination bar sits outside the scroll container and connects visually
to the grid body via `border-top` and matching `border-radius`. All
dimensions are controlled by CSS variables:

```css
:root {
  --grid-pagination-height: 48px;
  --grid-pagination-font-size: 0.8125rem;
  --grid-pagination-padding-x: 1rem;
}
```

The footer is responsive via `overflow-x: auto` — at narrow widths the
content scrolls horizontally instead of collapsing. No hardcoded
breakpoints.

The footer background uses `var(--grid-surface)` and the border uses
`var(--grid-border)`, matching the grid container. Text colors use
`--grid-muted-foreground` for secondary elements (row count, page indicator)
and `--grid-foreground` for interactive elements.

### Pagination Configuration

Control which elements are visible via `GridPaginationOptions`:

```tsx
<ActiveGrid
  pagination={{
    showRowCount: true, // "1 – 50 of 5,000"
    showPageSizeSelector: true, // Page-size dropdown
    showPageIndicator: true, // "Page 1 of 100"
    showFirstLastButtons: true, // « » buttons
    showPrevNextButtons: true, // ‹ › buttons
    rowCountPosition: 'left', // 'left' | 'right'
  }}
/>
```

## Menu Styling

Context menus and header menus are configured via `GridConfig.ui`. The
values are class names; the defaults are the grid's own
(`grid-menu-popup--context` 18rem, `grid-menu-popup--header` 16rem, item
padding 0.5rem and 0.375rem). A host passes its own class:

```css
.wide-grid-menu {
  inline-size: 20rem;
}
```

```tsx
<GridDefaults
  ui={{
    contextMenu: {widthClass: 'wide-grid-menu'},
  }}
>
```

## Header Menu Trigger

The header menu trigger (hamburger icon) uses progressive disclosure:

```css
/* Invisible by default */
.grid-header-cell .grid-header-menu-trigger {
  opacity: 0;
}

/* Subtle on column hover or when menu is open */
.grid-header-cell:hover .grid-header-menu-trigger {
  opacity: 0.6;
}

/* Full opacity on direct hover */
.grid-header-cell .grid-header-menu-trigger:hover {
  opacity: 1;
}
```

### Always-Visible Menu

To show the menu trigger at all times (AG Grid `suppressMenuHide`
equivalent):

```tsx
<ActiveGrid settings={{header: {showMenuOnHover: false}}} />
```

When `showMenuOnHover` is `false`, the grid sets `data-menu-always-visible`
on the header wrapper element. This attribute drives the CSS selectors in
`styles/interactions.css`:

```css
.grid-header-wrapper[data-menu-always-visible] .grid-header-menu-trigger {
  opacity: 0.6;
}
.grid-header-wrapper[data-menu-always-visible] .grid-header-menu-trigger:hover,
.grid-header-wrapper[data-menu-always-visible]
  .grid-header-menu-trigger:focus-visible {
  opacity: 1;
}
```

Override these selectors to customize the always-visible menu appearance.

### Menu Position

The menu button can be placed before or after the header label:

```tsx
// AG Grid "new" mode — menu left of label
<ActiveGrid settings={{ header: { menuPosition: 'left' } }} />

// Classic — menu right of label (default)
<ActiveGrid settings={{ header: { menuPosition: 'right' } }} />
```

### Filter Active Icon

When a column has an active filter, a small filter icon appears in the
header cell:

```tsx
// Enabled by default — disable with:
<ActiveGrid settings={{ header: { showFilterIcon: false } }} />

// Per-column suppression via column meta:
{ accessorKey: 'name', meta: { suppressFilterIcon: true } }
```

## Grid Container

The frame around header and body (`role="grid"`, class `grid-frame`) has a
`--grid-radius-lg` corner and a 1px `--grid-border` border. When pagination
is positioned at the bottom (default) the frame drops its bottom corners and
border (`grid-frame--pagination-below`) so it connects with the pagination
bar; with the grouping panel above it, its top corners go
(`grid-frame--grouping`).

## Focus and Selection

Every mark on a body cell is drawn from the grid's own state, never from where
the browser happens to have put the focus. The model behind it (which cell is
active, what a range is, which key does what) is in
`keyboard.md`; this section is what it looks
like.

**The state the stylesheet reads**, all set by the grid:

| Attribute | On | Means |
| --- | --- | --- |
| `data-focused` | Body cell | The active cell: the keyboard position |
| `data-cell-selected` | Body cell | The cell is in an explicit range |
| `data-cell-edge-*` | Body cell | Which sides of the cell lie on a range's outer edge |
| `data-cell-cue` | Body cell | The cell carries the selection's one cue |
| `data-body-focus` | Grid | The focus is in the grid's body, or in its own body menu |
| `data-cell-selection` | Grid | Cell selection is on (`enableCellSelection`) |
| `data-cell-range` | Grid | An explicit range exists |

**What is drawn:**

| State | Line | Fill | Cue |
| --- | --- | --- | --- |
| Active cell, cell selection off | One square pixel on four sides | None | None |
| Active cell, cell selection on, no range | One square pixel on four sides | None | Bottom-right |
| Range | One pixel on the range's outer edges | 8% `--ring` | On the last range's bottom-right cell |
| Active cell inside a range (the window) | Only the range's outer edges it lies on | None | None |
| An editor or control in the cell has focus | Its own field focus; the cell draws none | — | None |
| The grid does not hold the focus | The active cell draws nothing; a range stays drawn, without a window |  |  |

Every line and the cue are the grid's field focus, one pixel at 50% of
`--grid-ring` (`--grid-range-outline`); the fill is 8% of `--grid-ring`
(`--grid-range-fill`). Both tokens are declared on `.grid-root`, so they
follow the theme the grid sits in, from the first painted frame. The
corners are square: a cell is a grid square, not a field, and a range's
outline and the active cell's mark lie on the same pixels.

The marks are **painted, never laid out**. Line and fill are one overlay
(`::after`) inside the cell's box and the cue is a 5 px square (`::before`), so
no state moves a cell or its text: not a click, a range, an edge, a drag, a
menu, Escape or an edit. A border would move the text, and a box-shadow on the
cell would take the place of the pinned-column shadow. Neither pseudo-element
takes the pointer; the cue marks where the selection ends, it is not a handle,
and a drag starting on it is an ordinary range drag. The grid has no fill
handle and no paste. No mark transitions, and the row-hover background changes
at once: a mark drawn over a background that still fades changes contrast
under the reader's eye.

While a range is on screen, the row of the active cell takes no focus tint of
its own, so the range stays one region. A grid without `enableCellSelection`
never draws a cue. The browser's own focus outline on a body cell is off; the
state above is the one mark.

In forced colours the same state is drawn in system colours: range cells are
Highlight with HighlightText, the window is Canvas, and the active cell
carries a 2 px Highlight mark.

`e2e/cell-range-presentation.spec.ts` measures geometry, colour under four
theme combinations, the window, the cue, focus leaving and returning, a menu
open, the first frames after a reload and forced colours;
`e2e/focus-presentation.spec.ts` measures the mark moving with clicks and
keys.

The grid container (`role="grid"`) is the grid's tab stop. Reached by
keyboard, it marks itself with the same subdued expression: its own border at
50% of `--ring` (`focus-visible:border-ring/50`), no ring and no outline, in
light and dark. Arrow Down, Arrow Right or Enter moves focus into a cell, and a
return to the container restores the last active cell, so the container and
cell marks never draw together. `field-focus-contract.test.ts` guards the class
and `e2e/focus-presentation.spec.ts` the computed border under an accent theme.

A sort label the pointer focused shows no keyboard ring:

```css
.grid-header-cell-label[data-pointer-focus]:focus-visible {
  outline: none;
}
```

Editing cells get a subtle primary tint:

```css
.grid-cell--editing {
  background-color: color-mix(in oklch, var(--grid-primary) 5%, transparent);
}
```

Validation error cells get a destructive border:

```css
.grid-cell--error {
  border: 1px solid var(--destructive);
}
```

## Column Pinning Shadows

Pinned columns use theme-aware shadows to indicate the pin boundary:

```css
.grid-cell--pinned-last-start {
  box-shadow: calc(4px * var(--grid-dir-sign, 1)) 0 4px -2px
    color-mix(in oklch, var(--foreground) 8%, transparent);
  border-inline-end: 1px solid var(--grid-border);
}
```

The start section's last column casts its shadow toward the centre; under
`rtl` the inline sign `--grid-dir-sign` (set by the nearest `dir`) turns it.

On a narrow page (`max-width: 768px`), pinned **rows** stop sticking and lose
their shadow. Pinned **columns** stay pinned: a column pinned to the start keeps
its place under a sideways scroll at 390 px (browser evidence, FAM-V2-T5,
`e2e/mobile-narrow.spec.ts`).

## Resize Handle

The resize handle is a 12 px-wide target between columns, 24 px under a coarse
pointer (`@media (pointer: coarse)`). A mouse drags it; a finger does not yet
resize a column (R-57, recorded in FAM-V2-T5):

- Always visible as a subtle 1 px line (`--grid-resize-handle-color`).
- Widens to `var(--grid-resize-handle-width)` and turns
  `var(--grid-resize-handle-hover-color)` on hover.
- Height controlled by `--grid-resize-handle-height` (default: `50%`),
  vertically centered.
- Sets `cursor: col-resize` globally during active resize via
  `.grid-container--resizing`.

Customize the resize handle:

```css
:root {
  --grid-resize-handle-color: var(--grid-border);
  --grid-resize-handle-hover-color: var(--grid-primary);
  --grid-resize-handle-width: 2px;
  --grid-resize-handle-height: 50%;
}
```

## Host classes and utility frameworks

The grid's own markup uses only its own classes (`grid-*`, `resize-handle`);
no utility framework has to scan the package. A host may still put its own
classes, from any framework, on the grid through:

- the `className` prop on `ActiveGrid` (it lands on `.grid-root`);
- `meta.headerClassName` and `meta.cellClassName` on columns;
- `settings.footer.className` and the other style-config fields;
- `rowClassRules` and `cellClassRules` values;
- `ui.contextMenu` / `ui.headerMenu` class names.

```tsx
<ActiveGrid
  className="my-grid"
  settings={{
    rowClassRules: {
      'is-highlighted': (row) => row.isHighlighted,
    },
  }}
/>
```

With the layer order above, such a class wins over the grid's own rule on the
same element.

## Styling Conventions

- **Package classes** (`grid-*`) carry all structure and appearance, in
  `@layer active-grid`. Their names are stable styling hooks; their rules are
  not a contract a host overrides by specificity.
- **Tokens** (`--grid-*`) are the public styling API: roles to brand,
  derived tokens to fine-tune one grid, geometry tokens for density.
- **Data attributes** carry state (`[data-state='selected']`,
  `[data-cell-selected]`, `[data-open]`); style against them, not against
  class combinations.

| Concern | Where | Example |
| --- | --- | --- |
| Brand colours, radius, type | Role token | `--grid-primary: var(--brand)` |
| One grid's look | Derived token on the grid | `.dense .grid-root { --grid-header-bg: ... }` |
| Dimensions (heights, widths) | Geometry token | `--grid-row-height: 42px` |
| State indicators | CSS class + data attribute | `.grid-header-cell--filtered`, `[data-active]` |
| Z-index stacking | Geometry token | `--grid-z-header: 40` |

## Column-definition replacement

Column replacement is adopted through the commit-safe binding. Body and header
presentation observe the current column catalogue (R10D); width calculation
remains in the outer layout and measurement hooks. A source/model identity that
remains stable must not hide a changed renderer or header. This does not promise
that arbitrary host CSS or asynchronous measurement has no layout shift.
