Skip to content

6.8.1 — Design Systems & Layout Archetypes

An audit of a two-year-old product finds fourteen shades of grey, six button components with different names, four modal implementations, and three different spacing rhythms depending on which team wrote the screen. Nobody did anything wrong. Each of those was a reasonable local decision made under deadline, and there was no mechanism that would have produced a different outcome.

A design system is that mechanism. It is not a component library — a component library is one part of it — and the parts that matter most are the ones with the least visual interest.

1. Tokens, in three tiers

A design token is a named value: a colour, a spacing step, a font size, a radius, a shadow. The naming is the whole design, and the standard structure has three layers.

css
:root {
  /* Tier 1 — primitives. Raw values with descriptive names. */
  --blue-50:  #eef4ff;
  --blue-600: #0b62d6;
  --blue-900: #0a2d63;
  --grey-100: #f2f4f7;
  --grey-900: #101828;
  --space-1: 4px;  --space-2: 8px;  --space-3: 12px;  --space-4: 16px;

  /* Tier 2 — semantic. What the value MEANS. This is the tier that does the work. */
  --color-action:          var(--blue-600);
  --color-action-hover:    var(--blue-900);
  --color-surface:         white;
  --color-surface-sunken:  var(--grey-100);
  --color-text:            var(--grey-900);
  --color-danger:          var(--red-600);
  --space-inline-sm:       var(--space-2);
  --space-block-md:        var(--space-4);

  /* Tier 3 — component. Only when a component genuinely needs its own knob. */
  --button-padding-block:  var(--space-2);
  --button-radius:         6px;
}

Tier 1 names a value. Tier 2 names a decision. That difference is everything, and skipping tier 2 is the single most common mistake.

If a component says background: var(--blue-600), then dark mode means finding and changing every usage, and "we are changing the brand colour" is a search-and-replace across the codebase. If it says background: var(--color-action), then dark mode is one block:

css
[data-theme="dark"] {
  --color-action:  var(--blue-400);
  --color-surface: var(--grey-900);
  --color-text:    var(--grey-50);
}

Because custom properties inherit (Chapter 6.2.3), that block re-themes everything beneath it with no component changes at all. Semantic tokens are what make theming a configuration change rather than a project.

Tier 3 is for genuine component-level knobs, and it should stay small. A token per component per property is a second design system with none of the benefits.

Naming, and the spacing scale

The naming convention that survives contact with a real team is category-property-variant-state, read left to right from general to specific: color-text-danger-hover, space-inline-sm, border-radius-lg. It sorts sensibly in an editor's autocomplete, which is a small thing that matters daily.

Spacing should be a scale, not a free choice. A 4-pixel base with steps at 4, 8, 12, 16, 24, 32, 48, 64 covers essentially every layout. The value of the scale is not mathematical elegance; it is that there is no decision to make, so two developers on two screens produce the same rhythm without talking to each other.

Tokens as a build artefact

For anything beyond one web application, tokens live in a source of truth — usually JSON — and a build step generates the outputs each consumer needs: CSS custom properties for the web, a TypeScript object for typed access, and whatever the mobile platforms need.

ts
// Generated. Type-safe access, with the compiler catching a typo.
export const tokens = {
  color: { action: 'var(--color-action)', danger: 'var(--color-danger)' },
  space: { inlineSm: 'var(--space-inline-sm)' },
} as const;

The point of generating rather than hand-maintaining is that the design tool, the web application and the mobile application cannot drift, because they are outputs of the same file.

2. Component APIs: the choices that decide whether it gets used

A design system component that is hard to use gets copied and modified, and then you have two.

Variants and sizes, not booleans

tsx
// Combinatorial explosion, and nothing stops isPrimary + isDanger.
<Button isPrimary isLarge isDanger />        

// One axis each, invalid states unrepresentable.
<Button variant="primary" size="lg" tone="danger" />   

With union types (Chapter 3.7.2), the second form makes variant="primry" a compile error and makes "primary and danger at once" impossible to write. This is the same modelling argument as Chapter 9.4.14's state machine: make the invalid state unrepresentable rather than documenting that it is wrong.

Forward the rest, and forward the ref

tsx
type ButtonProps = {
  variant?: 'primary' | 'secondary' | 'ghost';
  size?: 'sm' | 'md' | 'lg';
} & React.ButtonHTMLAttributes<HTMLButtonElement>;   // (1)

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  function Button({ variant = 'primary', size = 'md', className, ...rest }, ref) {
    return (
      <button
        ref={ref}                                     // (2)
        className={cx(styles.base, styles[variant], styles[size], className)}  // (3)
        {...rest}                                     // (4)
      />
    );
  },
);

Line (1) inherits every native button attribute, so type, disabled, aria-*, form and every event handler work without you listing them.

