< Go back

Mastering Component Architecture: Design Systems That Don't Break

12/6/2026 · 13 min read

Most design systems don't fail because of bad design. They fail because of bad architecture. A button component that looked clean at launch becomes unmaintainable after 18 months of edge cases, new product requirements, and accumulated workarounds.

This article is about the structural decisions — in both Figma and code — that determine whether a component system stays coherent as it grows. Props API design, composition vs configuration, variant architecture, and versioning strategy. The patterns here come from design systems that have survived multi-year product evolution without requiring a full rewrite.

Why Design Systems Break
  • Monolithic components: One Button component with 47 props that handles every edge case. Nobody knows what combinations are valid. Prop interactions are undocumented. Adding a new use case requires touching a component everything depends on.
  • Shared state through style: Components that communicate their state visually (via className manipulation) instead of structurally. When a parent needs to know a child's state, it reads the DOM. This is fragile.
  • Design-code drift: The Figma component has 8 variants. The React component has 12. Nobody knows which are canonical. Designers use Figma variants that don't exist in code. Developers implement props that don't exist in Figma.
  • No versioning: The design system is always "latest". Breaking changes ship without warning. Teams pin to an old commit of the repo. Now there are three diverging versions in production.

Composition Over Configuration

The most important architectural principle: prefer composable primitives over configured monoliths. Instead of one complex component, build small focused components that compose cleanly.

The Monolith Anti-Pattern
// ❌ Monolith: one component, infinite props
<Button
  variant="primary"
  size="lg"
  leftIcon="search"
  rightIcon="chevron"
  isLoading={loading}
  loadingText="Searching..."
  isDisabled={disabled}
  fullWidth={true}
  tooltip="Search for items"
  onClick={handleClick}
/>
// 47 props and counting. What are valid combinations?
// What happens when loading=true AND disabled=true?
The Composition Pattern
// ✅ Composition: focused primitives that assemble
<Button variant="primary" size="lg" onClick={handleClick}>
  <Icon name="search" />
  Search
  <Icon name="chevron-right" />
</Button>

// Loading state is a wrapper concern:
<LoadingOverlay isLoading={loading}>
  <Button variant="primary">Search</Button>
</LoadingOverlay>

// Or a slot-based composition:
<Button variant="primary">
  {loading ? <Spinner size="sm" /> : 'Search'}
</Button>

Composition keeps each component's responsibility small and its props surface minimal. The component doesn't need to know about loading states, tooltips or icons — those are composition concerns, not Button concerns.

Designing the Props API

The props API is the public interface of your component. Once it's in production, changing it is a breaking change. Design it deliberately.

Semantic Over Presentational
// ❌ Presentational: ties API to visual implementation
<Button color="blue" size="40px" borderRadius="8px" />

// ✅ Semantic: describes intent, not appearance
<Button variant="primary" size="md" />

// Why: if the design system changes "primary" from blue to red,
// no consuming team needs to change code. The mapping is internal.
Explicit Over Implicit
// ❌ Implicit: magic string with undocumented valid values
<Badge type="success" />  // What other types exist? Nobody knows.

// ✅ Explicit: typed union, self-documenting
type BadgeVariant = 'success' | 'warning' | 'error' | 'info' | 'neutral';
<Badge variant="success" />
The Polymorphic Component Pattern

Buttons need to render as <a> tags sometimes. List items might be <div> or <li>. The polymorphic pattern handles this without duplication:

// The 'as' prop changes the rendered element
<Button as="a" href="/contact" variant="primary">
  Contact us
</Button>
// Renders: <a href="/contact" class="button button--primary">Contact us</a>

// TypeScript ensures the correct props for each element:
function Button<T extends ElementType = 'button'>({
  as,
  ...props
}: ButtonProps<T>) {
  const Component = as || 'button';
  return <Component {...props} />;
}

Figma Component Architecture That Matches Code

Design-code drift is the silent killer of design systems. The solution is designing Figma components with the same architectural principles as code components.

One Figma Component = One Code Component

Every Figma component should have a direct code equivalent with matching props. If a Figma variant doesn't exist in code, it should be removed from Figma (or added to code). The design system backlog should be the single source of truth for what's canonical.

