Topic #39Core16 min read

UI Component Libraries / Design Systems (MUI, Atlaskit)

How to consume, theme, and override accessible component libraries (MUI, shadcn/ui, Radix/Headless UI) and back them with a design-token system: palette, typography, spacing, dark mode, and the a11y you get for free — plus when to override vs when to hand-roll.

#mui#shadcn#radix#headless-ui#design-system#design-tokens#theming#dark-mode#accessibility

The big picture (read this first). A component library is a shipped, tested set of UI primitives — buttons, inputs, dialogs, menus, tables — that already handle the parts that are quietly hard: keyboard interaction, focus management, ARIA roles, and cross-browser quirks. A design system is the layer above it: a single source of truth for design decisions (color, type, spacing, radius, motion) expressed as design tokens, plus rules for how components compose. Consuming a library well is mostly about theming through tokens rather than fighting each component with one-off styles. Get this right and a rebrand is one edit; get it wrong and you have a thousand inline overrides that drift.

Design tokens are the vocabulary. A token is a named design decision — color.primary, space.4, radius.md, font.body, shadow.elevated — stored once and referenced everywhere. Tokens split into primitive tokens (raw values like blue.600 = #1B4F72) and semantic tokens (color.action = blue.600, color.danger = red.600) that map meaning onto primitives. Components read semantic tokens, so you can retheme (or ship dark mode) by swapping what the semantic token points at, without touching a single component. This indirection is the whole game.

Three flavors of library, and why they differ. Batteries-included libraries like MUI and Atlaskit ship styled, opinionated components plus a theming engine — fast to build with, but you inherit their look and bundle. Headless libraries like Radix UI, Headless UI, and React Aria ship behavior and accessibility only (open/close state, focus trap, ARIA) with zero styles, so you own 100% of the look. Copy-in kits like shadcn/ui are not a dependency at all: you copy Radix-based, Tailwind-styled component source into your repo and own it outright. The trade is control vs. maintenance: headless/copy-in give total design control but you write the CSS; batteries-included give speed but constrain the look.

How MUI theming works. You call createTheme to build a theme object holding tokens — palette (primary/secondary/error and light/dark mode), typography (font family and a heading scale), spacing (a base unit multiplied by numbers), shape (border radius) — and inject it with <ThemeProvider> at the app root. Per-component styleOverrides reshape every instance of a component (round all MuiButton corners, kill uppercase). Components read the theme through React context, so a token change ripples everywhere consistently. The rule: override at the theme level, never with scattered inline sx/style for things that are really global decisions.

How token systems work outside MUI. In a Tailwind/shadcn setup, tokens live as CSS custom properties (--color-primary: 27 79 114;) on :root, and dark mode flips them under a .dark selector or [data-theme="dark"]. Tailwind's theme.extend maps utility classes (bg-primary) onto those variables. Because the variables cascade, dark mode is a single class toggle on <html> — no re-render, no prop drilling. This is why CSS-variable-based theming is the modern default: the browser does the theming for you.

Accessibility is the real reason to use a library. A hand-rolled <div onClick> "button" is not focusable, not keyboard-operable, and invisible to screen readers. A real dialog needs focus trapping, role="dialog", aria-modal, escape-to-close, and focus restoration on close; a menu needs roving tabindex and arrow-key navigation; a combobox needs aria-expanded, aria-activedescendant, and listbox semantics. Mature libraries have solved and tested all of this. When you hand-roll, you silently take on every one of these obligations — and most bespoke components ship with several missing.

Controlled vs uncontrolled, and composition. Library inputs come in controlled (value + onChange, you own state) and uncontrolled (defaultValue, the DOM owns state) forms — the same distinction as native inputs. Prefer composition (asChild, slots, children) over deep prop lists: a good library lets you pass your own element and keeps its behavior, rather than forcing you to configure a dozen boolean props. When a library can express something declaratively, use it; the moment you reach for useRef hacks to fight it, reconsider.

Overriding without breaking things. There is a ladder of override strength: (1) tokens/theme — global, predictable, the default; (2) variant props (variant="outlined", size="sm") — sanctioned per-instance changes; (3) local style props (sx, className) for genuine one-offs; (4) replacing the component. Escalate only when the level above genuinely can't express the need. Reaching straight for !important or deep descendant selectors against library internals is a smell — those selectors break on the next library upgrade.

When to build bespoke. Build your own component only when the library truly can't express the requirement (a novel interaction, a domain-specific widget). Understand what you're signing up for: you now own accessibility, keyboard support, focus management, RTL, high-contrast mode, and the edge cases the library had already tested. A good middle path is to build bespoke on top of a headless primitive (Radix/React Aria) so you keep the a11y and only own the styling.

Bundle size and tree-shaking. Batteries-included libraries are large; import from the specific path (@mui/material/Button) and rely on tree-shaking so you don't pull the whole library. Headless/copy-in kits keep the bundle lean because you only include what you use. Icons are a classic footgun — import individual icons, never the barrel. Measure with your bundler analyzer; a design system that doubles your JS is a performance problem wearing a UX costume.

Dark mode and theming as a first-class feature. Design dark mode into the token layer from day one: semantic tokens (color.surface, color.text) resolve differently per mode, and you toggle the mode, not individual colors. Respect the user's OS preference with prefers-color-scheme, but let them override it and persist the choice. Never hard-code hex values in components — that's the single decision that makes theming possible or impossible.

The mental model (memorise this). A design system is tokens (named decisions) + components (behavior + a11y) + rules for composing them. Consume the library for the accessibility and consistency you'd otherwise get wrong; theme through tokens injected once at the root so a rebrand or dark mode is one edit; escalate overrides only as far up the ladder as you must; and hand-roll only when the library can't express it — because then you own the a11y yourself.

Backend Analogy

A design system's token layer is your externalized configuration + dependency injection: define tokens once in a central ThemeProvider (your application.yml / bean config) and every component resolves them at runtime, exactly as Spring beans read centralized properties instead of hard-coding values per class. Semantic tokens mapping onto primitives (color.action -> blue.600) are like an interface bound to an implementation — swap the binding (dark mode) without touching consumers. A batteries-included library like MUI is a heavyweight framework you configure; a headless library (Radix) is a thin abstract base class that gives you the correct behavior contract and leaves the implementation to you. Reaching for !important against library internals is like reflecting into a framework's private fields — it works until the next version.

Key Insights
  • A design system = design tokens (named decisions) + components (behavior + a11y) + composition rules; tokens are the single source of truth.
  • Split tokens into primitive (raw values) and semantic (meaning: color.action, color.danger); components read semantic tokens so retheming and dark mode are one swap.
  • Three library flavors: batteries-included (MUI, Atlaskit) = fast but opinionated; headless (Radix, Headless UI, React Aria) = behavior/a11y only, you style; copy-in (shadcn/ui) = you own the source.
  • In MUI, createTheme defines tokens (palette, typography, spacing, shape, per-component styleOverrides) and ThemeProvider injects them at the root; override at the theme level, not with scattered inline styles.
  • CSS-custom-property tokens on :root cascade, so dark mode is a single class toggle on <html> with no re-render — the modern default for theming.
  • The biggest reason to use a library is accessibility you'd get wrong by hand: focus trap, ARIA roles, keyboard nav, focus restoration.
  • Override ladder: tokens/theme -> variant props -> local style props -> replace the component; escalate only when the level above can't express the need.
  • Build bespoke only when the library can't express it — you then own a11y, keyboard, RTL, and edge cases; prefer building on a headless primitive to keep the a11y.
  • Watch bundle size: import from specific paths for tree-shaking and import individual icons, never the barrel.
  • Never hard-code hex in components; put decisions in tokens so dark mode and rebrands are possible at all.

Worked Code

MUI — theme tokens, ThemeProvider, and dark mode
TSX
import { createTheme, ThemeProvider, CssBaseline } from "@mui/material";
import { Button, TextField } from "@mui/material";
import { useMemo, useState } from "react";

// A theme is your token store: palette, typography, spacing, shape,
// and per-component style overrides that reshape EVERY instance.
function buildTheme(mode: "light" | "dark") {
  return createTheme({
    palette: {
      mode, // flips default surface/text colors for dark mode
      primary: { main: "#1B4F72" },   // semantic: brand action color
      secondary: { main: "#E67E22" },
      error: { main: "#C0392B" },
    },
    typography: {
      fontFamily: '"Inter", "Roboto", sans-serif',
      h1: { fontSize: "2rem", fontWeight: 700 },
    },
    shape: { borderRadius: 8 },      // one radius token, used everywhere
    spacing: 4,                       // theme.spacing(2) === 8px
    components: {
      MuiButton: {
        defaultProps: { disableElevation: true },
        styleOverrides: {
          // reshape all buttons at the theme level (not inline)
          root: { textTransform: "none", borderRadius: 8 },
        },
      },
    },
  });
}

export function App() {
  const [mode, setMode] = useState<"light" | "dark">("light");
  // useMemo so the theme is not rebuilt on every render
  const theme = useMemo(() => buildTheme(mode), [mode]);

  return (
    <ThemeProvider theme={theme}>
      <CssBaseline /> {/* normalizes styles + applies palette.mode bg */}
      <Button variant="contained" color="primary"
        onClick={() => setMode(m => (m === "light" ? "dark" : "light"))}>
        Toggle theme
      </Button>
      <TextField label="Amount" type="number" variant="outlined" />
    </ThemeProvider>
  );
}
CSS-variable design tokens + dark mode (Tailwind / shadcn style)
CSS
/* Primitive + semantic tokens as CSS custom properties.
   Components reference the SEMANTIC token, never the raw hex. */
:root {
  /* primitives */
  --blue-600: #1b4f72;
  --orange-500: #e67e22;
  --gray-50: #f9fafb;
  --gray-900: #111827;

  /* semantic tokens (what components actually use) */
  --color-bg: var(--gray-50);
  --color-text: var(--gray-900);
  --color-action: var(--blue-600);
  --radius-md: 8px;
  --space-4: 1rem;
}

/* Dark mode = repoint the SAME semantic tokens. One toggle on <html>,
   no re-render, no prop drilling — the cascade does the work. */
:root[data-theme="dark"] {
  --color-bg: var(--gray-900);
  --color-text: var(--gray-50);
}

/* Optionally follow the OS preference by default */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    --color-bg: var(--gray-900);
    --color-text: var(--gray-50);
  }
}

