---
title: 'Component Catalog'
source: 'https://academia.sh/en/courses/design-systems/component-catalog'
course: 'Design Systems'
language: en
updated: '2026-08-19T05:19:54+00:00'
license: 'CC BY-SA 4.0'
---

# Component Catalog

The scope criterion that decides whether a component earns a place in the catalog, the exit conditions for maturity levels, and the audit that ties catalog health to usage share.

Once the token layer is complete, the system's values are named and derivable: a
color has a name, a spacing step has a number, a theme's values compute from the
primitive layer. But components use these values, and the component layer cannot be
derived by computation the way tokens can. What enters the catalog, how ready it is,
and who owns it are separate decisions.

This lesson separates three questions: by what criterion does a component earn a place
in the catalog, what makes a cataloged component "ready," and by what number is a
catalog's health actually judged?

## What the Catalog Is and Is Not

A component catalog is not a list of the components a system offers; it is the
structure that records, for each component, **who owns it, what maturity level it is
at, and what contract it makes**. Twenty components in a repository does not make a
catalog — a catalog is defined by its entry and exit rules.

In a component library without a catalog, every component looks equally trustworthy:
the person using it cannot tell whether it will still look the same in six months or
its name will change tomorrow. A catalog reduces that uncertainty to a single maturity
label per component.

## The Scope Criterion

A catalog's natural tendency is to grow by absorbing everything that appears in the
interface. That tendency has two costs: every component must be documented, versioned,
and migrated, and the larger the catalog gets, the harder it is to search.

The decision to bring a component into scope comes down to three questions:

- **Recurrence.** How many places is the component used in? Something used in a single
  place belongs to the product, not the catalog.
- **Ownership spread.** How many teams use it? A component used by a single team
  evolves faster in that team's own repository; pulling it into the catalog only adds
  review overhead.
- **Decision bearing.** Does the component carry a design decision? The "Borrow" button
  carries the primary-action-color decision; a record row's column order does not — that
  is the product's information-architecture decision.

Two of these three questions can be turned into numbers. The program below takes the
catalog interface system's twenty-two components, derives the maturity distribution,
checks whether each meets its level's criterion, and lists the ones outside the scope
criterion.

