Every mature design system eventually discovers a weird law of physics: the better it works, the harder it becomes to change.

At first, changes are easy. A component library has one or two consumers, everyone ships together, and breaking changes are just “Tuesday.” Then adoption spreads. More teams, more surfaces, more release trains, more “small” assumptions buried in thousands of call sites. A single prop rename becomes a negotiation. A subtle focus change becomes a production incident. Maintainers learn that “safest” often means “do nothing,” and the design system enters its final form: permafrost.

The way out isn’t heroics or a rewrite. It’s mechanics.

This article is a migration playbook for senior engineers and tech leads who maintain shared UI libraries: define what “breaking” actually means (spoiler: not just types), publish a deprecation policy with real deadlines, ship codemods that turn weeks of manual edits into minutes, and roll out CI guardrails in phases so you can evolve the library without freezing it.

TL;DR

1) Why successful component systems freeze

Design systems don’t freeze because maintainers lack courage. They freeze because they’re successful.

Success looks like:

That creates a predictable failure mode:

  1. Blast radius grows. A small change can ripple across dozens of products.
  2. Coupling becomes invisible. Consumers depend on “quirks” that were never documented as API.
  3. Ownership becomes lopsided. Maintainers “own” breakages; consumers “own” delivery dates.
  4. Migration work has no home. Consumers treat upgrades as “overhead,” so adoption slows.
  5. Change becomes socially expensive. Every breaking change requires coordination, meetings, and exceptions.

Eventually, maintainers stop shipping meaningful improvements because they can’t predict adoption. Consumers stop upgrading because every upgrade feels like unbounded churn. The system becomes stable in the way a fossil is stable.

A migration playbook turns upgrades into a pipeline with predictable steps. The goal isn’t zero breakage. The goal is controlled, observable breakage with mechanical adoption—so the library can keep evolving without asking the organization to pause.

2) Define what counts as “breaking” (API vs behavior vs a11y)


If your definition of “breaking change” is “TypeScript errors,” your users will still suffer—just later, at runtime.

For shared UI libraries, treat “breaking” as a set of contracts:

API contract (compile-time)

Behavior contract (runtime)

Visual contract (rendering/layout)

Accessibility contract (a11y)

Automation/integration contract (optional but real)

The point isn’t to promise you’ll never change these. The point is to classify the break so you can pick the right mitigation:

When you don’t name these categories, consumers will name them for you—usually in incident reports.

3) Deprecation policy in writing (a contract, not folklore)

A deprecation policy is an API constitution. If it’s not written down, you don’t have policy—you have vibes and tribal memory.

A usable policy answers five questions:

  1. How do we mark deprecations? (types, docs, runtime warnings)
  2. How long do deprecations live? (time window or number of minor versions)
  3. What replaces the deprecated thing? (a direct replacement or a migration path)
  4. How do consumers migrate? (codemod + manual steps for edge cases)
  5. How do we enforce removal? (CI phases and a major-release boundary)

A pragmatic policy you can publish in a README might look like this (in plain language):

Two details matter more than everything else:

Policy is how you turn “please migrate” into “this is how migrations work here.”

4) Build compatibility: adapters, feature flags, and two-speed APIs

Migration-friendly design systems don’t rely on consumers to pause their roadmap. They ship changes in a way that consumers can adopt incrementally.

Adapter layers (compat wrappers)

An adapter keeps old call sites working while internally translating to the new API. Common patterns:

Adapters buy you time. They also give you a place to:

Feature flags / compat modes

Adapters are great for API shape changes. They’re not always enough for behavior changes—especially focus, keyboard navigation, and timing-sensitive interactions.

For behavioral and a11y changes, prefer staged rollout tools:

The goal is to ship the new behavior safely, learn from real usage, and give consumers a way to opt in or temporarily opt out while they validate.

Two-speed API design

Not everything should evolve at the same pace. A migration-friendly library often separates:

If the only way to add capability is to change the core contract, you’ll trigger global migrations too often. Two-speed design reduces how frequently you need “everyone change everything” moments.

Compatibility isn’t about coddling consumers. It’s about buying enough runway for upgrades to become routine.

5) Codemods: the adoption lever that turns weeks into minutes

Most migrations fail because they ask humans to do repetitive edits at scale.