Line (2) forwards the ref, so a caller can focus the button or measure it. A component that swallows the ref cannot be used inside a tooltip, a popover or a form library, and that is the most common reason a system component gets abandoned.

Line (3) appends the caller's className last, so they can override. Line (4) spreads the rest.

These four lines are the difference between a component people use and a component people work around. Everything else is styling.

Polymorphism, used sparingly

tsx
<Button as="a" href="/checkout">Checkout</Button>

Useful, because a link that looks like a button should still be a link — it must be right-clickable, openable in a new tab, and announced as a link. The cost is that typing polymorphic components correctly in TypeScript is genuinely awkward. Prefer a small number of purpose-built components (Button, LinkButton) over one component that can be anything.

Where to be closed and where to be open

Closed components take data as props and render fixed markup: <Button>, <Badge>, <Spinner>. Right when there is one correct structure.

Open components let the caller compose the parts, using the compound pattern from Chapter 6.4.4: <Tabs>, <Table>, <Menu>. Right when callers legitimately need to put things in unforeseen places.

The failure mode of a closed component is prop creep — showIcon, iconPosition, headerActions, footerVariant — each added for one screen. When a component reaches its fifth layout-shaping prop, it wanted to be open.

Headless components, and why the hard part is not the visuals

A headless component library provides behaviour and accessibility with no styling. You bring the appearance.

The reason this model won is worth stating precisely: the difficult part of a menu, a dialog or a combobox is not how it looks. A production-quality menu needs:

  • Focus moved into the menu on open and returned to the trigger on close.
  • Arrow keys moving between items, with one tab stop for the whole menu rather than one per item.
  • Typing a letter jumping to the matching item.
  • Escape closing, click-outside closing.
  • aria-expanded, aria-controls, role="menu", role="menuitem", aria-activedescendant.
  • Correct behaviour when the list is empty, when items are disabled, and when it opens near the edge of the screen.

That is several hundred lines of behaviour with a long tail of edge cases, and it is the same in every application. Reimplementing it is one of the least valuable things a product team can spend a month on, and the result is almost always less accessible than the library. Take the behaviour, write the styles.

Shadow DOM, and when it earns its cost

If a design system must serve applications written in different frameworks, web components with a shadow root are the strongest form of isolation:

js
class PriceBadge extends HTMLElement {
  connectedCallback() {
    const root = this.attachShadow({ mode: 'open' });     // (1)
    root.innerHTML = `
      <style>span { color: var(--color-action); }</style> <!-- (2) -->
      <span part="value"><slot></slot></span>             <!-- (3) -->
    `;
  }
}
customElements.define('price-badge', PriceBadge);

Line (1) creates a real boundary: styles inside cannot leak out, and outside selectors cannot reach in. This is stronger than the naming conventions in Chapter 6.2.2, because it is enforced by the platform rather than by discipline.

Line (2) shows the deliberate hole in the boundary — custom properties pierce it, which is exactly what makes token-based theming still work.

Line (3) <slot> projects the caller's content (the same idea as Angular's <ng-content> from Chapter 6.5.2), and part exposes a named hook the outside can style with ::part(value).

The costs are real and you should know them before choosing this. Forms are the sharpest: a form control inside a shadow root does not participate in an outer <form> unless you implement the form-associated custom element interface. Server rendering is more involved. Accessibility relationships that cross the boundary — aria-labelledby pointing at an id outside — do not work. And a global stylesheet cannot fix anything inside, which is the point and is occasionally infuriating.

Use it for a genuinely multi-framework system. Do not use it for a single React application, where you pay all the costs for isolation you could get from a naming convention.

3. The layout archetypes

Almost every application is one of about six shapes. Recognising which one you are building saves you from inventing a layout.

The three-pane

Navigation rail, a list, and a detail view. This is a mail client, a chat application, a settings screen, an issue tracker, and the shape most modern tools use.

css
.workspace {
  display: grid;
  grid-template-columns: 64px 320px 1fr;    /* rail · list · detail */
  grid-template-rows: 100svh;               /* svh, not vh — Chapter 6.2.3 */
}
.rail, .list { overflow-y: auto; }
.detail      { overflow-y: auto; }

The two rules that make it work:

Each pane scrolls independently, so the rail and list stay put while the detail scrolls. That means overflow-y: auto on each pane and no page-level scroll at all.

The URL determines which item is selected, not component state. /inbox/message-8891 means the list highlights that row and the detail shows it. This is the Chapter 6.4.3 point again, and here it has a specific payoff: the layout collapses correctly on mobile because "which pane is showing" is derived from the URL, so the back button naturally goes from detail to list.

css
@media (max-width: 820px) {
  .workspace { grid-template-columns: 1fr; }
  /* Show exactly one pane; which one is decided by the route. */
  .workspace[data-view="list"]   .detail { display: none; }
  .workspace[data-view="detail"] .list   { display: none; }
}

