Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Component Libraries and Design Systems

For .NET engineers who know: Telerik UI for Blazor, DevExpress, MudBlazor, or Syncfusion — component libraries where you install a package and get styled, interactive widgets You’ll learn: The headless component philosophy, how shadcn/ui works as our React choice, the Vue ecosystem options, and when to build vs. buy Time: 10-15 minutes

The .NET Way (What You Already Know)

In .NET UI development, a component library typically means one package that delivers both behavior and visual styling. You install Telerik or MudBlazor, add a theme, and you have a data grid with sorting, filtering, virtualization, and a consistent visual design. The library owns the look. Customization happens through theming APIs, CSS variable overrides, or — when you need to go further — fighting the library’s specificity.

// MudBlazor — behavior and styling bundled together
<MudDataGrid T="Order" Items="@orders" Filterable="true" SortMode="SortMode.Multiple">
    <Columns>
        <PropertyColumn Property="x => x.Id" Title="Order #" />
        <PropertyColumn Property="x => x.Total" Title="Total" Format="C" />
        <PropertyColumn Property="x => x.Status" Title="Status" />
    </Columns>
</MudDataGrid>

The trade-off is familiar: you move fast when the library’s design matches your requirements, and you slow down considerably when it does not. Overriding a Telerik theme for a specific design system can produce more CSS than you would have written from scratch.

The Modern JS/TS Way

The Headless Component Philosophy

The JS ecosystem split “behavior” and “styling” into two separate concerns at the library level. A headless component library implements all the hard interactive logic — keyboard navigation, ARIA attributes, focus management, accessibility semantics — but renders nothing with any visual style. You provide the markup and classes.

This is a different contract than MudBlazor. The library does not own the look. It owns the behavior.

// Radix UI — headless dialog. No styles, full accessibility out of the box.
import * as Dialog from "@radix-ui/react-dialog";

function OrderDetailDialog({
  order,
  onClose,
}: {
  order: Order;
  onClose: () => void;
}) {
  return (
    <Dialog.Root open={true} onOpenChange={(open) => !open && onClose()}>
      <Dialog.Portal>
        {/* Dialog.Overlay renders a <div> with role="none" — you style it */}
        <Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm" />

        {/* Dialog.Content renders a <div> with role="dialog", aria-modal, focus trap */}
        <Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg p-6 shadow-xl w-full max-w-md">
          <Dialog.Title className="text-lg font-semibold mb-2">
            Order #{order.id}
          </Dialog.Title>
          <Dialog.Description className="text-sm text-gray-500 mb-4">
            Total: ${order.total.toFixed(2)}
          </Dialog.Description>

          <Dialog.Close asChild>
            <button className="absolute top-4 right-4 text-gray-400 hover:text-gray-600">
              &times;
            </button>
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

What Radix gives you without any additional work: focus is trapped inside the dialog when open, Escape closes it, the scroll lock is applied to the body, ARIA attributes (role="dialog", aria-modal="true", aria-labelledby, aria-describedby) are wired automatically, and the Dialog.Close button triggers the onOpenChange callback. Try implementing that from scratch correctly, including all edge cases — it is not a weekend project.

The headless libraries in the React ecosystem:

  • Radix UI — the most comprehensive, highest quality. Covers dialogs, dropdowns, select, combobox, tooltip, popover, accordion, tabs, and many more.
  • Headless UI — from the Tailwind CSS team. Smaller component set, excellent quality, designed for Tailwind integration.
  • Floating UI (formerly Popper) — positioning engine for tooltips, dropdowns, anything that needs to float relative to a trigger.
  • React Aria (Adobe) — the most accessibility-focused option, implements ARIA patterns from the WAI-ARIA specification precisely.

shadcn/ui: Our React Choice

shadcn/ui is not a component library in the traditional sense — you do not install it as a dependency. You copy components into your project’s source code and own them. This is the key distinction.

# Add a component — this copies source files into your project
npx shadcn@latest add button
npx shadcn@latest add dialog
npx shadcn@latest add data-table

Each add command writes TypeScript source files into your components/ui/ directory. The button component is your button. You read it, modify it, extend it. There is no version to upgrade and no API surface to stay compatible with.

// components/ui/button.tsx — this file is now yours after shadcn copies it
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";

const buttonVariants = cva(
  // Base classes applied to every button
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
        secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 rounded-md px-3",
        lg: "h-11 rounded-md px-8",
        icon: "h-10 w-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button";
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    );
  }
);
Button.displayName = "Button";