```js
// catalog.mjs — component catalog scope and maturity audit

// Each record carries a component's state in the catalog: maturity level, the
// evidence fields that level requires, version, and how many places and teams use it.
const CATALOG = [
  { name: "button",              level: "stable",      doc: true,  example: true,  counterExample: true,  test: true,  access: true,  migrationNote: false, version: "2.3.0", usage: 412, team: 6 },
  { name: "link",                level: "stable",      doc: true,  example: true,  counterExample: true,  test: true,  access: true,  migrationNote: false, version: "1.4.2", usage: 268, team: 6 },
  { name: "text-field",          level: "stable",      doc: true,  example: true,  counterExample: true,  test: true,  access: true,  migrationNote: false, version: "2.0.1", usage: 143, team: 5 },
  { name: "checkbox",            level: "stable",      doc: true,  example: true,  counterExample: false, test: true,  access: true,  migrationNote: false, version: "1.2.0", usage:  96, team: 5 },
  { name: "radio-group",         level: "stable",      doc: true,  example: true,  counterExample: true,  test: true,  access: false, migrationNote: false, version: "1.1.0", usage:  34, team: 4 },
  { name: "search-field",        level: "stable",      doc: true,  example: true,  counterExample: true,  test: true,  access: true,  migrationNote: false, version: "1.6.0", usage:  71, team: 4 },
  { name: "badge",               level: "stable",      doc: true,  example: true,  counterExample: true,  test: true,  access: true,  migrationNote: false, version: "1.0.3", usage: 188, team: 6 },
  { name: "card",                level: "stable",      doc: true,  example: true,  counterExample: true,  test: true,  access: true,  migrationNote: false, version: "1.3.1", usage: 157, team: 6 },
  { name: "breadcrumb",          level: "stable",      doc: true,  example: true,  counterExample: true,  test: true,  access: true,  migrationNote: false, version: "1.0.0", usage:  29, team: 3 },
  { name: "pagination",          level: "stable",      doc: true,  example: true,  counterExample: true,  test: true,  access: true,  migrationNote: false, version: "1.2.4", usage:  41, team: 4 },
  { name: "notification-banner", level: "stable",      doc: true,  example: false, counterExample: false, test: true,  access: true,  migrationNote: false, version: "0.9.0", usage:  63, team: 5 },
  { name: "empty-state",         level: "stable",      doc: true,  example: true,  counterExample: true,  test: true,  access: true,  migrationNote: false, version: "1.1.2", usage:  52, team: 5 },
  { name: "skeleton",            level: "candidate",   doc: true,  example: true,  counterExample: false, test: true,  access: false, migrationNote: false, version: "0.7.0", usage:  38, team: 3 },
  { name: "tabs",                level: "candidate",   doc: true,  example: true,  counterExample: false, test: false, access: false, migrationNote: false, version: "0.5.1", usage:  24, team: 3 },
  { name: "dropdown",            level: "candidate",   doc: false, example: true,  counterExample: false, test: false, access: false, migrationNote: false, version: "0.4.0", usage:  57, team: 4 },
  { name: "accordion",           level: "candidate",   doc: true,  example: true,  counterExample: false, test: false, access: false, migrationNote: false, version: "0.3.2", usage:  17, team: 2 },
  { name: "modal",               level: "candidate",   doc: true,  example: false, counterExample: false, test: true,  access: false, migrationNote: false, version: "0.6.0", usage:  45, team: 5 },
  { name: "tooltip",             level: "experimental", doc: false, example: false, counterExample: false, test: false, access: false, migrationNote: false, version: "0.1.0", usage:  12, team: 2 },
  { name: "table",               level: "experimental", doc: false, example: true,  counterExample: false, test: false, access: false, migrationNote: false, version: "0.2.1", usage:   9, team: 2 },
  { name: "record-row",          level: "experimental", doc: false, example: false, counterExample: false, test: false, access: false, migrationNote: false, version: "0.1.0", usage:   6, team: 1 },
  { name: "filter-panel",        level: "candidate",   doc: true,  example: true,  counterExample: false, test: false, access: false, migrationNote: false, version: "0.3.0", usage:   4, team: 1 },
  { name: "loading-indicator",   level: "deprecated", doc: true, example: true, counterExample: true,  test: true,  access: true,  migrationNote: true,  version: "1.0.0", usage:  31, team: 4 },
];

// Exit criterion for each level: the fields that must be filled to remain at that level.
const CRITERIA = {
  experimental: [],
  candidate:    ["doc", "example"],
  stable:       ["doc", "example", "counterExample", "test", "access"],
  deprecated:   ["doc", "migrationNote"],
};

// Stable level additionally requires version 1.0.0 or above.
const majorVersion = (s) => Number(s.split(".")[0]);

const LEVELS = ["experimental", "candidate", "stable", "deprecated"];
const LABEL = { experimental: "experimental", candidate: "candidate", stable: "stable", deprecated: "deprecated" };

const totalUsage = CATALOG.reduce((t, b) => t + b.usage, 0);

console.log("maturity level   components   usage   usage share");
for (const d of LEVELS) {
  const group = CATALOG.filter((b) => b.level === d);
  const k = group.reduce((t, b) => t + b.usage, 0);
  console.log(
    `${LABEL[d].padEnd(17)} ${String(group.length).padStart(9)} ${String(k).padStart(9)} ` +
    `${((k / totalUsage) * 100).toFixed(1).padStart(11)}%`
  );
}
console.log(`${"total".padEnd(17)} ${String(CATALOG.length).padStart(9)} ${String(totalUsage).padStart(9)} ${"100.0".padStart(11)}%`);

// Components that fail the criterion for their level.
console.log("\ncomponent             level         missing criterion");
let violation = 0;
for (const b of CATALOG) {
  const missing = CRITERIA[b.level].filter((field) => !b[field]);
  if (b.level === "stable" && majorVersion(b.version) < 1) missing.push(`version<1.0.0 (${b.version})`);
  if (missing.length === 0) continue;
  violation++;
  console.log(`${b.name.padEnd(21)} ${LABEL[b.level].padEnd(13)} ${missing.join(", ")}`);
}
console.log(`components failing their criterion: ${violation}/${CATALOG.length}`);

// Scope criterion: a component earns a place in the catalog only when used by at
// least two teams and in at least eight places. Below that it belongs to the
// product, not the catalog.
const TEAM_THRESHOLD = 2;
const USAGE_THRESHOLD = 8;
console.log("\noutside the scope criterion (team < 2 or usage < 8)");
console.log("component             team   usage");
for (const b of CATALOG) {
  if (b.team >= TEAM_THRESHOLD && b.usage >= USAGE_THRESHOLD) continue;
  console.log(`${b.name.padEnd(21)} ${String(b.team).padStart(4)} ${String(b.usage).padStart(10)}`);
}

// One-line health indicator: how much of the usage falls on stable components.
const stableUsage = CATALOG.filter((b) => b.level === "stable").reduce((t, b) => t + b.usage, 0);
const passingCriterion = CATALOG.filter((b) => {
  const missing = CRITERIA[b.level].filter((field) => !b[field]);
  return missing.length === 0 && !(b.level === "stable" && majorVersion(b.version) < 1);
});
const passingUsage = passingCriterion.reduce((t, b) => t + b.usage, 0);
console.log(`\nusage share at stable level        : ${((stableUsage / totalUsage) * 100).toFixed(1)}%`);
console.log(`usage share of components passing criterion : ${((passingUsage / totalUsage) * 100).toFixed(1)}%`);
```