.card {
  background: var(--color-bg);
  color: var(--color-text);
  border-radius: var(--radius-md);
  padding: var(--space-4);
}
.button-action { background: var(--color-action); color: #fff; }
Headless primitive (Radix): behavior + a11y, you own the styles
TSX
import * as Dialog from "@radix-ui/react-dialog";

// Radix ships the HARD parts for free: focus trap, role="dialog",
// aria-modal, escape-to-close, focus restoration, scroll lock.
// You supply 100% of the styling via className / your token classes.
export function ConfirmDialog() {
  return (
    <Dialog.Root>
      <Dialog.Trigger className="button-action">Delete</Dialog.Trigger>
      <Dialog.Portal>
        {/* overlay + content are unstyled — bring your own tokens */}
        <Dialog.Overlay className="dialog-overlay" />
        <Dialog.Content className="dialog-content">
          <Dialog.Title>Delete expense?</Dialog.Title>
          <Dialog.Description>This cannot be undone.</Dialog.Description>
          {/* Dialog.Close restores focus to the trigger automatically */}
          <Dialog.Close className="button-action">Confirm</Dialog.Close>
          <Dialog.Close>Cancel</Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}
MUI DataGrid — declarative columns/rows with built-in virtualization
TSX
import { DataGrid, type GridColDef } from "@mui/x-data-grid";

// DataGrid is config-driven: declare columns, pass rows. It virtualizes
// (renders only the visible window), and gives sort/paginate/select free.
const columns: GridColDef[] = [
  { field: "id", headerName: "ID", width: 70 },
  { field: "category", headerName: "Category", width: 140 },
  { field: "amount", headerName: "Amount", type: "number", width: 120 },
  {
    field: "approved",
    headerName: "Status",
    width: 120,
    // renderCell escapes to custom JSX for one column only
    renderCell: (params) => (params.value ? "Approved" : "Pending"),
  },
];

export function ExpenseTable({ rows }: { rows: any[] }) {
  return (
    <div style={{ height: 400 }}>
      <DataGrid
        rows={rows}
        columns={columns}
        checkboxSelection
        initialState={{ pagination: { paginationModel: { pageSize: 10 } } }}
        pageSizeOptions={[10, 25, 50]}
      />
    </div>
  );
}

Try It Live

Edit the code and press Run — it executes safely in a sandboxed iframe. Use the Console tab for log output.

Design tokens + dark-mode toggle with pure CSS variables

Interview-Ready Q&A

A design token is a named, reusable design decision — a color, type scale, spacing unit, radius, or shadow — stored once and referenced everywhere. Split into primitive tokens (raw values) and semantic tokens (meaning, like color.action or color.danger) that map onto primitives, tokens give a single source of truth: components read semantic tokens, so a rebrand or dark mode is one edit that ripples consistently. Inline overrides scatter the same value across files, drift over time, and make theming and dark mode impossible.

Things to Remember
  • 1Design system = tokens (named decisions) + components (behavior + a11y) + composition rules.
  • 2Primitive tokens hold raw values; semantic tokens hold meaning and map onto primitives — components read semantic tokens.
  • 3createTheme defines MUI tokens (palette, typography, spacing, shape, styleOverrides); ThemeProvider injects at the root.
  • 4CSS custom properties on :root cascade, so dark mode is one attribute toggle on <html> with no re-render.
  • 5Batteries-included (MUI, Atlaskit) = speed; headless (Radix, React Aria) = behavior/a11y only; copy-in (shadcn/ui) = you own the source.
  • 6Override ladder: theme/tokens -> variant props -> local style -> replace; escalate only when forced.
  • 7Bespoke means you own a11y, keyboard, focus, RTL — build on a headless primitive to keep the a11y.
  • 8Import from specific paths and import icons individually to keep the bundle small.
  • 9Never hard-code hex in components; put every decision in a token.
  • 10The library's biggest gift is the accessibility (focus trap, ARIA, keyboard) you'd otherwise get wrong.

References & Further Reading