export { Button, buttonVariants };

The cva function (class-variance-authority) manages variant combinations. Slot from Radix allows the asChild pattern — when asChild is true, the button renders as its child element (useful for wrapping an <a> tag with button styles without nesting). cn is the clsx + tailwind-merge helper from Article 3.7.

Why “Copy/Paste” Is Better Than a Dependency Here

With MudBlazor, when a bug exists in the DataGrid, you wait for a library release. When MudBlazor’s design conflicts with your design system, you write override CSS. When a MudBlazor component lacks a prop you need, you open an issue and wait.

With shadcn/ui, the component source is in your repository. You fix bugs, modify design, add props, and delete what you do not need. The component is a starting point, not a contract.

The trade-off: you are responsible for maintenance. If Radix UI releases a fix for a keyboard navigation bug, you do not get it automatically — you re-run the shadcn CLI to see what changed, or you apply the fix manually. For teams with design systems, this trade-off is clearly worth it.

Vue Options

Vue does not have a direct equivalent to shadcn/ui with the same momentum, but the options are mature:

PrimeVue — the most complete Vue component library. Covers DataTable, TreeTable, Calendar, Charts, and many more. Supports both styled and unstyled (headless-like) modes via “PT” (Pass-Through) API and a Tailwind preset.

// PrimeVue unstyled mode — you control all classes via passthrough
import DataTable from "primevue/datatable";
import Column from "primevue/column";

// In main.ts
app.use(PrimeVue, {
  unstyled: true,
  pt: {
    datatable: {
      root: "relative",
      table: "w-full table-auto border-collapse",
      thead: "border-b border-gray-200",
      tbody: "divide-y divide-gray-100",
    },
    column: {
      headercell: "px-4 py-3 text-left text-sm font-semibold text-gray-600",
      bodycell: "px-4 py-3 text-sm text-gray-900",
    },
  },
});

Vuetify — Material Design implementation for Vue. Opinionated about design (Material Design tokens), comprehensive, widely used. Better choice when the client requires Material Design or when the team is already familiar with it.

Naive UI — TypeScript-first, theme-aware, good component quality. The design is more neutral than Vuetify and more customizable.

Radix Vue / Reka UI — headless components for Vue, similar philosophy to Radix UI for React. Reka UI is the actively maintained successor to Radix Vue.

There is no single Vue equivalent of shadcn/ui, but the combination of Reka UI (headless behavior) + Tailwind (styling) + your own component layer achieves the same architecture.

Accessibility: What Headless Libraries Give You for Free

This is worth stating explicitly because it is something .NET engineers underestimate when considering whether to build components from scratch.

Building a fully accessible custom <Select> component requires:

  • combobox role on the input, listbox role on the dropdown
  • aria-expanded, aria-haspopup, aria-controls, aria-activedescendant attributes updated dynamically
  • Keyboard navigation: ArrowDown/ArrowUp for option traversal, Enter/Space to select, Escape to close, Home/End for first/last option, typeahead search by first letter
  • Focus management: focus returns to trigger after close
  • Mobile screen reader compatibility (different from desktop keyboard)
  • Touch support that does not conflict with native behavior

Radix UI and Headless UI implement all of this. When you use their Select or Combobox component, you inherit years of work on accessibility edge cases across browsers and assistive technologies. When you build a <div> dropdown from scratch, you own all of that.

The cost of ignoring accessibility is not just ethical — WCAG compliance is a legal requirement in many jurisdictions. Using headless libraries is the most practical path to compliance without a dedicated accessibility engineer on every team.

When to Build Custom vs. Use a Library

Use a library component when:

  • The component type is in the library (dialog, dropdown, select, tooltip, tabs, accordion)
  • Your needs fit the component’s documented behavior
  • The time to learn the library API is less than the time to build from scratch

