Skip to content

Replacing Internals

Most styling you do to a table is column-level — a class on a cell, a renderer that builds a status pill. But some changes apply across every row at once: hover states, density, dividers, the markup around sticky columns, the element that actually scrolls. Those changes live in the table’s structural slots, and you change them by passing a replacement component.

The components prop is the channel. Each key in components swaps out one piece of the table’s internal markup with your own component.

import * as React from 'react'
import type { RowComponentProps } from '@virtuoso.dev/data-table'

const components = {
  Row: React.forwardRef<HTMLDivElement, RowComponentProps>(function Row(props, ref) {
    return <div ref={ref} className="border-b transition-colors hover:bg-muted/50" {...props} />
  }),
}

;<DataTable components={components} model={model}>
  {/* columns */}
</DataTable>

The override receives the same props the default <div> would have received — class, style, children, data attributes, the table’s context value. Forward them all, or the table loses track of the element. The forwarded ref is what the virtualizer reads sizes from, so passing it to the root element is mandatory; the table will appear broken without it.

The components prop covers structural slots that appear repeatedly or wrap whole regions:

  • Row — the element used for data rows (one per visible row). Hover states, dividers, alternating backgrounds, density, row-level transitions.
  • StickyHeader — the wrapper around the sticky header row. Backgrounds and borders that need to match your page chrome instead of the default.
  • StickyColumnContainer — the wrapper around each sticky column region, used in both header and body. The data-sticky attribute on the props tells you whether it’s the left or right side.
  • LoadingPlaceholder, LoadingOverlay, LoadingFooter — the three loading-state slots. Covered separately in Empty and Loading States because each one has its own narrative.
import type { DataTableComponents, RowComponentProps, StickyColumnContainerComponentProps } from '@virtuoso.dev/data-table'

const components: DataTableComponents = {
  Row: React.forwardRef<HTMLDivElement, RowComponentProps>(function Row(props, ref) {
    return <div ref={ref} className="border-b" {...props} />
  }),
  StickyColumnContainer: React.forwardRef<HTMLDivElement, StickyColumnContainerComponentProps>(function Sticky(props, ref) {
    const side = props['data-sticky']
    return <div ref={ref} className={side === 'left' ? 'bg-card shadow-[1px_0_0_0_hsl(var(--border))]' : 'bg-card'} {...props} />
  }),
}

A few replacements have their own top-level props instead of living under components:

  • EmptyPlaceholder — rendered when the model has zero rows. Top-level prop because most apps customize it and want a single explicit place to look.
  • ScrollElement — the element that actually scrolls. Top-level prop because it interacts with viewport ownership (useWindowScroll, customScrollParent) — see Scroll Containers.

Both follow the same forward-the-props rule.

const ScrollElement = React.forwardRef<HTMLDivElement, React.HTMLProps<HTMLDivElement>>(function ScrollElement(props, ref) {
  return <div ref={ref} className="custom-scrollbar overflow-auto" {...props} />
})

;<DataTable EmptyPlaceholder={EmptyPlaceholder} ScrollElement={ScrollElement} model={model}>
  {/* columns */}
</DataTable>

The shadcn wrapper at @/components/ui/data-table already supplies defaults for every slot. Two questions decide where your override belongs:

  • Does every table in your app want this change? Edit the default in the wrapper file. See The shadcn Wrapper.
  • Does this one table want something different? Pass components (or EmptyPlaceholder / ScrollElement) on that table only.

Both routes hit the same slots; the difference is scope.

Don’t reach for data-testid as a styling hook

Section titled “Don’t reach for data-testid as a styling hook”

Per the data-table conventions, data-testid is reserved for tests. When your override needs a styling hook, use a semantic data attribute (data-density="compact") or a className — not a test ID. The table itself never styles against data-testid and you shouldn’t either.