```
maturity level   components   usage   usage share
experimental              3        27         1.5%
candidate                 6       185        10.3%
stable                   12      1554        86.5%
deprecated                1        31         1.7%
total                    22      1797       100.0%

component             level         missing criterion
checkbox              stable        counterExample
radio-group           stable        access
notification-banner   stable        example, counterExample, version<1.0.0 (0.9.0)
dropdown              candidate     doc
modal                 candidate     example
components failing their criterion: 5/22

outside the scope criterion (team < 2 or usage < 8)
component             team   usage
record-row               1          6
filter-panel             1          4

usage share at stable level        : 86.5%
usage share of components passing criterion : 83.6%
```

## Maturity Levels

The output's first block shows the four levels; what they mean comes from how
different a promise each makes:

- **Experimental.** The system says this component's shape and name may still change,
  and the team using it absorbs that change itself. Documentation is not required;
  only the component's level must be visible.
- **Candidate.** The shape has settled but the contract is not yet locked. Documentation
  and at least one example are required; a breaking change can still happen, but it is
  announced.
- **Stable.** The contract is locked; a breaking change can only arrive with a major
  version bump and a migration path. This promise ties to the output's five evidence
  fields: documentation, example, counter-example, test, and accessibility review.
- **Deprecated.** The component should no longer be used but still works. This level's
  criterion is the migration note: what to use instead must be written down.

Levels are defined not as labels but as **exit criteria**: saying "this component is
stable" claims the five listed fields are filled. The output's second block shows that
claim failing in three places — `checkbox` has no counter-example, `radio-group` has
not passed accessibility review, and `notification-banner` is marked stable with three
gaps at once. These rows are not a bug list but a **demote-or-complete** work list, one
the criterion alone made possible.

Level transitions are not one-directional: a component that stays stable while failing
accessibility review can be demoted to candidate. A demotion differs from a breaking
change — the component still works, but the system's promise of stability has been
withdrawn.

## What Falls Outside Scope

The third block runs the scope criterion: `record-row` and `filter-panel` are used by
a single team and fewer than eight places. Both are parts of the catalog interface
itself and belong to the product, not the system.

This resolves a confusion born from the system first being built inside a product:
because it grew out of the catalog interface, every part of the product looks like a
natural candidate at first glance. But `record-row` decides which fields of a
bibliographic record are shown in what order, and that decision cannot be reused
elsewhere. The badge, button, and link inside that row, by contrast, can be.

The thresholds themselves are a decision that scales with the organization: in a
two-team organization, "at least two teams" eliminates almost nothing; in a ten-team
organization it could be raised to three. The criterion's value lies not in the number
but in the reasoning behind it being written down.

## Not Measuring the Catalog by Component Count

The last two lines give two numbers for the catalog's health: 86.5% of usage falls on
stable components, and 83.6% falls on components that genuinely meet the criterion for
their level. The 2.9-point gap is the portion where the "stable" label goes unbacked.

These two numbers say something different from component count. Three of the
twenty-two components are experimental, but their usage share is only 1.5% — the
catalog's immature portion touches only a small slice of the interface. A report
measuring by component count would say "13.6% of components are experimental," making
the same catalog look riskier.

The reverse can also happen: `dropdown` is an undocumented candidate used in 57 places
— on its own, more than all experimental components combined. A usage-weighted
indicator brings it to the front; a count-based one would rank it equally with the
other five candidates. Usage weighting is what sorts the catalog's work list.

## Summary

- A component catalog is not a component list; it is the structure that records each
  component's owner, maturity level, and contract.
- The scope decision comes down to recurrence, ownership spread, and decision bearing;
  the first two can be turned into thresholds and filter out product-specific
  components.
- Maturity levels are not labels but exit criteria: each level is defined by the
  evidence fields it requires to be filled, and a level can change in either direction.
- Catalog health is measured by usage share, not component count; unweighted counting
  makes the same catalog look riskier or safer than it is.
- The list of components that fail their criterion turns directly into a work list once
  it is sorted by usage weight.

## Next Step

The scope and maturity decisions rested on an assumption: that when a component is
named, the designer and the developer mean the same thing. Whether `dropdown` has a
counterpart in the design file, or whether `tooltip` is the same component as a "hint
bubble" in some product, cannot be read from this table. The next lesson establishes
the naming convention: it ties design names to code names with a rule, writes a script
that checks it, and counts the mismatched names on both sides.