Build custom when:

  • The component type is not available anywhere (a specific data visualization, a domain-specific widget)
  • The library component’s behavior fundamentally conflicts with your requirements (not just styling)
  • The library adds dependencies that significantly increase bundle size for a small gain

Do not build custom when:

  • You want to because it seems simpler (it is not — accessibility edge cases are the complexity you are not seeing)
  • The library’s default design does not match yours (change the design; do not rebuild the behavior)
  • You have not tried to style the library component (Radix UI renders semantically correct HTML; Tailwind styling is straightforward)

Extending Library Components

The pattern for building on top of a library component is to wrap it with your own component that applies your design system:

// Wrapping shadcn/ui Badge to add domain-specific variants
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";

type OrderStatus = "pending" | "processing" | "fulfilled" | "cancelled";

const statusConfig: Record<OrderStatus, { label: string; className: string }> = {
  pending: { label: "Pending", className: "bg-amber-100 text-amber-800 border-amber-200" },
  processing: { label: "Processing", className: "bg-blue-100 text-blue-800 border-blue-200" },
  fulfilled: { label: "Fulfilled", className: "bg-emerald-100 text-emerald-800 border-emerald-200" },
  cancelled: { label: "Cancelled", className: "bg-red-100 text-red-800 border-red-200" },
};

function OrderStatusBadge({ status }: { status: OrderStatus }) {
  const { label, className } = statusConfig[status];
  return (
    <Badge variant="outline" className={cn("font-medium", className)}>
      {label}
    </Badge>
  );
}

// The base Badge handles sizing, border-radius, and font-size.
// OrderStatusBadge handles domain semantics (which status maps to which color).
// Neither component knows about the other's concerns.
// Composing Radix primitives into a higher-level component
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { cn } from "@/lib/utils";

interface Action {
  label: string;
  icon?: React.ReactNode;
  onClick: () => void;
  variant?: "default" | "destructive";
  disabled?: boolean;
}

function ActionMenu({
  trigger,
  actions,
}: {
  trigger: React.ReactNode;
  actions: Action[];
}) {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger asChild>{trigger}</DropdownMenu.Trigger>

      <DropdownMenu.Portal>
        <DropdownMenu.Content
          className="min-w-40 bg-white rounded-md shadow-lg border border-gray-200 py-1 z-50"
          sideOffset={4}
        >
          {actions.map((action, index) => (
            <DropdownMenu.Item
              key={index}
              disabled={action.disabled}
              onClick={action.onClick}
              className={cn(
                "flex items-center gap-2 px-3 py-2 text-sm cursor-default select-none outline-none",
                "hover:bg-gray-50 focus:bg-gray-50",
                "data-[disabled]:opacity-50 data-[disabled]:pointer-events-none",
                action.variant === "destructive" && "text-red-600 hover:bg-red-50 focus:bg-red-50"
              )}
            >
              {action.icon}
              {action.label}
            </DropdownMenu.Item>
          ))}
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  );
}

The data-[disabled] and data-[state] selectors are Radix’s way of exposing component state to CSS. Radix adds data-state="open" / data-state="closed", data-highlighted, data-disabled, and so on to its rendered elements. Tailwind’s arbitrary variant syntax (data-[disabled]:opacity-50) selects on these attributes.

Key Differences

.NET Library PatternJS/TS EquivalentNotes
Install NuGet package, get styled componentsInstall + use (Material UI, Vuetify)Traditional model; library owns the look
CSS override via specificityTailwind classes on the rendered elementHeadless: no library styles to override
Theme customization via SCSS variablesDesign tokens in tailwind.config.tsUpstream from components
Telerik/DevExpress DataGridTanStack Table + headless componentBehavior library; you build the markup
MudBlazor <MudDialog>Radix <Dialog.Root> + your stylesSame behavior; you control the design
Waiting for library bug fixEdit the copied source file (shadcn)You own the code
Component parameter APIProps API (same concept, same constraints)
RenderFragment for slotschildren prop / named slotsDifferent syntax, same idea
@bind for two-way bindingControlled component patternvalue + onChange in React

Gotchas for .NET Engineers

Gotcha 1: Radix Components Are Compound — You Cannot Use Just the Root

