Skip to content

Theming

The grid will not duplicate your design system. That claim has a specific mechanism behind it, and this page is the mechanism.

The one line that does it

Every colour in the stylesheet is declared like this:

css
--dg-bg: var(--background, oklch(1 0 0));

If the page already defines --background, the grid uses it. If nothing does, the fallback takes over. There is no configuration step, no theme provider, and no prop to pass — the cascade does the whole job.

Two consequences follow, and both are the point:

In a shadcn/ui project it looks native immediately. shadcn defines --background, --foreground, --muted, --border, --input, --accent, --primary, --popover, --ring, --destructive and --radius on :root, which is exactly the set the grid reads. Drop the stylesheet in and the grid picks up your radius, your border colour and your primary without being told.

On a blank HTML page it still looks finished. The fallbacks are a complete, measured palette. Nothing is required of the host.

The same grid, unmodified, inside an element defining shadcn/ui's tokens — visibly different corner radius, border colour and primary, identical structure

The same component, unmodified, inheriting a host design system's tokens. No props changed, no stylesheet edited.

The variables

Thirteen, and no more. Every one maps to a host token.

VariableReads host tokenLight fallbackDark fallbackUsed for
--dg-bg--backgroundoklch(1 0 0)oklch(0.18 0 0)Frame, rows, buttons, select
--dg-fg--foregroundoklch(0.21 0 0)oklch(0.98 0 0)Body text
--dg-muted--mutedoklch(0.97 0 0)oklch(0.26 0 0)Header fill, chips, skeleton, banner
--dg-muted-fg--muted-foregroundoklch(0.44 0 0)oklch(0.75 0 0)Column headers, secondary text
--dg-border--borderoklch(0.85 0 0)oklch(0.4 0 0)Row dividers, frame and popover edges
--dg-control-border--inputoklch(0.58 0 0)oklch(0.55 0 0)The edge of an interactive control
--dg-accent--accentoklch(0.96 0 0)oklch(0.28 0 0)Row hover, selected row, option hover
--dg-primary--primaryoklch(0.21 0 0)oklch(0.92 0 0)Primary button, active segment, checkboxes
--dg-primary-fg--primary-foregroundoklch(0.99 0 0)oklch(0.2 0 0)Primary button label
--dg-popover--popoveroklch(1 0 0)oklch(0.22 0 0)Filter menu surface
--dg-ring--ringoklch(0.45 0 0)oklch(0.72 0 0)Focus outline
--dg-danger--destructiveoklch(0.44 0.17 27)oklch(0.7 0.17 25)Error text, the dropped-filter chip
--dg-radius--radius8px8pxCorner radius, with smaller derived values

Two borders, on purpose

--dg-border and --dg-control-border look like duplication and are not.

--dg-border is a divider: the line between two rows, the edge of the frame. It is decorative, because the table's structure is carried by the markup — a screen reader knows where a row ends without seeing a line. It does not have to clear 3:1, and forcing it to would make an ordinary table look like a spreadsheet from 1998.

--dg-control-border is the edge of an interactive control: the outline of a text input, a button, a select. That one is required to identify the component — without it you cannot see that a text field is a text field — so it is measured against the background at 3:1 and is a visibly stronger grey.

If you are retheming and tempted to collapse them into one value, that is the trade-off you are making.

Derived values

The stylesheet computes smaller radii rather than asking for more variables:

css
border-radius: calc(var(--dg-radius) - 2px); /* buttons, chips, cards */
border-radius: calc(var(--dg-radius) - 4px); /* checkbox options, select */

Set --radius: 0 on the host and the whole grid squares off, including the derived values, which clamp at zero.

Retheming

Override the --dg-* variables on any ancestor. This scopes to one grid, or to one section of a page, without touching the stylesheet:

css
.audit-log {
  --dg-muted: oklch(0.94 0.02 250);
  --dg-primary: oklch(0.55 0.18 250);
  --dg-primary-fg: oklch(0.99 0 0);
  --dg-radius: 4px;
}

You can equally define the host tokens on an ancestor and let the grid inherit them, which is what the shadcn screenshot above is:

css
.themed-section {
  --background: oklch(1 0 0);
  --primary: oklch(0.21 0.006 285);
  --radius: 0.625rem;
}

Prefer the host tokens when the section contains other components too — one set of values driving everything is the entire benefit. Prefer the --dg-* overrides when you want to move the grid and only the grid.

Whichever you pick, re-measure. Overriding --dg-muted without checking it against --dg-muted-fg is how a column header ends up at 2.9:1 on a screen nobody looks at closely.

Dark mode

The dark palette applies under three conditions:

css
.dark,
[data-theme="dark"] { … }

@media (prefers-color-scheme: dark) {
  :root:not(.light):not([data-theme="light"]) { … }
}

So it works with a class-based toggle (.dark, the Tailwind and shadcn convention), with a data-theme attribute, or with nothing at all — following the operating system.

