Skip to content

Card Mode

Users can switch between a table and cards without losing their sorting or filtering. Your application owns the switch; the table keeps the same data model connected in either presentation.

Pass mode="card" and a components.Card component to show cards. The default mode is "table". Each switch starts at the first item; it does not restore the previous scroll position or repeat the model handshake.

Card receives { data, index, context } inside a wrapper that the library measures. The optional CardHeader receives { context } and stays above the cards as a sticky header. Both are inside the same engine provider as the table, so header controls can read model action state and dispatch sorting or filtering actions.

This example uses the editable shadcn wrapper and keeps its column declarations mounted in both modes.

import * as React from 'react'

import { DataTable, DataTableCell, DataTableColumn, DataTableColumnHeader } from '@/components/ui/data-table'
import { dispatchModelAction$, localModel, modelActionState$, useCellValue, usePublisher } from '@virtuoso.dev/data-table'
import type { CardComponentProps } from '@virtuoso.dev/data-table'

interface Service {
  id: number
  name: string
  requests: number
}

const services: Service[] = Array.from({ length: 1000 }, (_, id) => ({
  id,
  name: `Service ${id}`,
  requests: (id * 137) % 10000,
}))

function ServiceCard({ data }: CardComponentProps<Service>) {
  return (
    <article>
      <h3 className="font-medium">{data.name}</h3>
      <p>{data.requests.toLocaleString()} requests</p>
    </article>
  )
}

function Controls() {
  const dispatch = usePublisher(dispatchModelAction$)
  const state = useCellValue(modelActionState$)
  return (
    <label className="flex items-center gap-2 bg-background p-3">
      <input
        type="checkbox"
        checked={Boolean(state.sort?.payload)}
        onChange={(event) => dispatch({ action: 'sort', payload: event.target.checked })}
      />
      Descending requests
    </label>
  )
}

const components = { Card: ServiceCard, CardHeader: Controls }

export default function App() {
  const [mode, setMode] = React.useState<'card' | 'table'>('card')
  const model = React.useMemo(
    () =>
      localModel<Service>({
        data: services,
        pipeline: ['sort'],
        actions: {
          sort: {
            stage: 'sort',
            handler: ({ data, payload }: { data: Service[]; payload: unknown }) =>
              payload ? data.toSorted((a, b) => b.requests - a.requests) : data,
          },
        },
      }),
    []
  )

  return (
    <div className="space-y-3">
      <button className="rounded border px-3 py-1" onClick={() => setMode(mode === 'card' ? 'table' : 'card')}>
        Show {mode === 'card' ? 'table' : 'cards'}
      </button>
      <DataTable<Service, unknown, never>
        model={model}
        mode={mode}
        components={components}
        computeRowKey={({ data }) => data.id}
        cardListClassName="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3"
        cardItemClassName="h-36 min-w-0 overflow-hidden rounded border p-4"
        style={{ height: 380 }}
      >
        <DataTableColumn field="name">
          <DataTableColumnHeader>Service</DataTableColumnHeader>
          <DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
        </DataTableColumn>
        <DataTableColumn field="requests">
          <DataTableColumnHeader>
            <Controls />
          </DataTableColumnHeader>
          <DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
        </DataTableColumn>
      </DataTable>
    </div>
  )
}

For filtering or remote sorting, connect controls to the same model actions you use in table mode. Card mode does not generate operation controls or choose breakpoints.

Style the list with cardListClassName and its item wrappers with cardItemClassName. The renderer measures one representative wrapper, the container width, and the computed CSS gaps. It derives the number of columns and virtualizes whole lines. There is no numeric card-size prop.

  • Every card must have the same outer width and height at the current container width. Matching heights within each individual grid row is not sufficient.
  • Use a regular row-major CSS grid, such as repeat(auto-fill, minmax(200px, 1fr)), or a wrapping flex layout with equal-width items and flex-shrink: 0.
  • Use gap, row-gap, and column-gap with lengths that resolve to pixels for spacing. Percentage gaps, item margins, spanning items, dense placement, and space-distributed alignment are not supported.
  • Keep list padding and borders off the measured list. The library owns its vertical padding for virtualization. Put decoration on the card wrappers or outside the table.
  • CSS may change dimensions and gaps responsively. A resize observer remeasures the active layout, including after a hidden container becomes visible.
  • Remote placeholders must use the same dimensions as loaded cards. Supply a schema-compatible placeholder and handle it in Card.

Card dimensions never enter the table row measurement cache, and row heights never seed card geometry. itemHeight$ remains a table-row measurement API. Each presentation remeasures when activated; retained data and column settings do not imply retained geometry.

Use the local Card Mode Ladle preview to change container width and card height independently, then switch to table rows of a different height.

initialLocation, scrollToRow$, and scrollIntoView$ refer to model item indexes, not visual grid-row indexes. The same is true of viewportRange$ and remote-model viewport requests. increaseViewportBy adds pixel overscan, and onRenderedDataChange reports the mounted model items.

Cards support internal scrolling, useWindowScroll, and customScrollParent. Their sticky header is included in the visible-height calculation. Empty, initial-loading, refresh-overlay, and append-footer slots use the same model loading state as the table. Switching during a request does not cancel or restart it; the new viewport can request additional data normally.

See Scroll Containers for scroll-owner setup and Empty and Loading States for loading and retry controls. The Card Mode Ladle previews include remote offset and append models with delayed requests and simulated errors.

Card mode requires components.Card and ungrouped model data. Otherwise it displays a configuration error. Providing the missing component, sending an ungrouped result, or returning to table mode recovers without replacing the model. It does not silently flatten groups or change your grouping action.

Column headers, column resizing, sticky columns, and column reordering are table-only surfaces. Their declarations and configuration remain available when the application switches back.

Use table mode when users need grouped rows. For unequal card dimensions or masonry placement, use a dedicated layout such as Virtuoso Masonry instead of this uniform card presentation.