Radix components are composed of multiple primitives that must be used together in a specific structure. Unlike <MudDialog Open="@_open">, you cannot just render the root element and expect behavior to work.

// BROKEN — Dialog.Root alone renders nothing and triggers nothing
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
  <div>Some content</div>
</Dialog.Root>

// CORRECT — the full primitive structure is required
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
  <Dialog.Portal>          {/* Renders into document.body via React portal */}
    <Dialog.Overlay />     {/* The backdrop, wired to close on click */}
    <Dialog.Content>       {/* The dialog container with ARIA and focus trap */}
      <Dialog.Title />     {/* Required for accessibility (aria-labelledby) */}
      <Dialog.Description /> {/* Optional but recommended */}
      <Dialog.Close />     {/* The close trigger */}
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>

Read the Radix documentation for each component before using it. The compound structure is documented and intentional. Dialog.Portal renders the dialog outside the component tree’s DOM position (into <body>) to avoid stacking context issues — the same problem that makes z-index on modals unreliable in nested components.

Gotcha 2: shadcn/ui Requires a Specific Project Structure and Dependencies

shadcn/ui is not a drop-in library. It requires:

  • Tailwind CSS configured and working
  • The cn utility (clsx + tailwind-merge) at @/lib/utils
  • Specific Tailwind CSS variables for design tokens (--background, --foreground, --primary, etc.) in your globals.css
  • Path aliases (@/) configured in tsconfig.json and your bundler

Running npx shadcn@latest init sets all of this up. Running npx shadcn@latest add button without init first, or without Tailwind configured, will produce a component that does not work and errors that are not obvious.

# Correct initialization sequence for a new Vite + React project
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npx tailwindcss init -p        # or use the Vite Tailwind plugin
npx shadcn@latest init         # sets up globals.css, lib/utils, tsconfig paths
npx shadcn@latest add button   # now this works

Gotcha 3: “Unstyled” in PrimeVue and Similar Libraries Is Not the Default

PrimeVue ships with a default styled theme. The unstyled mode (Pass-Through) is opt-in at the app.use(PrimeVue, { unstyled: true }) level. If you configure it styled and then try to add Tailwind classes, the library’s own CSS specificity will often win.

When using PrimeVue with Tailwind, make the decision up front: either use the PrimeVue theme and apply Tailwind only to non-PrimeVue areas, or use unstyled: true from the start and style everything with Tailwind via Pass-Through. Mixing both approaches mid-project is painful.

Gotcha 4: forwardRef Is Required for Library Integration in React

Many component libraries, and the asChild pattern in Radix, require that your custom components forward refs. A .NET engineer wrapping a library component without forwardRef will encounter errors when the library tries to attach a ref to manage focus or positioning.

// BROKEN — Tooltip cannot attach its positioning ref to this component
function MyButton({ children, ...props }: ButtonProps) {
  return <button {...props}>{children}</button>;
}

<Tooltip.Trigger asChild>
  <MyButton>Hover me</MyButton> {/* Radix cannot get a ref to the DOM node */}
</Tooltip.Trigger>

// CORRECT — forwardRef passes the ref through to the DOM element
const MyButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ children, ...props }, ref) => (
    <button ref={ref} {...props}>{children}</button>
  )
);
MyButton.displayName = "MyButton";

All shadcn/ui components already use forwardRef. When building your own wrapper components that will be placed inside Radix primitives with asChild, always add forwardRef.

Hands-On Exercise

Build a data table with sorting, filtering, and pagination using TanStack Table (the headless table behavior library) styled with Tailwind.

TanStack Table is the standard for complex table needs in the React ecosystem — it handles sorting, filtering, pagination, row selection, column visibility, virtualization, and more, without any rendering assumptions.

Setup:

npm install @tanstack/react-table

Data:

// types.ts
export interface Order {
  id: number;
  customer: string;
  total: number;
  status: "pending" | "processing" | "fulfilled" | "cancelled";
  createdAt: string;
}

