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.
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.
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.
- 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
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>
);
}/* 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; }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>
);
}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.
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.
- 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.