Humans are excellent at design judgment and terrible at bulk refactors across thousands of call sites. Codemods (automated code transformations) fix that mismatch.

Codemods are best at:

Codemods are bad at:

So the maintainers’ job is to design migrations to be codemoddable:

Operationally:

A codemod is more than convenience. It’s the difference between a migration being “eventually” and being “this sprint.”

6) CI guardrails: phased enforcement that doesn’t brick the world

CI is where migrations stop being a suggestion. But enforcement must be staged, or you’ll train teams to avoid upgrades.

A phased model works because it matches organizational reality:

Phase 1 — Warn

Goal: visibility without pain.

Phase 2 — Block new usage

This is the highest-leverage phase.

You don’t need everyone to migrate immediately. You need to stop deprecated APIs from spreading. A “no new usage” gate prevents the problem from getting worse while teams migrate on their schedule.

Implementation ideas:

Phase 3 — Budget down

Once you’ve stopped the bleeding, apply steady pressure:

Goal: predictable progress without panic.

Phase 4 — Remove (major)

Now removal is boring:

The principle: first prevent spread, then enforce reduction. If you skip Phase 2, Phase 3 becomes impossible, and Phase 4 becomes a crisis.

7) Versioning + deprecation timeline (make time visible)

Version numbers don’t reduce pain by themselves. What reduces pain is predictability: consumers should know when something will stop working and how to fix it.

If you use semantic versioning, define what it means for your UI library:

Then publish a deprecation lifecycle that aligns with those releases.

Time ────────────────────────────────────────────────────────────────────>

v1.8           v1.9            v1.10           v1.11            v2.0
 |              |               |               |                |
 |  Ship NewAPI |  Deprecate    |  Block new    |  Budget down   |  Remove
 |  (additive)  |  OldAPI       |  OldAPI usage |  OldAPI usage  |  OldAPI
 |              |               |               |                |
 |--------------|---------------|---------------|----------------|-------->
                 ^ types/docs + dev warn          ^ CI: no-new-uses  ^ hard break
                 ^ codemod released               ^ adoption continues (expected)


What to include in every deprecation notice (in code and docs):

When you make time visible, migrations stop being a surprise and start being a plan.

8) The migration pipeline: warn → block → remove (turn policy into machinery)

A deprecation policy becomes real when it maps to an operational pipeline that maintainers can run repeatedly.

The pipeline is a state machine, not a one-off event:

┌──────────────────────────────────┐
│  Replacement API ships        │
│  (additive, documented)       │
└──────────────┬───────────────┘
               │
               v
┌────────────────────────────────┐
│ Phase 1: WARN                 │
│ - @deprecated in types/docs   │
│ - dev-only warning (once)     │
│ - adapter keeps old working   │
└──────────────┬───────────────┘
               │
               v
┌────────────────────────────────┐
│ Codemod available             │
│ - bulk rewrite + report       │
│ - leaves TODOs for edge cases │
└──────────────┬───────────────┘
               │
               v
┌────────────────────────────────┐
│ Phase 2: BLOCK NEW USAGE      │
│ - CI fails on usage increases │
│ - lint/import restrictions    │
└──────────────┬───────────────┘
               │
               v
┌────────────────────────────────┐
│ Phase 3: BUDGET DOWN          │
│ - CI enforces a decreasing cap│
│ - exceptions are explicit     │
└──────────────┬───────────────┘
               │
               v
┌──────────────────────────────┐
│ Phase 4: REMOVE (major)      │
│ - delete old exports/adapters│
│ - archive migration docs     │
└──────────────────────────────┘


A concrete implementation needs three building blocks:

  1. A deprecation marker that developers see while coding (types/docs).
  2. A runtime warning that catches non-type usage (dev-only, warn once).
  3. A CI gate that shifts from “inform” to “enforce” (block new usage → budget).

Here’s a minimal example of (1) + (2).

// Deprecated component wrapper with type deprecation + dev-only warning.
// Generic example: mapping legacy props to a new API.

import * as React from "react";
import { Button as ButtonV2, type ButtonProps as ButtonV2Props } from "./ButtonV2";

const warned = new Set<string>();

function warnOnce(key: string, message: string) {
  if (process.env.NODE_ENV === "production") return;
  if (warned.has(key)) return;
  warned.add(key);
  // eslint-disable-next-line no-console
  console.warn(message);
}