The application shell

A fixed header, an optional sidebar, a scrolling content area, and a footer. The grid version is in Chapter 6.2.4. The detail that matters is that only the content area scrolls, which keeps the header available and makes the layout feel like an application rather than a document.

Master-detail with a long list

The three-pane shape where the list has ten thousand rows. The list must be virtualised — only the visible rows in the DOM — which Chapter 6.9 covers, and which changes how you handle scroll restoration and "scroll to the selected item".

The dashboard grid

Cards of different sizes on a responsive grid, with repeat(auto-fill, minmax(…, 1fr)) and explicit spans for the wide ones. grid-auto-flow: dense backfills gaps — with the tab-order warning from Chapter 6.2.4.

The resizable split

Two panes with a draggable divider. The implementation uses setPointerCapture from Chapter 6.3.1 so the drag keeps working when the pointer leaves the divider, and it should write the position to a CSS custom property rather than to inline styles on both panes:

ts
divider.addEventListener('pointerdown', (e) => {
  divider.setPointerCapture(e.pointerId);        // (1) all further events come here
  const onMove = (ev: PointerEvent) => {
    // (2) One custom property; the grid template reads it.
    container.style.setProperty('--split', `${ev.clientX}px`);
  };
  divider.addEventListener('pointermove', onMove);
  divider.addEventListener('pointerup', () => {
    divider.releasePointerCapture(e.pointerId);
    divider.removeEventListener('pointermove', onMove);
    savePreference(container.style.getPropertyValue('--split'));   // (3)
  }, { once: true });
});

Line (1) is what stops the drag breaking when the pointer moves fast. Line (2) writes one property that grid-template-columns: var(--split) 4px 1fr reads, so one write updates both panes. Line (3) persists it, because a user who resizes a pane expects it to stay resized. Also give the divider a keyboard interface — arrow keys with role="separator" and aria-valuenow — or it is unusable without a mouse.

The overlay layer

Modals, drawers, toasts and popovers all live above everything, and Chapter 6.2.5 explained why they must not be nested inside a transformed or clipped container. Render them at the root, or use the platform's top layer, which Chapter 6.8.2 covers.

4. Pagination interfaces

Three shapes, and the choice is a product decision with a technical consequence.

Numbered pages are right when users need to know how much there is, jump around, or return to a specific place — search results, an admin table, a document archive. They require a total count, which on a large table is an expensive query, and that cost is the usual reason teams abandon them.

A "load more" button is right for a browsing feed where nobody needs page 7. It keeps the footer reachable, it is trivially accessible, and the user stays in control.

Infinite scroll is right for a continuous consumption feed and wrong for almost everything else. Chapter 6.3.2 built one correctly; the product-level costs are that the footer becomes unreachable, the back button loses position unless you restore it explicitly, and the user has no sense of how much remains.

Three rules regardless of shape:

The page or cursor belongs in the URL. Otherwise a shared link, a bookmark and the back button all show the wrong thing.

Use cursor pagination, not offsets, for anything that changes — Chapter 9.6.2 explains why an insert at the top makes offsets duplicate and skip rows.

Announce the change. When new rows append without a page load, a screen reader user is told nothing. An aria-live="polite" region saying "20 more results loaded, 60 of 340" is a two-line fix that makes the pattern usable.

And for restoring position on back: save the scroll offset and the loaded page count in history state, and on restore, render the same number of items before scrolling. Scrolling to an offset that does not exist yet does nothing, which is why "back goes to the top" is such a persistent bug.

5. Permission-driven rendering

The rule first, because everything else follows from it:

The interface hides things for usability. The server decides what is allowed. These are different jobs, and the client's version is not security.

Hiding a "Delete" button stops an ordinary user from being confused. It does nothing about anyone who opens the network tab, and the request must be rejected on the server (Chapter 9.9.3 and Chapter 8.1 for the mindset). Every permission check in the client is a duplicate, for the user's benefit, of a decision made authoritatively elsewhere.

Send capabilities, not roles

ts
// Fragile: the rule lives in the client and drifts from the server's version.
if (user.role === 'admin' || user.role === 'manager') showDeleteButton();   

// Durable: the server sends what this user can do with this resource.
if (order.permissions.includes('order:cancel')) showCancelButton();         

The first version breaks the day someone adds a regional-manager role, or the day cancellation becomes disallowed after dispatch — a rule that has nothing to do with roles. The client should not contain the policy, because then the policy exists in two places and they will disagree.

Having the server attach a permissions array to each resource it returns is the pattern that scales: the rule stays in one place, the client just reads a list.

tsx
function Can({ do: action, on: resource, children, fallback = null }: CanProps) {
  return resource.permissions.includes(action) ? <>{children}</> : <>{fallback}</>;
}