Variants as Props

Figma variants map directly to component props. A Button with variants variant/primary, secondary, ghost and size/sm, md, lg corresponds exactly to:

type ButtonVariant = 'primary' | 'secondary' | 'ghost';
type ButtonSize = 'sm' | 'md' | 'lg';

interface ButtonProps {
  variant: ButtonVariant;
  size: ButtonSize;
  // ... other props
}
Boolean Variants for States

Figma's boolean variants (Disabled: true/false, Loading: true/false) should map to boolean props in code. Not variant="disabled" — that conflates variant with state. Disabled is a state that overlays any variant.

// ❌ State as variant
<Button variant="primary-disabled" />
// 3 variants × 2 states = 6 combinations. Add another state: 12. Unmaintainable.

// ✅ State as separate prop
<Button variant="primary" disabled />
// 3 variants + n states = 3 + n. Scales cleanly.
Slot-Based Figma Components

In Figma, use nested components as slots. A Card component has a Header slot and a Body slot — each is itself a component. This mirrors the composition pattern in code and prevents the Figma component from becoming a hardcoded monolith.

Versioning: The Thing Nobody Does Until It's Too Late

A design system without versioning is a liability. Every update is a potential breaking change for every team using it.

Semantic Versioning for Design Systems
  • Patch (1.0.x): Bug fixes that don't change the API. A visual glitch fixed, a typo corrected. Safe to auto-update.
  • Minor (1.x.0): New components or new optional props added. Existing usage is unaffected. Safe to update with review.
  • Major (x.0.0): Breaking changes. A prop renamed, a component removed, a variant restructured. Requires migration guide and team coordination.
The Deprecation-Before-Removal Protocol
// Step 1: Mark deprecated in code (one major version warning period)
interface ButtonProps {
  /** @deprecated Use `variant` instead. Will be removed in v4. */
  type?: 'primary' | 'secondary';
  variant?: 'primary' | 'secondary';
}

// Step 2: Runtime warning in development
if (props.type && process.env.NODE_ENV === 'development') {
  console.warn('Button: `type` prop is deprecated. Use `variant` instead.');
}

// Step 3: In Figma — mark deprecated variants with 🚫 prefix
// 🚫 type/primary → replaced by variant/primary

// Step 4: Remove in next major version with migration guide
Changelog as a First-Class Artefact

Every release needs a changelog entry readable by both designers and developers. Not just "fixed button bug" — but what changed, why, and what consuming teams need to do. The changelog is often the most-read document in a design system.

Component Testing Strategy

Design system components need two kinds of tests: functional tests (does it work?) and visual regression tests (does it look right?).

  • Unit tests for behaviour: Test keyboard navigation, ARIA attribute output, event callbacks, prop combinations that should render specific markup. These run fast and catch regressions immediately.
  • Visual regression with Storybook + Chromatic: Every component has stories for every meaningful state. Chromatic snapshots each story. PR merges are blocked until visual changes are approved. This is the most effective way to prevent unintended visual regressions.
  • Accessibility tests: Run axe-core against every component story in CI. Accessibility regressions are the most expensive to fix post-launch and the easiest to catch pre-merge.
// Storybook story with all meaningful states
export const AllButtonVariants: Story = {
  render: () => (
    <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
      {(['primary', 'secondary', 'ghost'] as const).map(variant =>
        (['sm', 'md', 'lg'] as const).map(size => (
          <Button key={`${variant}-${size}`} variant={variant} size={size}>
            {variant} {size}
          </Button>
        ))
      )}
    </div>
  )
};

Conclusion

Design systems that survive long-term share the same characteristics: small composable primitives instead of configured monoliths, semantic props that describe intent, Figma components that map 1:1 to code, strict semantic versioning with a deprecation protocol, and visual regression testing on every PR. None of these are complicated. All of them require deliberate decisions made early. The cost of retrofitting good architecture onto a broken system is always higher than building it right from the start — and the teams that skip it always regret it around the 18-month mark.
Next article

Mastering Design Tokens at Scale →

Would you like to collaborate?

Contact me