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
This guide is the package's docs/, 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:
import '@workspace/active-grid/styles.css';All of it sits in the cascade layer active-. 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:
@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 class, cell, row, ...)
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- guards both).
Tokens#
Three kinds, all declared in src/:
Role tokens, what a host maps: surfaces and text (
--grid-,background --grid-,foreground --grid-,surface --grid-,surface- foreground --grid-,popover --grid-), emphasis (popover- foreground --grid-,muted --grid-,accent --grid-,primary --grid-,secondary --grid-, each with itsdestructive -foreground), lines (--grid-,border --grid-,separator --grid-,input --grid-,input- border --grid-), the status tones (ring --grid-, each with astatus- neutral|info|success|warning|danger -textcolour), the radius scale (--grid-,radius -sm,-md,-lg,-xl) and the chrome type sizes (--grid-,text- xs -sm,-base).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-, ...). Override one for one grid through that grid'sshadow- * class; brand through the roles.Name 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:
: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/'s
grid-, and nothing else; data- 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
., and on every popup the grid renders (.). 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 . scope on a
panel). Declared on ., 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
. (.) or through the grid's
class.
Geometry tokens#
: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-, no ring, no glow, no outline, in
light and dark. The popover and builder fields share the class
grid-; the inline filter bar follows
--grid-. Overriding that token to a stronger
value, or adding a ring to a grid field, breaks the calibration;
field- 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:
.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;}<ActiveGrid className="compact-grid" />Create a spacious grid:
.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- | 48px | 48px |
| Row height | --grid- | 42px | 42px |
| Cell horizontal padding | --grid- | 16px | 12px |
| Cell vertical padding | --grid- | (derived) | 8px (0.5rem) |
| Footer height | --grid- | 48px | 48px |
| Pagination height | --grid- | 48px | 48px |
| Pagination font size | --grid- | 13px | 13px (0.8125rem) |
| Header background | --grid- | #f8f8f8 | var(--grid- |
| Header text color | --grid- | rgba(255,255,255,0.9) | var(--grid- |
| Header font weight | --grid- | 600 | 600 |
| Header font size | --grid- | 14px | 13px (0.8125rem) |
| Header hover background | --grid- | rgba(255,255,255,0.05) | None (no hover effect) |
| Header moving background | --grid- | rgba(255,255,255,0.1) | color- |
| Column separator display | --grid- | block | block |
| Column separator color | --grid- | rgba(255,255,255,0.1) | color- |
| Column separator height | --grid- | 50% | 50% |
| Column separator width | --grid- | 1px | 1px |
| Resize handle color | --grid- | rgba(255,255,255,0.2) | var(--grid- |
| Resize handle hover color | --grid- | var(--primary) | var(--grid- |
| Resize handle width | --grid- | 2px | 2px |
| Resize handle height | --grid- | 50% | 50% |
| Icon size | --grid- | 14- | 14px |
| Sort icon color | --grid- | foreground | var(--grid- |
| Filter active icon color | --grid- | var(--primary) | var(--grid- |
| Menu icon color | --grid- | muted | var(--grid- |
| Filter trigger radius | --grid- | N/A | var(--grid- |
| Filter trigger background | --grid- | N/A | color- |
| Filter trigger border | --grid- | N/A | color- |
| Filter trigger hover bg | --grid- | N/A | var(--grid- |
| Filter trigger focus border | --grid- | N/A | color- |
| Filter trigger focus bg | --grid- | N/A | var(--grid- |
| Cell font size | --grid- | 14px | 13px (0.8125rem) |
| Border color | var(--grid- | #babfc7 | Theme-dependent |
| Row hover | var(--grid- | rgba(33,150,243,.1) | Theme-dependent |
| Selected row | color- | rgba(33,150,243,.2) | Theme-dependent |
| Scrollbar thickness | --grid- | 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- | Main grid background |
| Header background | --grid- | Header row and pinned header cells (via --grid-) |
| Header text | --grid- | Header text colour (via --grid-) |
| All borders | --grid- | Row borders, cell dividers, container border, column separators |
| Row hover | --grid- | Background colour on row hover |
| Selected row | --grid- | Mixed at 10% for the selection background |
| Focus | --grid- | Active cell and field focus, at 50% |
| Secondary text | --grid- | Empty state, aggregation labels, sort icons |
| Popups | --grid- | Menus, popovers, dialog and select list (with -foreground) |
| Fields | --grid-, -border | Filter fields, the toolbar search, checkboxes |
| Status cells | --grid- | Grid badges: tinted fill and border, -text colour |
| Destructive | --grid- | Validation error borders, destructive menu items |
| Primary indicators | --grid- | Filter active icon, resize handle active state, selection |
A design system built on the common shadcn token names maps these one to one
(--grid-, --grid-, ...).
Z-index Architecture#
All z-index values are controlled by CSS custom properties for predictable stacking:
| Token | Default | Usage |
|---|---|---|
--grid- | 0 | Default layer |
--grid- | 5 | Pinned body cells (sticky) |
--grid- | 20 | Resize handle overlay |
--grid- | 25 | Pinned rows (top/bottom sticky) |
--grid- | 30 | Aggregation footer (sticky bottom) |
--grid- | 40 | Header wrapper (sticky top) |
--grid- | 45 | Pinned header cells |
--grid- | 50 | Loading/error/refetching overlays |
--grid- | 50 | Filter popovers |
--grid- | 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-.
Header cells use a ::before pseudo-element; body cells with
show use a ::after pseudo-element — both centered at 50%
height for a clean, AG Grid-like appearance.
Customize the appearance:
.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:
<ActiveGrid settings={{header: {showColumnSeparators: false}}} />Body/Header/Footer Column Borders#
The show 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).
<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-:
.grid-root { --grid-header-moving-bg: color-mix( in oklch, var(--foreground) 10%, transparent );}The moving background uses color- with var(--foreground) so it
adapts to light and dark themes automatically.
Dark Mode#
The baseline has a dark set under the . ancestor convention it shares
with active-charts: put . 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- with var(--grid- so they stay visible
on a dark surface:
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:
<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:
{ 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:
{ 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:
<ActiveGrid settings={{stripedRows: true}} />The stripe color uses color-
for a subtle effect that works in both light and dark modes.
Custom stripe styling:
<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-; Firefox offers exactly two
properties, scrollbar- and scrollbar-, 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. 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/) wraps Base
UI's Scroll Area around the grid's existing scroll element:
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.CornerThree 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
scroll,Top scroll, wheel, trackpad, keyboard, programmatic scrolling and the header's scroll synchronisation are untouched. There is exactly one native scroller per surface.Left The platform scrollbar is hidden, not styled. Base UI puts
scrollbar-andwidth: none ::-webkit-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.scrollbar { display: none } 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-), 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- | the lane the grid DECLARES and draws in. One source: the lane constant in css- |
--grid- | 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- 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- 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/) |
|---|---|
| lane | display: flex; padding: 1px, --grid- wide |
| vertical | border- |
| horizontal | flex- |
| thumb | flex: 1 1 0%, border-, 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- state are
identical on both axes, in light and dark. Changing any of these on one side
only is a defect; scrollbar- 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-, --grid-,
--grid-) 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-, --scrollbar- and --scrollbar-
(active-ui ADR-026). The thumb is painted over --grid-, so a
translucent token gives one colour on every surface. Under forced-
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 scroll |
| Grid menus/popups | y | the grid's own popup primitives (components/) |
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:
.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-yourself. Since 7.7.0 that variable is written by the grid at runtime and holds the gutter width it measured. Ascrollbar- width :rootoverride 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 at12px, 8px at16px).
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 (offset) | --grid-, and the header's matching padding |
| Trailing clearance | the largest of that gutter, the lane, and an 8px floor | --grid-, 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 trailing, so the CSS
clearance and the width the flex fill holds back cannot drift apart.
Aggregation Footer#
The . aggregation row is position: sticky; bottom: 0 with
z-. It uses
min- 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- and matching border-. All
dimensions are controlled by CSS variables:
:root { --grid-pagination-height: 48px; --grid-pagination-font-size: 0.8125rem; --grid-pagination-padding-x: 1rem;}The footer is responsive via overflow- — at narrow widths the
content scrolls horizontally instead of collapsing. No hardcoded
breakpoints.
The footer background uses var(--grid- and the border uses
var(--grid-, matching the grid container. Text colors use
--grid- for secondary elements (row count, page indicator)
and --grid- for interactive elements.
Pagination Configuration#
Control which elements are visible via Grid:
<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 Grid. The
values are class names; the defaults are the grid's own
(grid- 18rem, grid- 16rem, item
padding 0.5rem and 0.375rem). A host passes its own class:
.wide-grid-menu { inline-size: 20rem;}<GridDefaults ui={{ contextMenu: {widthClass: 'wide-grid-menu'}, }}>Header Menu Trigger#
The header menu trigger (hamburger icon) uses progressive disclosure:
/* 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 suppress
equivalent):
<ActiveGrid settings={{header: {showMenuOnHover: false}}} />When show is false, the grid sets data-
on the header wrapper element. This attribute drives the CSS selectors in
styles/:
.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:
// 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:
// 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-) has a
--grid- corner and a 1px --grid- border. When pagination
is positioned at the bottom (default) the frame drops its bottom corners and
border (grid-) so it connects with the pagination
bar; with the grouping panel above it, its top corners go
(grid-).
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.; this section is what it looks
like.
The state the stylesheet reads, all set by the grid:
| Attribute | On | Means |
|---|---|---|
data- | Body cell | The active cell: the keyboard position |
data- | Body cell | The cell is in an explicit range |
data- | Body cell | Which sides of the cell lie on a range's outer edge |
data- | Body cell | The cell carries the selection's one cue |
data- | Grid | The focus is in the grid's body, or in its own body menu |
data- | Grid | Cell selection is on (enable) |
data- | 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- (--grid-); the fill is 8% of --grid-
(--grid-). Both tokens are declared on ., 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 enable
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/ 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/ 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-), 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- guards the class
and e2e/ the computed border under an accent theme.
A sort label the pointer focused shows no keyboard ring:
.grid-header-cell-label[data-pointer-focus]:focus-visible { outline: none;}Editing cells get a subtle primary tint:
.grid-cell--editing { background-color: color-mix(in oklch, var(--grid-primary) 5%, transparent);}Validation error cells get a destructive border:
.grid-cell--error { border: 1px solid var(--destructive);}Column Pinning Shadows#
Pinned columns use theme-aware shadows to indicate the pin boundary:
.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- (set by the nearest dir) turns it.
On a narrow page (max-), 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/).
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-and turnsresize- handle- width) var(--grid-on hover.resize- handle- hover- color) Height controlled by
--grid-(default:resize- handle- height 50%), vertically centered.Sets
cursor: col-globally during active resize viaresize ..grid- container--resizing
Customize the resize handle:
: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-);
no utility framework has to scan the package. A host may still put its own
classes, from any framework, on the grid through:
the
classprop onName Active(it lands onGrid .);grid- root meta.andheader Class Name meta.on columns;cell Class Name settings.and the other style-config fields;footer. class Name rowandClass Rules cellvalues;Class Rules ui./context Menu ui.class names.header Menu
<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-. Their names are stable styling hooks; their rules are not a contract a host overrides by specificity.grid 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-); style against them, not against class combinations.open]
| Concern | Where | Example |
|---|---|---|
| Brand colours, radius, type | Role token | --grid- |
| One grid's look | Derived token on the grid | . |
| Dimensions (heights, widths) | Geometry token | --grid- |
| State indicators | CSS class + data attribute | ., [data- |
| Z-index stacking | Geometry token | --grid- |
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.