Active Foundation

Search documentation

Search all pages, headings and text, in the current package or topic or in all documentation.

Styling

The stylesheet, the --grid-* tokens, the cascade layer, and how a host brands the grid.

@workspace/active-grid 11.0.0Status unknowncontent differs from the recorded release 11.0.0baseline ce081dc3

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:

TypeScript
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).

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#

PropertyActive Grid TokenAG Grid Quartz DefaultActive Grid Default
Header height--grid-header-height48px48px
Row height--grid-row-height42px42px
Cell horizontal padding--grid-cell-padding-x16px12px
Cell vertical padding--grid-cell-padding-y(derived)8px (0.5rem)
Footer height--grid-footer-height48px48px
Pagination height--grid-pagination-height48px48px
Pagination font size--grid-pagination-font-size13px13px (0.8125rem)
Header background--grid-header-bg#f8f8f8var(--grid-surface)
Header text color--grid-header-fgrgba(255,255,255,0.9)var(--grid-foreground)
Header font weight--grid-header-font-weight600600
Header font size--grid-header-font-size14px13px (0.8125rem)
Header hover background--grid-header-hover-bgrgba(255,255,255,0.05)None (no hover effect)
Header moving background--grid-header-moving-bgrgba(255,255,255,0.1)color-mix(foreground 10%)
Column separator display--grid-column-separator-displayblockblock
Column separator color--grid-column-separator-colorrgba(255,255,255,0.1)color-mix(--grid-border 70%)
Column separator height--grid-column-separator-height50%50%
Column separator width--grid-column-separator-width1px1px
Resize handle color--grid-resize-handle-colorrgba(255,255,255,0.2)var(--grid-border)
Resize handle hover color--grid-resize-handle-hover-colorvar(--primary)var(--grid-primary)
Resize handle width--grid-resize-handle-width2px2px
Resize handle height--grid-resize-handle-height50%50%
Icon size--grid-icon-size14-16px14px
Sort icon color--grid-sort-icon-colorforegroundvar(--grid-muted-foreground)
Filter active icon color--grid-filter-active-icon-colorvar(--primary)var(--grid-primary)
Menu icon color--grid-menu-icon-colormutedvar(--grid-muted-foreground)
Filter trigger radius--grid-filter-trigger-radiusN/Avar(--grid-radius-sm)
Filter trigger background--grid-filter-trigger-bgN/Acolor-mix(foreground 6%)
Filter trigger border--grid-filter-trigger-borderN/Acolor-mix(foreground 10%)
Filter trigger hover bg--grid-filter-trigger-hover-bgN/Avar(--grid-accent)
Filter trigger focus border--grid-filter-trigger-focus-borderN/Acolor-mix(--grid-ring 50%)
Filter trigger focus bg--grid-filter-trigger-focus-bgN/Avar(--grid-background)
Cell font size--grid-body-font-size14px13px (0.8125rem)
Border colorvar(--grid-border)#babfc7Theme-dependent
Row hovervar(--grid-accent)rgba(33,150,243,.1)Theme-dependent
Selected rowcolor-mix(--grid-primary 10%)rgba(33,150,243,.2)Theme-dependent
Scrollbar thickness--grid-scrollbar-thickness8px10px (lane, thumb as the house ScrollArea)

Theme Integration#

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

Grid elementRole tokenUsage
Grid container background--grid-surfaceMain grid background
Header background--grid-surfaceHeader row and pinned header cells (via --grid-header-bg)
Header text--grid-foregroundHeader text colour (via --grid-header-fg)
All borders--grid-borderRow borders, cell dividers, container border, column separators
Row hover--grid-accentBackground colour on row hover
Selected row--grid-primaryMixed at 10% for the selection background
Focus--grid-ringActive cell and field focus, at 50%
Secondary text--grid-muted-foregroundEmpty state, aggregation labels, sort icons
Popups--grid-popoverMenus, popovers, dialog and select list (with -foreground)
Fields--grid-input, -borderFilter fields, the toolbar search, checkboxes
Status cells--grid-status-<tone>GridStatusTone badges: tinted fill and border, -text colour
Destructive--grid-destructiveValidation error borders, destructive menu items
Primary indicators--grid-primaryFilter 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:

TokenDefaultUsage
--grid-z-base0Default layer
--grid-z-cell-pinned5Pinned body cells (sticky)
--grid-z-resize-handle20Resize handle overlay
--grid-z-row-pinned25Pinned rows (top/bottom sticky)
--grid-z-footer30Aggregation footer (sticky bottom)
--grid-z-header40Header wrapper (sticky top)
--grid-z-header-pinned45Pinned header cells
--grid-z-overlay50Loading/error/refetching overlays
--grid-z-popover50Filter popovers
--grid-z-drop-indicator60Column 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:

ModeDescription
'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:

TokenMeaning
--grid-scrollbar-thicknessthe lane the grid DECLARES and draws in. One source: the lane constant in css-var-utils.ts
--grid-scrollbar-widththe 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:

PartRule (styles/surfaces.css)
lanedisplay: flex; padding: 1px, --grid-scrollbar-thickness wide
verticalborder-left: 1px solid transparent
horizontalflex-direction: column; border-top: 1px solid transparent
thumbflex: 1 1 0%, border-radius: 9999px, the contract colours
cornertransparent

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#

SurfaceScrollsPresentation
Grid bodyx and ythe scroll-area adapter
Pagination barxthe scroll-area adapter (the bar is the content)
Column chooserythe scroll-area adapter
Select filter listythe scroll-area adapter
Header wrapperneveroverflow: hidden; follows the body by scrollLeft
Grid menus/popupsythe 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:

QuantitySourceWhat it drives
Scrollbar guttermeasured at runtime (offsetWidth − clientWidth)--grid-scrollbar-width, and the header's matching padding
Trailing clearancethe 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.

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.

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'  }}/>

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.

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:

AttributeOnMeans
data-focusedBody cellThe active cell: the keyboard position
data-cell-selectedBody cellThe cell is in an explicit range
data-cell-edge-*Body cellWhich sides of the cell lie on a range's outer edge
data-cell-cueBody cellThe cell carries the selection's one cue
data-body-focusGridThe focus is in the grid's body, or in its own body menu
data-cell-selectionGridCell selection is on (enableCellSelection)
data-cell-rangeGridAn explicit range exists

What is drawn:

StateLineFillCue
Active cell, cell selection offOne square pixel on four sidesNoneNone
Active cell, cell selection on, no rangeOne square pixel on four sidesNoneBottom-right
RangeOne pixel on the range's outer edges8% --ringOn the last range's bottom-right cell
Active cell inside a range (the window)Only the range's outer edges it lies onNoneNone
An editor or control in the cell has focusIts own field focus; the cell draws none—None
The grid does not hold the focusThe 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.

ConcernWhereExample
Brand colours, radius, typeRole token--grid-primary: var(--brand)
One grid's lookDerived token on the grid.dense .grid-root { --grid-header-bg: ... }
Dimensions (heights, widths)Geometry token--grid-row-height: 42px
State indicatorsCSS class + data attribute.grid-header-cell--filtered, [data-active]
Z-index stackingGeometry 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.

This page as Markdown