Design Systems and Component Libraries Questions
Building and scaling reusable design foundations: component architecture, design tokens, pattern libraries, versioning, governance, and adoption across teams. Covers ensuring visual and behavioral consistency, evolving a system without breaking consumers, and the tooling and cross-functional alignment that keep a design system healthy at scale.
Design a robust public API for a Modal component used across multiple products, including a case where closing the modal should first confirm with the user asynchronously (for example, a form with unsaved changes). Walk through the props and variants you'd support for content, size, placement, and backdrop behavior, and how you'd handle keyboard focus so the modal is usable without a mouse. Explain your defaults and how composability, like a fully custom header or footer, should be exposed.
Sample Answer
The API should expose behavior and layout through a small set of variant/size/placement props, content through composable header/footer slots rather than a growing pile of booleans, and treat accessibility (focus trap, focus restoration, correct ARIA role) as a non-optional default, not something a consumer has to opt into. The async unsaved-changes case is handled by making the close contract itself async-aware, rather than bolting a separate confirmation flow on top.
Core API shape
interface ModalProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
variant?: "default" | "dialog" | "panel"; // default: "default"
size?: "sm" | "md" | "lg" | "fullscreen"; // default: "md"
placement?: "center" | "bottom-sheet" | "right"; // default: "center"
backdrop?: "none" | "dim" | "blur"; // default: "dim"
closeOnBackdropClick?: boolean; // default: true
closeOnEsc?: boolean; // default: true
/** Return false (or resolve false) to block the close, e.g. unsaved changes. */
beforeClose?: () => boolean | Promise<boolean>;
ariaLabel?: string;
ariaLabelledBy?: string;
header?: React.ReactNode; // full custom header slot
footer?: React.ReactNode; // full custom footer slot
children: React.ReactNode;
}
Header and footer are typed as ReactNode slots rather than a set of booleans like showCloseButton, showFooterDivider, hasSecondaryAction; a modal with even 5 such booleans has 25=32 possible combinations to reason about and test, most of which are nonsensical. A slot only has to support "whatever content a consumer puts there," which collapses that combinatorial surface to one well-tested seam.
Async close confirmation
async function handleRequestClose(modal: ModalProps) {
if (modal.beforeClose) {
const canClose = await modal.beforeClose();
if (!canClose) return; // stay open; beforeClose is responsible for showing its own confirm UI
}
modal.onOpenChange(false);
}
For the unsaved-changes case, beforeClose opens its own lightweight confirm dialog ("Discard changes?") and resolves true/false based on the user's choice; the base Modal doesn't need to know anything about forms or unsaved state, it just awaits a promise before it actually closes.
Accessibility and focus handling
- Render through a portal at the document root, with
role="dialog"(or"alertdialog"for a confirmation) andaria-modal="true". - On open, move focus to the first focusable element inside the modal (or the modal's own container if no fields exist yet), not left on the trigger.
- Trap
Tab/Shift+Tabcycling within the modal's focusable elements for as long as it's open. Escapetriggers the samehandleRequestClosepath as any other close, so an unsaved-changes guard applies consistently regardless of how the user tried to close it.- On close, return focus to the element that opened the modal, so keyboard-only users don't lose their place in the page.
- Mark background content
inert(oraria-hidden) while the modal is open so screen reader users can't navigate into content behind it.
Trade-offs and pitfalls
The most common regression is handling Escape and the backdrop click as raw onOpenChange(false) calls that bypass beforeClose, which silently defeats the unsaved-changes guard for exactly the interaction path most likely to trigger it by accident. Nested modals are the other sharp edge: without a shared modal stack, two open modals fight over the focus trap, and an Escape press can close both instead of just the top-most one; a stack manager that only lets the top modal respond to Escape and only traps focus for the top modal solves this. Finally, a boolean-heavy API (showFooter, hideCloseIcon, fullWidthFooter...) looks easier to use at first but becomes unmaintainable as products need visual permutations the booleans didn't anticipate; slots cost a little more typing up front and pay it back every time a new layout need shows up.
Your company just acquired four smaller brands, and leadership wants all five brands running on one shared component library while each keeps its own visual identity. Architect a token system and build pipeline that supports this: shared tokens where it makes sense, brand-specific overrides where it doesn't, and as little duplication and developer friction as possible. Walk through how tokens flow from source to the final published packages each brand consumes.
Sample Answer
Direct answer
Layer the token source in three tiers, core tokens shared by all five brands, a brand-override layer that only specifies what differs, and a component-override layer for the rare cases where a brand needs to diverge on one specific component rather than a whole token. Run all three tiers through one build pipeline that emits a versioned package per brand, so shared updates propagate everywhere automatically and brand-specific changes stay isolated.
Structured elaboration
flowchart LR
Core[Core tokens] --> Merge[Brand override layer]
Merge --> CompOverride[Component override layer]
CompOverride --> SD[Style Dictionary transform]
SD --> Web[Web CSS variables]
SD --> IOS[iOS Swift constants]
SD --> AND[Android resources]
Web --> PkgA[npm brand-a package]
Web --> PkgB[npm brand-b package]
Three tiers
- Core tokens (
core.json): spacing scale, grid, and semantic tokens that every brand shares (color.surface.primary,spacing.stack.16). This is the bulk of the token set, most values genuinely don't need to vary by brand. - Brand override layer (
brand-{name}.json): only the diffs, typically the color palette and primary typeface. A brand file with no overrides is empty and simply inherits core. - Component override layer: for the rare case where a brand needs a specific component to diverge beyond what a token override can express (say, Brand C's button always has a pill radius regardless of the shared
radius.mdtoken), an override lives at the component-token level (button.radiusfor Brand C specifically), not by forking the component's code.
Resolution order
A component always resolves component-override, then brand-override, then core, falling back one tier at a time, so a brand with no button override still gets the correct shared default.
Build and publish
- Style Dictionary (or equivalent) merges the three tiers per brand and emits platform artifacts (Web CSS variables, iOS Swift constants, Android XML resources) for each.
- Each brand publishes as its own versioned npm package (
@org/tokens-brand-a), all depending on a shared@org/tokens-corepackage so a core-level fix (say, a spacing bug affecting all five brands) is a single change that flows to every brand's next release.
Worked example
button.background resolution for two brands:
| Tier | Brand A | Brand B |
|---|---|---|
| Core | color.surface.primary = #2563EB | color.surface.primary = #2563EB |
| Brand override | color.surface.primary = #0EA5E9 (Brand A overrides to a lighter blue) | (no override, inherits core) |
| Component override | (none) | (none) |
Resolved button.background | #0EA5E9 | #2563EB |
Brand A's override cascades to button.background automatically because the component references the semantic token, not a hardcoded value, that's the point of the semantic layer: Brand A never had to touch a button-specific token to change its primary color everywhere the semantic token is used.
Trade-offs & pitfalls
Three resolution tiers means tracing a component's final value by eye gets harder as overrides accumulate, without tooling (a "resolve this token for this brand" CLI or Figma plugin) engineers will resort to reading generated CSS output to debug a color, which defeats the point of the abstraction. The most common failure mode at this scale is a brand team patching a component's code directly to fix a visual mismatch instead of adding a token override, that fix works for their build but silently forks the component from the shared library, and the next core update won't reach them. Publishing five separate packages also adds real release coordination overhead versus a single package with a brand prop, that trade-off is worth it here because five independently deployed brands are exactly the case where isolated release cadences (Brand A shipping a fix without waiting on Brand B's QA cycle) matter more than a single unified release.
Your company is moving to a micro-frontend architecture: different product teams will ship independently deployed frontends, and some are on React, others on Angular, with at least one legacy team still on jQuery. All of them need to consume the same shared component library. Propose an architecture that gets components working across every team's stack, addressing compatibility, CSS isolation, token synchronization, safe upgrades, and distribution, and explain how you'd support the legacy team without blocking everyone else.
Sample Answer
Direct answer
Build the component library's core as native Web Components: custom elements (a browser API that lets you define your own HTML tag, like <ds-button>, with its own JavaScript behavior) using Shadow DOM (a scoped, isolated mini-DOM attached to that element so its internal markup and styles can't leak out or be overridden by the page). These are framework-agnostic by construction, then ship thin adapter wrappers for React and Angular that map idiomatic props/events to the underlying element's attributes and custom events. The legacy jQuery team needs no special adapter at all, custom elements work natively in any DOM, jQuery included, they just need a polyfill bundle if the browser is old enough to lack native support.
Structured elaboration
flowchart TD
Tokens[Shared design tokens] --> Core[Web component core with Shadow DOM]
Core --> ReactAdapter[React adapter]
Core --> AngularAdapter[Angular adapter]
Core --> NativeCE[Native custom element]
ReactAdapter --> ReactApp[React team app]
AngularAdapter --> AngularApp[Angular team app]
NativeCE --> JqueryApp[jQuery legacy app]
Compatibility across frameworks
- The core component is a standard custom element, it works anywhere the DOM works, that's what makes it a viable shared substrate across React, Angular, and jQuery simultaneously instead of picking one framework's component model as the source of truth.
- React historically needed a wrapper to translate props to attributes and to bind custom events (
addEventListenerinstead of JSX's syntheticonXhandlers), because React couldn't pass non-string props or listen to DOM CustomEvents directly through JSX. Modern React versions have improved custom-element interop, but a thin adapter is still the safer, explicit choice for a shared library so the mapping is documented in one place rather than left to each consuming team to rediscover. - Angular has first-class custom element support (
CUSTOM_ELEMENTS_SCHEMA), so its adapter is thinner, mostly type definitions for template checking. - jQuery needs nothing beyond the custom element bundle itself and, for older browsers, the custom-elements polyfill.
CSS isolation
- Shadow DOM scopes the component's internal styles so they can't leak out and the host page's styles can't accidentally leak in, which is exactly the isolation guarantee a multi-framework, multi-team environment needs, no team can break another team's component by having a conflicting global class name.
- Design tokens cross the shadow boundary as CSS custom properties, which inherit through Shadow DOM by design, so a token change at
:rootstill reaches inside every component without breaking encapsulation.
Token synchronization
- Tokens publish as CSS custom properties (for the styling layer) and as a parallel JSON/JS manifest (for any adapter or tooling that needs the values outside CSS, e.g. a chart library picking a brand color programmatically).
Safe upgrades and versioning
- Semantic versioning with the option to publish a scoped custom element tag name per major version (e.g.
<ds-button>v1 vs<ds-v2-button>) so two majors can coexist on the same page during a gradual migration, this is the standard technique for letting the legacy team upgrade on their own timeline without blocking teams already on the latest version. - A documented deprecation window and migration guide per breaking change, same discipline as the token pipeline's versioning.
Worked example
Core custom element (simplified):
class DsButton extends HTMLElement {
static get observedAttributes() { return ['tone']; }
connectedCallback() {
this.attachShadow({ mode: 'open' }).innerHTML =
`<style>:host{--btn-bg:var(--color-brand-primary);} button{background:var(--btn-bg);}</style><button><slot></slot></button>`;
this.shadowRoot.querySelector('button').addEventListener('click', (e) =>
this.dispatchEvent(new CustomEvent('ds-click', { detail: { tone: this.getAttribute('tone') } }))
);
}
}
customElements.define('ds-button', DsButton);
React adapter (a custom element tag needs a one-time JSX type augmentation, TypeScript has no built-in knowledge of <ds-button>, or the adapter won't type-check):
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'ds-button': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & { tone?: string };
}
}
}
export function Button({ tone, onDsClick, children }: { tone: string; onDsClick?: (e: CustomEvent) => void; children: React.ReactNode }) {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const el = ref.current;
const handler = (e: Event) => onDsClick?.(e as CustomEvent);
el?.addEventListener('ds-click', handler);
return () => el?.removeEventListener('ds-click', handler);
}, [onDsClick]);
return <ds-button ref={ref} tone={tone}>{children}</ds-button>;
}
jQuery team, no adapter needed:
$('#save-btn')[0].outerHTML = '<ds-button tone="primary">Save</ds-button>';
$(document).on('ds-click', 'ds-button', function (e) { /* handle it */ });
Trade-offs & pitfalls
Shadow DOM's isolation is also its main cost: form participation is awkward (a custom element inside a native <form> doesn't automatically submit its value unless it implements ElementInternals/form-associated custom element APIs), and some third-party CSS-in-JS theming tools assume global stylesheets and don't reach into shadow roots without extra configuration. A second real cost is event-binding verbosity in the React adapter, every custom event needs an explicit addEventListener/removeEventListener pair since JSX's synthetic event system doesn't natively bridge CustomEvents, that boilerplate is the price of true framework-agnostic isolation and is worth documenting once in the adapter rather than leaving every consuming team to rediscover it. Version coexistence via scoped tag names avoids blocking any one team's upgrade, but it means running two copies of the component's CSS and JS on the same page during a migration window, a real (if temporary) bundle-size cost.
Explain composition versus inheritance in UI component architecture, and why composition is generally preferred for component libraries. Name and demonstrate at least three distinct composition patterns you'd reach for in practice, with a short example of each, and describe a situation where inheritance might still be the more acceptable choice.
Sample Answer
Direct answer
Composition, assembling behavior from small independent pieces via props, children, or hooks, is generally preferred over inheritance for UI components because a component tree's shape changes at runtime and across products in ways a fixed class hierarchy can't accommodate without becoming tightly coupled and hard to test in isolation. Inheritance ties a component's behavior to a rigid ancestor chain; composition lets you assemble only the behavior you need.
Structured elaboration
Pattern 1: Compound components (slots). A parent exposes named sub-components that share implicit state via context, giving consumers layout control without prop-drilling every option.
function Card({ children }) {
return <div className="card">{children}</div>;
}
Card.Header = ({ children }) => <div className="card-header">{children}</div>;
Card.Body = ({ children }) => <div className="card-body">{children}</div>;
// usage
<Card>
<Card.Header>Title</Card.Header>
<Card.Body>Content</Card.Body>
</Card>
Pattern 2: Render props. The parent owns shared stateful logic; the consumer controls what gets rendered.
function Disclosure({ children }) {
const [open, setOpen] = useState(false);
return children({ open, toggle: () => setOpen(o => !o) });
}
// usage
<Disclosure>
{({ open, toggle }) => (
<button onClick={toggle}>{open ? "Hide" : "Show"} details</button>
)}
</Disclosure>
Pattern 3: Custom hooks. Cross-cutting logic is extracted without wrapping the tree in an extra component at all, keeping the render output entirely under the consumer's control.
function useDisclosure(initial = false) {
const [open, setOpen] = useState(initial);
return { open, toggle: () => setOpen(o => !o) };
}
// usage
function Panel() {
const { open, toggle } = useDisclosure();
return <button onClick={toggle}>{open ? "Hide" : "Show"}</button>;
}
When inheritance is still the more acceptable choice: when you're extending a genuinely closed, stable base class from a framework or platform that only exposes a class-based extension point, for example a class-based error boundary (React does not currently offer a hook equivalent for componentDidCatch/getDerivedStateFromError), or extending a base Shape class in a canvas-drawing engine where the base class defines a fixed lifecycle (draw(), hitTest()) that every subclass must implement identically. These are cases where the "interface contract" genuinely is the point, not an accident of implementation reuse.
Worked example
Inheritance smell: PrimaryButton extends Button and overrides renderIcon() to inject a custom icon slot. This works until Button's internal render order changes (say, the base class starts rendering a loading spinner before the icon), silently breaking PrimaryButton because the override assumed an internal detail of the parent's implementation, not a public contract.
Compositional equivalent that doesn't have this fragility:
<Button icon={<CustomIcon />}>Save</Button>
Button owns its full render order internally; icon is an explicit, documented prop rather than an overridable internal method, so Button is free to reorder its own internals without breaking any consumer.
Trade-offs & pitfalls
This is the classic fragile base class problem: a change to the parent can silently break every subclass that depended on an implementation detail rather than a documented contract, and in a deep inheritance chain it becomes genuinely hard to trace which of several ancestors set a given behavior. Composition isn't free either: over-composing into many tiny wrapper components adds indirection and can reintroduce prop-threading overhead if state has to be pushed down through several layers of composed pieces, which is the same problem a well-chosen Context or colocated state is meant to solve.
Your design system relies on some advanced frontend features that older browsers don't fully support. How would you ensure cross-browser compatibility across the board? Describe your testing strategy, how you'd handle the gaps in older browsers, theming implications, and how this integrates with component frameworks like React.
Sample Answer
Direct answer
Build on progressive enhancement: feature-detect capabilities rather than checking which browser is asking, layer richer behavior behind a capability check with a working fallback baseline underneath it, and verify the result continuously in CI against a real multi-engine browser matrix rather than trusting that it looks fine on one machine.
Structured elaboration
Testing strategy: run automated tests across the major rendering engines (Chromium, WebKit, Gecko), covering the current and previous major version of each evergreen browser, using a test runner that supports multi-engine execution or a cloud browser lab. Pair this with visual regression snapshots taken per engine, not just per viewport size, since rendering differences (font hinting, native form-control styling) are engine-specific and a viewport-only snapshot suite won't catch them.
Handling gaps in browsers that lag on a newer feature: for CSS, wrap the enhanced behavior in an @supports feature query and keep a working fallback as the unconditional baseline underneath it, never as the only path. For JavaScript APIs, check for the capability (typeof X !== 'undefined' or an in check) before use, and load any polyfill conditionally via a dynamic import only when the check fails, rather than shipping it to every browser unconditionally.
Theming implications: if the token pipeline relies on a newer CSS capability (for example, a color-mixing function to generate tints on the fly), precompute a static fallback value for that token at build time so unsupported browsers still render the intended color, instead of silently failing to apply it at runtime.
React integration: run the feature-detection and any conditional polyfill loading once, at the app's bootstrap point before the first render, not inside individual components. That way every component downstream can assume the capability check has already happened, instead of every component re-checking the same thing.
Worked example
Styling a form field's label differently when its input is invalid, using the relatively new :has() selector, with a fallback for engines that don't yet support it:
/* baseline: works everywhere, driven by a class the form's JS toggles */
.field.has-error .field-label { color: var(--text-error); }
/* enhancement: works without any JS, additive only */
@supports selector(:has(*)) {
.field:has(input:invalid) .field-label { color: var(--text-error); }
}
Both rules produce the identical visual result. Browsers that support :has() get it for free with no JavaScript involved; browsers that don't still work correctly through the class-based fallback, which is present unconditionally rather than only as a last resort.
Trade-offs and pitfalls
Shipping a JavaScript polyfill unconditionally to guarantee parity bloats the bundle for the majority of users who don't need it; always gate it behind the actual capability check. Maintaining an @supports branch effectively doubles the CSS for that rule, so keep the enhanced branch strictly additive, never the only way to reach a working state, so a mismatch between the two branches can't break the fallback. Visual regression suites that only vary viewport size, not rendering engine, will miss exactly the class of bug this strategy is meant to catch.
Unlock Full Question Bank
Get access to all Design Systems and Component Libraries interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.