/**
 * @deprecated Use `ButtonV2` instead.
 * Deprecated since: v1.9. Removal target: v2.0.
 */
export type LegacyButtonProps = Omit<ButtonV2Props, "tone"> & {
  /**
   * @deprecated Use `tone`.
   * Legacy mapping: "primary" -> "brand", "secondary" -> "neutral"
   */
  variant?: "primary" | "secondary";
};

export function LegacyButton(props: LegacyButtonProps) {
  warnOnce(
    "LegacyButton",
    `[DesignSystem] LegacyButton is deprecated (since v1.9). ` +
      `Use ButtonV2 instead. Removal target: v2.0.`
  );

  const { variant, ...rest } = props;

  const tone: ButtonV2Props["tone"] =
    variant === "primary" ? "brand" : variant === "secondary" ? "neutral" : undefined;

  return <ButtonV2 {...rest} tone={tone} />;
}


And here’s a lightweight CI gate idea for Phase 2/3: fail builds when deprecated usage increases (block new) or exceeds a budget (budget down). The scanning can be implemented via ESLint output, TypeScript compiler API, or a fast pattern-based scan—what matters is the behavior.

// scripts/deprecation-gate.js
// CI guardrail: prevent new deprecated usage and optionally enforce a usage budget.
//
// Inputs:
// - DEPRECATION_PHASE: "warn" | "block-new" | "budget"
// - DEPRECATION_BUDGET: number (used in "budget" phase)
// - .deprecations-baseline.json: committed snapshot of deprecated usage counts
//
// The scanner can be implemented via ESLint JSON output, TS compiler API, or simple patterns.

const fs = require("node:fs");
const path = require("node:path");

const BASELINE_PATH = path.join(process.cwd(), ".deprecations-baseline.json");
const PHASE = process.env.DEPRECATION_PHASE || "block-new";
const BUDGET = Number(process.env.DEPRECATION_BUDGET || "0");

function scanDeprecatedUsages() {
  // Replace this with real scanning logic.
  // Return an object: { "LegacyButton": 12, "LegacyModal": 2 }
  return { LegacyButton: 12, LegacyModal: 2 };
}

function readBaseline() {
  if (!fs.existsSync(BASELINE_PATH)) return {};
  return JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
}

function totalCounts(map) {
  return Object.values(map).reduce((sum, n) => sum + n, 0);
}

const current = scanDeprecatedUsages();
const baseline = readBaseline();

const currentTotal = totalCounts(current);
const baselineTotal = totalCounts(baseline);

if (PHASE === "warn") {
  console.log(`[deprecation-gate] WARN: ${currentTotal} deprecated usages detected.`);
  process.exit(0);
}

if (PHASE === "block-new") {
  if (currentTotal > baselineTotal) {
    console.error(
      `[deprecation-gate] FAIL: deprecated usage increased (${baselineTotal} → ${currentTotal}).\n` +
        `Run the codemod and migrate deprecated APIs.`
    );
    process.exit(1);
  }
  process.exit(0);
}

if (PHASE === "budget") {
  if (currentTotal > BUDGET) {
    console.error(
      `[deprecation-gate] FAIL: deprecated usage budget exceeded (${currentTotal} > ${BUDGET}).\n` +
        `Migrate deprecated APIs to get under budget.`
    );
    process.exit(1);
  }
  process.exit(0);
}

console.error(`[deprecation-gate] Unknown DEPRECATION_PHASE="${PHASE}"`);
process.exit(2);


Once you have these mechanics, migrations stop being an emergency project and become part of how the library evolves.

Pitfalls & Fi

xes

Fix: Publish the breaking-change taxonomy (API/behavior/visual/a11y) and align release notes and versioning to it. Consistency beats perfection.

Adoption checklist

 Remove deprecated APIs in a major release after the support window eny?

Conclusion

Migrations fail when they rely on hope: “everyone will read the docs and update their code.” They succeed when you make the safe path the default: explicit deprecation metadata, machine-enforced timelines, and automated fixes that land in PRs.


If you build one thing first, build the pipeline: a token registry (or component contract registry) that can emit warnings, generate changelogs, run codemods, and block merges when policy says it’s time. Once the guardrails exist, the design system can evolve quickly without turning every release into a fire drill.