<Can do="order:cancel" on={order}>
  <Button tone="danger" onClick={cancel}>Cancel order</Button>
</Can>

Hidden or disabled?

Hide when the action is irrelevant to this user — showing it only creates questions they cannot answer.

Disable with an explanation when the action is relevant but not currently possible: "Cannot cancel after dispatch". A bare disabled button with no reason is worse than either option, and a disabled button is also not reachable by keyboard, so the explanation must be in text, not a title tooltip.

Route guards and the flash

A guard that redirects unauthorised users must not let the protected content render first. That produces a visible flash of a page the user should not have seen, and if the data was already fetched, it is now in their browser regardless of what happened next.

The fix is to resolve permission before rendering — in a route loader, in a server component, or with a pending state that renders nothing but a skeleton until the check completes. Never render the real content and then redirect.

What the interviewer will push on

"How do you structure design tokens?" Three tiers: primitives, semantic, component. The semantic tier is the one that matters, because it is what makes theming a configuration change instead of a search-and-replace. A candidate who only describes a colour palette has described tier 1.

"What makes a component library component actually reusable?" Forwarding the ref, spreading the rest props, extending the native element's attribute type, and appending the caller's className. Those four make the difference between adoption and workaround. Then variants as unions rather than booleans, so invalid combinations cannot be written.

"Why use a headless component library?" Because the hard part of a menu or a dialog is focus management, roving tab index, keyboard interaction and ARIA relationships — not the appearance. It is the same in every product and reimplementing it is a month with a worse result.

"When is Shadow DOM worth it?" A design system serving multiple frameworks. Then name the costs unprompted: form participation, server rendering, ARIA relationships across the boundary. Custom properties still pierce it, which is what keeps theming working.

"Design a three-pane layout that works on mobile." Grid with three columns, each pane scrolling independently and no page scroll, and the selected item in the URL — that last part is what makes the mobile collapse and the back button work for free.

"Infinite scroll or pagination?" A product question. Numbered pages when users need to navigate and know the size; load-more for browsing; infinite scroll only for continuous feeds, and then name its costs — unreachable footer, lost scroll position, no sense of progress. Add the accessibility requirement of a live region.

"How do you handle permissions in the frontend?" The interface hides for usability; the server decides. Send capabilities per resource, not roles, so the policy lives in one place. Hide when irrelevant, disable with a stated reason when temporarily impossible, and resolve the check before rendering so there is no flash.

One thing to volunteer: point out that a component which swallows the ref cannot be used inside a tooltip, a popover or a form library, and that this is the most common reason a design-system component gets copied and modified. It is a small API detail with an organisational consequence, which is exactly what design system work is.

Recall

  • Tokens come in three tiers: primitive (names a value) → semantic (names a decision) → component. The semantic tier is what makes theming a configuration change, because custom properties inherit and one [data-theme] block re-themes everything.
  • Use a spacing scale (4/8-based). Its value is that there is no decision to make, so two developers produce the same rhythm without talking.
  • A reusable component forwards the ref, spreads the rest, extends the native attribute type, and appends the caller's className. A swallowed ref is the most common reason a component gets copied instead of used.
  • Variants as unions, not booleans — invalid combinations become unwritable.
  • Closed components for one correct structure; open/compound when callers need to place things. The fifth layout-shaping prop means it wanted to be open.
  • Headless libraries win because the hard part is behaviour: focus return, roving tab index, type-ahead, Escape, click-outside, and the full ARIA set — identical in every product and expensive to get right.
  • Shadow DOM is real platform-enforced isolation, and custom properties pierce it so tokens still work. Costs: form participation, server rendering, cross-boundary ARIA. Worth it for multi-framework systems only.
  • Layout archetypes: three-pane (independent pane scrolling, no page scroll, selection in the URL so mobile collapse and Back work for free) · app shell · virtualised master-detail · dashboard grid · resizable split (setPointerCapture, one custom property, plus a keyboard interface) · overlay layer at the root.
  • Pagination: numbered when users navigate and need the size; load-more for browsing; infinite scroll only for continuous feeds — it costs the footer, the back position and any sense of progress. Always: state in the URL, cursors not offsets, and an aria-live announcement.
  • The interface hides for usability; the server decides. Send capabilities per resource, not roles, so policy lives in one place. Hide when irrelevant, disable with a stated reason when temporary, and resolve guards before rendering to avoid a flash of protected content.

Self-test: Why does skipping the semantic token tier make dark mode a project? · Which four API details decide whether a component gets adopted? · What is actually hard about building a menu? · What makes a three-pane layout collapse correctly on mobile? · Why send capabilities rather than roles? · Why is a hidden button not a security control?

Next: 6.8.2 works through the interactions that are genuinely difficult to build correctly — an upload that survives a dropped connection, a session timeout that is honest about what it protects, animated view transitions, and the service worker that makes a page work with no network at all.