An explicit light choice wins. The :not(.light):not([data-theme="light"]) guard means a user who has chosen light mode in your application gets light mode, even on a machine set to dark. A theme toggle that the OS overrides is a toggle that does not work.

The dark values are also var(--host-token, fallback), so a host that defines its own dark palette still wins inside dark mode. The grid never hard-codes a colour it could inherit.

If you retheme, do both

The light and dark blocks are separate declarations. Overriding --dg-muted only in light mode leaves the dark theme on the original value, which usually shows up as a header fill that is subtly wrong against a themed background — and only for the half of your users on the other theme.

Class names

For the CSS-only path, and for anyone styling around the grid. Every class is prefixed dg-; the host element carries dg.

ClassElement
.dgThe host element
.dg-segments / .dg-segmentThe segment strip and its buttons
.dg-segment-countThe count pill on a segment or filter button
.dg-toolbar / .dg-toolbar-spacerThe toolbar row and its flexible gap
.dg-search / .dg-search-iconThe search field wrapper and its icon
.dg-search-clearThe clear-search button inside the field
.dg-btn / .dg-btn-primaryButtons
.dg-chips / .dg-chipThe active-filter chip row
.dg-chip-warnThe dropped-filter warning chip
.dg-pop / .dg-pop-titleA filter popover and its heading
.dg-opt / .dg-pop-actionsA checkbox option and the popover's button row
.dg-frameThe bordered box around the table
.dg-refreshingAdded to the frame during a refresh (the sweep)
.dg-scrollThe horizontal scroll container
.dg-tableThe <table>
.dg-sort / .dg-sort-arrowA sortable header's button and its chevron
.dg-numA right-aligned cell or header
.dg-checkboxA selection cell
.dg-state + .dg-state-{kind}An empty/no-match/error block
.dg-state-icon, -title, -body, -actions, -metaIts parts
.dg-skeleton-row / .dg-skeleton-cellThe loading skeleton
.dg-banner / .dg-banner-dangerThe stale-data banner you render
.dg-selectionThe selection bar
.dg-pagination / .dg-count / .dg-page-buttonsThe footer row and its parts
.dg-cards / .dg-cardThe cards display mode
.dg-sr-onlyThe visually hidden live region

The stylesheet also carries one defensive rule worth knowing about:

css
.dg [hidden] {
  display: none !important;
}

A class that sets display outranks the user-agent rule for [hidden], so a flex container hidden by attribute keeps rendering as an empty bordered strip. This puts the attribute back in charge. It cost a screenshot to notice.

Environment handling

prefers-reduced-motion: reduce switches off the skeleton pulse, the refresh sweep, and the sort-arrow transition. Nothing else in the grid animates.

forced-colors: active — Windows High Contrast and equivalents. Custom properties are ignored there, so the stylesheet re-states the boundaries with system keywords: ButtonBorder for the frame, popover, chips, banner and selection bar; Canvas behind the header row; Highlight / HighlightText for hovered and selected rows; CanvasText for the focus outline. A grid whose selected row is only distinguishable by a custom --accent disappears entirely in that mode.

print hides the toolbar, the pagination row, the selection bar and any open popover, gives the frame a solid black border, releases the horizontal scroll container so wide tables are not clipped at the paper's edge, and lets cell text wrap instead of truncating. What prints is the data.

Measured contrast

These numbers are measured out of data-grid.css by npm run contrast data-grid, which parses the stylesheet rather than reading a colour someone typed into a document. They cannot drift from the code.

lightdarkneeds
body text on background17.7217.754.5
muted text on background7.778.454.5
column header on header fill7.126.984.5
primary button label17.2214.294.5
error text on background8.486.534.5
focus ring on background7.447.583.0
focus ring on header fill6.826.263.0
control border vs background4.28 by fill3.88 by fill3.0
control border vs header fill3.93 by fill3.20 by fill3.0
primary button vs background17.72 by fill14.84 by fill3.0

Text needs 4.5:1 (WCAG 1.4.3); boundaries need 3:1 (WCAG 1.4.11).

Text is checked against 4.5:1 (WCAG 1.4.3) and boundaries against 3:1 (WCAG 1.4.11). CI runs the --check mode, so the stylesheet and this page cannot disagree.

These measure the fallback palette

The numbers above describe what a standalone page renders — the fallbacks in the table at the top of this page.

The moment you inherit a host theme, the host owns the contrast. We cannot measure colours we do not define, and reporting a ratio for var(--background) would be exactly the unverified claim this collection exists to avoid.

If you theme the grid from your design system, measure your own palette. The pairs worth checking are the ones in the generated table: body text on background, muted text on background, column header on header fill, primary button label on primary, error text on background, the focus ring against both the background and the header fill, and the control border against both fills.

If you are contributing upstream, run npm run contrast -- --sync, commit the regenerated tables, and say in the pull request which values changed. See Contributing.