export const orders: Order[] = [
  { id: 1, customer: "Acme Corp", total: 1240.00, status: "fulfilled", createdAt: "2026-02-01" },
  { id: 2, customer: "Globex Inc", total: 580.50, status: "processing", createdAt: "2026-02-05" },
  { id: 3, customer: "Initech", total: 3200.00, status: "pending", createdAt: "2026-02-10" },
  { id: 4, customer: "Acme Corp", total: 750.00, status: "cancelled", createdAt: "2026-02-12" },
  { id: 5, customer: "Umbrella Ltd", total: 94.99, status: "fulfilled", createdAt: "2026-02-14" },
  { id: 6, customer: "Initech", total: 450.00, status: "fulfilled", createdAt: "2026-02-15" },
  { id: 7, customer: "Globex Inc", total: 2100.00, status: "processing", createdAt: "2026-02-17" },
  { id: 8, customer: "Umbrella Ltd", total: 88.00, status: "pending", createdAt: "2026-02-18" },
];

Part 1 — Basic sortable table:

import {
  createColumnHelper,
  flexRender,
  getCoreRowModel,
  getSortedRowModel,
  type SortingState,
  useReactTable,
} from "@tanstack/react-table";
import { useState } from "react";
import { Order, orders } from "./types";

const columnHelper = createColumnHelper<Order>();

const columns = [
  columnHelper.accessor("id", {
    header: "Order #",
    cell: (info) => `#${info.getValue()}`,
  }),
  columnHelper.accessor("customer", {
    header: "Customer",
  }),
  columnHelper.accessor("total", {
    header: "Total",
    cell: (info) => `$${info.getValue().toFixed(2)}`,
  }),
  columnHelper.accessor("status", {
    header: "Status",
    // TODO: render an OrderStatusBadge here instead of plain text
    cell: (info) => info.getValue(),
  }),
  columnHelper.accessor("createdAt", {
    header: "Date",
  }),
];

