Versioning & Deprecation Strategies
Ship breaking changes without breaking trust — a practitioner's guide to semver, deprecation cycles, and migration tooling for production design systems.
8 min read
The full lesson
A design system that never changes is a design system nobody is using. Real systems evolve — tokens get renamed, component APIs get refactored, entire patterns get retired. How you manage that evolution determines whether consuming teams see the system as a reliable platform or a liability. Done badly, versioning erodes trust overnight. Done well, it becomes a competitive advantage: teams adopt eagerly because upgrades are predictable and rollbacks are possible.
Why Versioning Is a First-Class Concern
Most design systems start with a single consumer: the product that created them. At that scale, a breaking change is a Slack message and a Monday morning fix. But once a system serves five or ten product teams — let alone external developers — everything changes.
Think about the blast radius of one token rename: color.action.default becomes color.interactive.primary. Every component that references that token must update at the same time, or some components break while others keep working. If that rename ships without warning inside a minor version, teams discover the regression in production. That single event can poison adoption for months.
The core insight: a design system is a published API, not an internal detail. Token names, component prop signatures, slot names, class names, and CSS custom property names are all public surface area. Treat them with the same respect a backend team gives its REST or GraphQL API.
Semantic Versioning as the Foundation
Semantic versioning (semver) uses the format MAJOR.MINOR.PATCH. Every consumer already knows what each number means:
- PATCH (
1.0.0→1.0.1): Bug fixes only. No visible API changes. Safe to adopt without review. - MINOR (
1.0.0→1.1.0): New tokens, components, or props with defaults. Backward-compatible. Consumers can upgrade whenever they like. - MAJOR (
1.0.0→2.0.0): Breaking changes. Removed tokens, renamed props, restructured component APIs, significant visual regressions. Requires intentional migration.
The most important rule: never sneak breaking changes into a minor or patch release. This sounds obvious, but teams violate it constantly. They rationalize that “only one team uses this component” or “it’s just an internal token.” Once you’ve published, you have no reliable way to know who has adopted what.
Pre-1.0 Versioning
Before a system reaches 1.0.0, semver allows instability: 0.x.y releases can contain breaking changes in minors. This is legitimate while the system is still finding its API shape. Just communicate it explicitly. A 0.x badge in your docs tells consumers: “don’t pin us in a critical path yet.”
Deprecation as a Protocol, Not an Afterthought
Deprecation is a handshake between the system team and consumers: “we’re removing this, here’s what to use instead, here’s your runway.” Skipping it — or treating it as a courtesy rather than a commitment — breaks the social contract that makes adoption possible.
A solid deprecation protocol has four phases:
- Announce: Mark the token, prop, or component as deprecated in the current release. Add a
$descriptionnote in DTCG token files and a JSDoc@deprecatedannotation on component exports. Ship a changelog entry. If your system has a Slack channel or digest, announce there too. - Warn at runtime: For component props, emit a
console.warnin development builds when a deprecated prop is used. For tokens, linting rules (CSS custom property lint, Stylelint, ESLint plugins) can flag deprecated token names at CI time. - Provide migration tooling: A deprecation without a migration path just delegates a chore to every consumer. Codemods — automated code transforms using tools like
jscodeshiftorast-grep— handle prop renames and import path changes automatically. Token rename maps can drive automated find-and-replace across design files and codebases. - Remove: After a defined sunset period — typically one to two major versions — remove the deprecated surface. Ship a final reminder in the release notes.
The minimum viable deprecation runway depends on your consumer count and release cadence. A system shipping one major version per quarter should give consumers at least two quarters of warnings. That means the deprecated surface survives through two consecutive major versions before removal.
Changelog Discipline
A changelog is the primary communication channel between the system team and consumers. Lazy changelogs — auto-generated commit messages, or no changelog at all — leave consumers guessing about the impact of each release.
Modern practice follows the Keep a Changelog format, grouped by change type:
## [3.0.0] - 2026-04-15
### Breaking Changes
- Removed `Button` prop `variant="ghost-danger"` (deprecated since 2.2). Use `variant="danger" appearance="ghost"`.
- Renamed token `color.action.default` → `color.interactive.default`. Run the provided codemod.
### Deprecated
- `color.action.hover` is deprecated. Use `color.interactive.hovered`. Removed in v4.
### Added
- New `IconButton` component replacing the `Button` `icon-only` pattern.
- Token `motion.duration.emphasized` for hero transitions.
### Fixed
- `Dialog` focus trap now uses the `inert` attribute, fixing VoiceOver navigation on Safari 17+.
Every breaking change entry must include: what changed, why it changed, what to use instead, and a pointer to migration tooling. Every deprecation entry must include the sunset version.
Migration Tooling
Codemods reduce migration from hours to minutes. The investment is modest; the goodwill is enormous.
For component prop changes, jscodeshift transforms are the standard approach in React ecosystems. Here is a simple codemod for the prop rename example above:
// codemod: button-variant-ghost-danger.js
module.exports = function transformer(file, api) {
const j = api.jscodeshift;
return j(file.source)
.find(j.JSXOpeningElement, { name: { name: "Button" } })
.forEach((path) => {
path.node.attributes.forEach((attr) => {
if (
attr.type === "JSXAttribute" &&
attr.name.name === "variant" &&
attr.value.value === "ghost-danger"
) {
attr.value.value = "danger";
path.node.attributes.push(
j.jsxAttribute(
j.jsxIdentifier("appearance"),
j.stringLiteral("ghost")
)
);
}
});
})
.toSource();
};
For token renames, a JSON rename map fed into a Style Dictionary transform — or a simple find-and-replace script — is enough. Publish codemods in the system’s repository under a codemods/ directory, version-tagged alongside releases.
Do
- Treat token names, component prop signatures, and exported CSS custom property names as public API — never rename them in a minor or patch.
- Publish a codemod alongside every breaking rename so consumers can migrate with a single command.
- Mark deprecated tokens with
$extensions.deprecatedmetadata so linting and build tools surface warnings automatically. - Give consumers at least one full major version (ideally two) of deprecation runway before removing surface area.
- Ship a
MIGRATION.mdfile with every major release documenting every breaking change and its migration path in one place.
Don't
- Treat “no compile error” as proof a change is non-breaking — visual regressions and runtime behavior changes also count.
- Remove a component or token without providing a clear replacement and a documented migration path.
- Auto-generate changelogs from raw commit messages — consumers cannot assess impact from “fix stuff” or “refactor Button.”
- Use a single main branch without tags — consumers need the ability to pin a release and upgrade deliberately.
- Skip the deprecation phase by jumping straight to removal because “nobody uses this” — you cannot know that without usage telemetry.
Multi-Package Versioning
As design systems grow, they often split into multiple packages: a token package, a component library, a utility library, an icons package, a data-viz library. This introduces a key choice: how do you version them relative to each other?
Lockstep versioning means all packages share the same version number. @acme/[email protected], @acme/[email protected], and @acme/[email protected] always release together. Consumers know that matching version numbers are compatible. The tradeoff: a breaking change in one package forces a major bump across all packages, even unchanged ones.
Independent versioning lets packages evolve at their own pace. @acme/tokens might be at 4.1.0 while @acme/components is at 3.5.2. This is more flexible, but it creates a peer-dependency compatibility matrix that must be explicitly documented and tested. Tools like changesets (the standard in monorepos) automate version bumping, changelog generation, and publishing while supporting independent versioning.
Most systems at medium scale (three to eight packages) use lockstep for major versions with independent patches. Breaking changes in any package trigger a coordinated major release across the suite, while bug fixes and additions ship independently.
Peer Dependency Contracts
A component library that depends on tokens should declare that dependency as a peer dependency, not a direct dependency. This prevents consumers from accidentally ending up with two copies of the token package at different versions. The peerDependencies field communicates the compatible range:
{
"peerDependencies": {
"@acme/tokens": ">=3.0.0 <5.0.0"
}
}
Explicitly test the boundaries of that range in CI — at the minimum version, at the current stable, and at an upcoming pre-release — so you catch incompatibilities before consumers do.
Pre-release Channels
Giving consumers access to upcoming changes before a final release is one of the highest-leverage versioning practices. It surfaces integration problems and collects feedback before you lock in a breaking API.
Common channel conventions:
| Channel | Tag | Purpose |
|---|---|---|
| Alpha | 3.0.0-alpha.1 | Early exploration, API shape is not stable |
| Beta | 3.0.0-beta.1 | Feature-complete, integration testing |
| Release Candidate | 3.0.0-rc.1 | Bug fixes only, final QA |
| Stable | 3.0.0 | Production-ready |
Publish alphas and betas on a separate npm dist-tag (for example, next) so consumers cannot install them by accident. Early adopter teams opt in explicitly via npm install @acme/components@next.
Pair pre-release channels with migration guides. A consumer hitting a beta can flag that the codemod misses their pattern — before the stable release ships that gap to everyone.
Usage Telemetry and the Deprecation Decision
Removing a deprecated surface without data is a leap of faith. With usage telemetry, it is an informed decision.
Modern approaches to design system telemetry:
- Import analysis: Static analysis of consumer codebases — via a CLI scanner or a build plugin — reports which components and tokens are imported, at what version, and across how many files. This is the most reliable signal because it counts actual usage rather than downloads.
- Component usage events: Lightweight instrumentation in the component library that fires analytics events when a component renders. Useful for understanding frequency of use, not just presence.
- Storybook usage stats: Storybook’s Chromatic integration provides visibility into which stories — and by inference which components — are actively maintained by consumers.
Telemetry answers the hardest deprecation question: “Is anyone still using this?” Without it, you either over-retain surface area out of fear, or over-remove it and break a team that never responded to the deprecation notice.
Keeping Design Files in Sync
Versioning in code is well-understood. Versioning in design files is harder and often inconsistent — which creates a dangerous gap where the Figma library is on a different conceptual version than the published npm package.
Best practices:
- Mirror semver in Figma library names: Publish separate Figma libraries for each major version. “Acme Design System v3” and “Acme Design System v4” coexist; teams migrate their files deliberately.
- Use Tokens Studio with git-backed token sync: When DTCG token files in the repo are the single source of truth, the Figma library always reflects the state of a tagged git commit — not a manual export.
- Document the design-to-code parity version in Figma file descriptions: Make it trivially easy for designers to know whether their file’s components match what is in production.
- Deprecate Figma components in parallel with code: Hide deprecated components in Figma and add a description pointing to the replacement — the same deprecation protocol as code.