export function OrderTable() {
  const [sorting, setSorting] = useState<SortingState>([]);

  const table = useReactTable({
    data: orders,
    columns,
    state: { sorting },
    onSortingChange: setSorting,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
  });

  return (
    <div className="overflow-x-auto rounded-lg border border-gray-200">
      <table className="w-full text-sm text-left">
        <thead className="bg-gray-50 border-b border-gray-200">
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <th
                  key={header.id}
                  className="px-4 py-3 font-semibold text-gray-600 cursor-pointer select-none hover:bg-gray-100"
                  onClick={header.column.getToggleSortingHandler()}
                >
                  <div className="flex items-center gap-1">
                    {flexRender(header.column.columnDef.header, header.getContext())}
                    {/* Sort indicator */}
                    {header.column.getIsSorted() === "asc" && " ↑"}
                    {header.column.getIsSorted() === "desc" && " ↓"}
                    {!header.column.getIsSorted() && header.column.getCanSort() && (
                      <span className="text-gray-300">↕</span>
                    )}
                  </div>
                </th>
              ))}
            </tr>
          ))}
        </thead>
        <tbody className="divide-y divide-gray-100">
          {table.getRowModel().rows.map((row) => (
            <tr key={row.id} className="hover:bg-gray-50 transition-colors">
              {row.getVisibleCells().map((cell) => (
                <td key={cell.id} className="px-4 py-3 text-gray-900">
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

Part 2 — Add global filter (search):

import {
  // ... previous imports
  getFilteredRowModel,
  type ColumnFiltersState,
} from "@tanstack/react-table";

// TODO: Add to the component:
// 1. const [globalFilter, setGlobalFilter] = useState("");
// 2. Add to useReactTable: { state: { sorting, globalFilter }, onGlobalFilterChange: setGlobalFilter, getFilteredRowModel: getFilteredRowModel() }
// 3. Add a search input above the table:
//    <input
//      value={globalFilter}
//      onChange={(e) => setGlobalFilter(e.target.value)}
//      placeholder="Search orders..."
//      className="px-3 py-2 border border-gray-300 rounded-md text-sm w-64 focus:outline-none focus:ring-2 focus:ring-blue-500"
//    />

// TODO: Add status filter (a <select> to filter by status column only):
// columnHelper.accessor("status", {
//   ...
//   filterFn: "equals",  // exact match instead of contains
// }),
// const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
// table.getColumn("status")?.setFilterValue(selectedStatus || undefined)

Part 3 — Add pagination:

import {
  // ... previous imports
  getPaginationRowModel,
  type PaginationState,
} from "@tanstack/react-table";

// TODO: Add pagination to the table:
// 1. const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 5 });
// 2. Add to useReactTable: { state: { ..., pagination }, onPaginationChange: setPagination, getPaginationRowModel: getPaginationRowModel() }
// 3. Add controls below the table:
//    - "Previous" button: table.previousPage(), disabled when !table.getCanPreviousPage()
//    - "Next" button: table.nextPage(), disabled when !table.getCanNextPage()
//    - Page indicator: "Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}"
//    - Rows per page select: table.setPageSize(Number(value))

Complete working solution (all three parts combined):

import {
  createColumnHelper,
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  type ColumnFiltersState,
  type PaginationState,
  type SortingState,
  useReactTable,
} from "@tanstack/react-table";
import { useState } from "react";
import { Order, orders } from "./types";

const columnHelper = createColumnHelper<Order>();

const statusColors: Record<Order["status"], string> = {
  pending: "bg-amber-100 text-amber-800",
  processing: "bg-blue-100 text-blue-800",
  fulfilled: "bg-emerald-100 text-emerald-800",
  cancelled: "bg-red-100 text-red-800",
};

const columns = [
  columnHelper.accessor("id", {
    header: "Order #",
    cell: (info) => <span className="font-mono">#{info.getValue()}</span>,
  }),
  columnHelper.accessor("customer", {
    header: "Customer",
  }),
  columnHelper.accessor("total", {
    header: "Total",
    cell: (info) => (
      <span className="font-medium">${info.getValue().toFixed(2)}</span>
    ),
  }),
  columnHelper.accessor("status", {
    header: "Status",
    filterFn: "equals",
    cell: (info) => (
      <span
        className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold ${statusColors[info.getValue()]}`}
      >
        {info.getValue()}
      </span>
    ),
  }),
  columnHelper.accessor("createdAt", {
    header: "Date",
  }),
];

export function OrderTable() {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [globalFilter, setGlobalFilter] = useState("");
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
  const [pagination, setPagination] = useState<PaginationState>({
    pageIndex: 0,
    pageSize: 5,
  });

  const table = useReactTable({
    data: orders,
    columns,
    state: { sorting, globalFilter, columnFilters, pagination },
    onSortingChange: setSorting,
    onGlobalFilterChange: setGlobalFilter,
    onColumnFiltersChange: setColumnFilters,
    onPaginationChange: setPagination,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
  });

  const statusOptions: Array<Order["status"] | ""> = [
    "",
    "pending",
    "processing",
    "fulfilled",
    "cancelled",
  ];

  return (
    <div className="space-y-4">
      {/* Toolbar */}
      <div className="flex items-center gap-3">
        <input
          value={globalFilter}
          onChange={(e) => setGlobalFilter(e.target.value)}
          placeholder="Search orders..."
          className="px-3 py-2 border border-gray-300 rounded-md text-sm w-64 focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        <select
          value={
            (table.getColumn("status")?.getFilterValue() as string) ?? ""
          }
          onChange={(e) =>
            table.getColumn("status")?.setFilterValue(e.target.value || undefined)
          }
          className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        >
          {statusOptions.map((s) => (
            <option key={s} value={s}>
              {s === "" ? "All statuses" : s}
            </option>
          ))}
        </select>
      </div>

      {/* Table */}
      <div className="overflow-x-auto rounded-lg border border-gray-200">
        <table className="w-full text-sm text-left">
          <thead className="bg-gray-50 border-b border-gray-200">
            {table.getHeaderGroups().map((headerGroup) => (
              <tr key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <th
                    key={header.id}
                    className="px-4 py-3 font-semibold text-gray-600 cursor-pointer select-none hover:bg-gray-100"
                    onClick={header.column.getToggleSortingHandler()}
                  >
                    <div className="flex items-center gap-1">
                      {flexRender(header.column.columnDef.header, header.getContext())}
                      {header.column.getIsSorted() === "asc" && " ↑"}
                      {header.column.getIsSorted() === "desc" && " ↓"}
                      {!header.column.getIsSorted() && header.column.getCanSort() && (
                        <span className="text-gray-300">↕</span>
                      )}
                    </div>
                  </th>
                ))}
              </tr>
            ))}
          </thead>
          <tbody className="divide-y divide-gray-100">
            {table.getRowModel().rows.length === 0 ? (
              <tr>
                <td colSpan={columns.length} className="px-4 py-8 text-center text-gray-400">
                  No orders match the current filters.
                </td>
              </tr>
            ) : (
              table.getRowModel().rows.map((row) => (
                <tr key={row.id} className="hover:bg-gray-50 transition-colors">
                  {row.getVisibleCells().map((cell) => (
                    <td key={cell.id} className="px-4 py-3 text-gray-900">
                      {flexRender(cell.column.columnDef.cell, cell.getContext())}
                    </td>
                  ))}
                </tr>
              ))
            )}
          </tbody>
        </table>
      </div>

      {/* Pagination */}
      <div className="flex items-center justify-between text-sm text-gray-600">
        <span>
          {table.getFilteredRowModel().rows.length} total order
          {table.getFilteredRowModel().rows.length !== 1 ? "s" : ""}
        </span>
        <div className="flex items-center gap-2">
          <select
            value={table.getState().pagination.pageSize}
            onChange={(e) => table.setPageSize(Number(e.target.value))}
            className="px-2 py-1 border border-gray-300 rounded text-sm"
          >
            {[5, 10, 20].map((size) => (
              <option key={size} value={size}>
                {size} per page
              </option>
            ))}
          </select>
          <span>
            Page {table.getState().pagination.pageIndex + 1} of{" "}
            {table.getPageCount()}
          </span>
          <button
            onClick={() => table.previousPage()}
            disabled={!table.getCanPreviousPage()}
            className="px-3 py-1 border border-gray-300 rounded text-sm hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
          >
            Previous
          </button>
          <button
            onClick={() => table.nextPage()}
            disabled={!table.getCanNextPage()}
            className="px-3 py-1 border border-gray-300 rounded text-sm hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
          >
            Next
          </button>
        </div>
      </div>
    </div>
  );
}

Quick Reference

LibraryTypeFrameworkUse When
Radix UIHeadlessReactNeed accessible primitives; building own design system
Headless UIHeadlessReact, VueAlready using Tailwind; smaller component set than Radix
shadcn/uiCopy-paste (Radix + Tailwind)ReactOur React recommendation; want to own the code
TanStack TableHeadless (table only)React, Vue, SvelteComplex table requirements
Material UIStyled (Material Design)ReactClient requires Material Design; large team familiar with it
PrimeVueStyled or unstyledVueComplex components (DataTable, Calendar, TreeTable) in Vue
Reka UIHeadlessVueVue equivalent of Radix
VuetifyStyled (Material Design)VueMaterial Design requirement in Vue
Naive UIStyledVueTypeScript-first Vue component library
React AriaHeadless (accessibility-first)ReactStrictest accessibility requirements
DecisionRecommendation
New React project, needs design systemshadcn/ui + Tailwind
New Vue project, needs design systemReka UI + Tailwind or PrimeVue unstyled
Existing project with styled libraryStay with it; do not mix headless and styled
Complex data tableTanStack Table (all frameworks)
Accessible dropdown/dialog/popoverRadix UI (React) or Reka UI (Vue)
Already have a design system in FigmaHeadless library + implement your own styles
Rapid prototype, design does not matterMaterial UI or Vuetify (fastest to functional)

Further Reading

  • Radix UI Documentation — Component-by-component reference. Read the “Accessibility” section for each component to understand what behavior you get for free.
  • shadcn/ui Documentation — Setup guide, component catalog, and theming reference. Start with “Installation” and “Theming”.
  • TanStack Table Documentation — The guide section “Column Definitions”, “Sorting”, “Filtering”, and “Pagination” covers 95% of real-world needs.
  • WAI-ARIA Authoring Practices Guide — The W3C specification for accessible widget patterns. Radix and Headless UI implement these. Reading the Dialog and Combobox patterns explains why the headless libraries are structured the way they are.
  • Reka UI Documentation — The actively maintained headless component library for Vue.