Custom Properties as an API Contract for Components
AI generated
{ }
@
CSS · Custom Properties · Design Systems · API Design
Custom Properties as an API Contract
CSS variables as a public component interface

A component that exports custom properties by accident rather than by design creates an invisible coupling between consumers and internal implementation details. Anyone who instead treats custom properties like a real API, with a clear namespace, documented fallbacks and version stability, makes design systems safely extensible across team boundaries.

18 min read Custom Properties · Design Systems · API Stability CSS 2026 · Framework agnostic

1. Why custom properties are an API, not an implementation

Custom properties are treated in most projects as a pure implementation detail: a color gets extracted into a variable because it is convenient, not because an interface for consumers was deliberately designed. The problem only shows up later, when another team, another project or a theming system tries to adjust a component from the outside by overriding exactly this custom property, and discovers the internal name can change at any time because it was never intended as a stable interface.

The decisive mental shift: as soon as a custom property is set or read from outside its own component, whether by a theming layer, a consuming team or another component, it is de facto part of that component's public API. Just as a PHP method with public visibility should not be renamed without notice, a publicly used custom property should meet the same stability standard as any other public interface in software design.

This article treats custom properties consistently as an API contract: with namespace conventions that separate private from public values, with documented fallback values, with a strategy for breaking changes, and with test methods that catch an accidental contract violation early. The goal is a design system in which consumers of a component can rely on its custom properties just as much as on a documented function signature.

2. Namespace: separating private and public custom properties

The first step toward a solid API contract is a consistent naming convention distinguishing public from private custom properties. A proven pattern: public properties follow the scheme --component-property, for example --button-bg, while private, purely internally computed values carry an additional prefix like --_button-bg-computed. This convention makes it clear from the name alone whether a consumer may safely override a property, without consulting separate documentation.

Underscore prefixes are not technically enforceable in CSS like a private keyword in a programming language, they are purely convention. Precisely because of that, this convention must be documented and enforced through code review within the team. A linting ruleset that checks whether underscore prefixed properties are ever referenced outside their defining file catches violations of this contract early, before they solidify into implicit dependencies.


/* Public API: stable, documented, safe for consumers to override */
.c-button {
  --button-bg: var(--color-brand-500);
  --button-fg: white;
  --button-radius: 0.5rem;

  /* Private, computed internally, name signals "do not touch" */
  --_button-bg-hover: color-mix(in oklch, var(--button-bg) 85%, black);

  background: var(--button-bg);
  color: var(--button-fg);
  border-radius: var(--button-radius);
}

.c-button:hover {
  background: var(--_button-bg-hover);
}

/* Consumer safely overrides the public contract, never the private detail */
.c-button--danger {
  --button-bg: var(--color-danger-500);
}

3. Fallbacks as part of the contract

An often overlooked part of the API contract is the fallback value inside var(). The syntax var(--button-radius, 0.5rem) does not just define an emergency exit for the case where the property is unset, it simultaneously documents the component's default value directly in the code, readable by anyone who opens the rule, without needing to search an external settings file. These custom properties with an explicit fallback work like a function parameter with a default value in a typed programming language.

An API contract with fallback values is more resilient against faulty integration. If a consumer forgets to include the central settings file where --button-radius is defined globally, the component falls back to the local default and stays visually functional, instead of silently running into border-radius: ;. This behavior should be a deliberate part of the contract, not an accidental byproduct of a particular browser implementation.


/* Fallback values document the contract's default behavior in place */
.c-card {
  background: var(--card-bg, #ffffff);
  padding: var(--card-padding, 1.5rem);
  border-radius: var(--card-radius, 0.75rem);
  box-shadow: var(--card-shadow, 0 4px 12px rgb(0 0 0 / 8%));
}

/* Consumer overrides only what differs from the documented default */
.c-card--compact {
  --card-padding: 0.75rem;
}

4. Stability and versioning without build tooling

Unlike a JavaScript library, there is no native semver concept for custom properties, no package manager that emits a breaking change warning. Stability must therefore be created through convention and discipline. A pragmatic approach: a comment block above every component file that explicitly lists the public custom properties, similar to an interface in a typed language, supplemented with a change date and a short description of each property.

For design systems with many consumers, a deprecation period is also worthwhile: an old property remains functional and is internally redirected to the new one, while a console warning, for example via an accompanying lint script, points to the rename. This transition period gives consumers time to adapt their code before the old custom property is finally removed, instead of creating an immediate break without warning.


/**
 * Public API contract for .c-badge (component version 2.1)
 * --badge-bg        background color, default: brand-500
 * --badge-fg        text color, default: white
 * --badge-size      inline padding scale, default: 0.5rem
 *
 * Deprecated (removed in 3.0): --badge-color, use --badge-bg instead
 */
.c-badge {
  --badge-bg: var(--badge-color, var(--color-brand-500));
  background: var(--badge-bg);
  color: var(--badge-fg, white);
  padding-inline: var(--badge-size, 0.5rem);
}

5. Documentation directly in the stylesheet

The most effective documentation of a custom properties contract lives directly next to the code, not in a separate wiki that quickly goes stale. A structured comment block, as shown in the previous section, is sufficient for most teams and has the advantage of being automatically read along during every code review. Larger design systems supplement this with generated documentation, for example using tools like Storybook, which extracts a component's custom properties from comments automatically and displays them in a searchable interface.

It matters that the documentation makes the distinction clear between "this property exists" and "this property is part of the stable contract". A property that only happens to work because it is used somewhere in internal CSS but was never intended as a public interface should be explicitly marked as internal, so no consumer accidentally relies on an implementation detail that disappears without warning during the next refactor.

6. Typing with @property as contract reinforcement

The native @property rule allows assigning an explicit syntax type, an inheritance mode and an initial value to a custom property. For the API contract this means an additional, browser enforced guarantee: a consumer trying to override --badge-size with an invalid value, say a string instead of a length, sees the initial value instead of a silently misinterpreted one. That makes the contract more robust, because failure cases become visible instead of silently wrong.

This typing is especially valuable for properties meant to be animated, because @property tells the browser how to interpolate between two values, for example as a number instead of a plain string. Without this declaration, a CSS transition on a custom property skips the intermediate state entirely, which produces surprising, hard to debug behavior for consumers of the API contract.


/* @property strengthens the contract with a browser-enforced type */
@property --badge-size {
  syntax: "<length>";
  inherits: false;
  initial-value: 0.5rem;
}

.c-badge {
  padding-inline: var(--badge-size);
  transition: padding-inline 200ms ease;
}

/* Invalid consumer override falls back to the initial value, not silent breakage */
.c-badge--broken {
  --badge-size: "not-a-length"; /* browser ignores, uses 0.5rem instead */
}

7. Avoiding and communicating breaking changes

A breaking change on a public custom property usually arises from three patterns: renaming without a transition period, changing the expected value type, or changing the semantics without changing the name, for example when --card-spacing suddenly controls padding instead of margin. Of these three, the silent semantic change is the most dangerous, because neither a linter nor @property detects it, the consumer receives no warning at all, only a visually wrong result.

The most reliable safeguard against accidental breaking changes in the API contract is a visual regression test that renders the component with typical consumer overrides and compares screenshots across versions. A changelog that explicitly lists every change to a public custom property also helps, analogous to an API changelog for a REST interface, including a migration guide in case a rename is necessary.

8. Testing the API: contract tests for CSS

Contract tests for custom properties differ from classic unit tests, because CSS has no assertions in the conventional sense. A practical approach: an automated test that compares the computed style (getComputedStyle) of a component with and without a set public custom property, and ensures a change to the property actually affects the expected visual attribute, no more and no less.

For design systems with many components, it is worth automating a snapshot of the public API itself: a list of all properties starting with the --component- prefix for each component, versioned in the repository. A CI check that compares this list against the current snapshot file on every pull request makes any change to the API contract visible before it reaches the main branch, regardless of whether the change was intentional or a mistake.

9. Public API versus internal implementation compared

The following table contrasts how handling custom properties differs, depending on whether they are treated as a public contract or as a pure internal detail.

Aspect Accidental internal detail Deliberate API contract
Namespace Inconsistent, no pattern --component-property strictly separated from --_private
Fallback values Missing or accidental Documented directly inside var()
Type safety No protection against bad values @property with syntax and initial-value
Breaking changes Unannounced, breaks consumers Deprecation period plus changelog
Testability No automated check Snapshot test of the public property list

The difference between the two columns is usually not a question of CSS syntax, but of organizational discipline. All the tools for a solid API contract already exist natively in CSS, they only need to be applied consistently.

Mironsoft

CSS architecture, design systems and frontend refactoring

Are your custom properties a stable API or an accident?

We review existing design systems for implicit coupling, introduce namespace conventions, and set up contract tests for your public custom properties, so consumers can rely on them.

API audit

Analysis of existing custom properties for implicit, unintended public usage

Namespace migration

Consistent separation of public and private properties with a deprecation plan

Contract tests

CI safeguards against unintentional breaking changes in the design system

10. Summary

Custom properties, once set or read from outside a component, are no longer implementation details but part of a public API, with the same stability requirements as any other software interface. A clear namespace separates public from private values, documented fallbacks make default behavior visible directly in the code, and @property reinforces the contract through browser enforced type safety.

The biggest lever is not new CSS syntax, but organizational discipline: a documented deprecation process, a changelog for every change to a public custom property, and automated contract tests that catch a breaking change situation before it reaches consumers. Design systems that apply this practice consistently allow teams to work independently of each other, without an internal refactoring decision silently breaking someone else's code.

Custom Properties as an API Contract — The essentials at a glance

Namespace

Public properties with a clear prefix, private values marked with an underscore convention.

Fallbacks

var(--property, default) documents default behavior directly in the code.

Type safety

@property with syntax and initial-value protects against silently wrong values.

Stability

Deprecation period, changelog and contract tests prevent unannounced breaking changes.

11. FAQ: Custom Properties as an API Contract

1When does a property become public API?
As soon as it is set or read from outside the component.
2Separate public from private?
Naming convention: --component-property public, --_component-internal private.
3Why do fallback values matter?
Document default behavior in the code, protect against a missing settings file.
4What does @property provide?
Type safety: invalid values fall back to initial-value instead of breaking silently.
5Handling breaking changes?
Deprecation period with redirect, changelog and migration guide.
6Automatically testable?
Yes, via computed style comparisons and versioned snapshot lists in CI.
7Most dangerous breaking change type?
Silent semantic change without a name change, undetected by linter or @property.
8Is the convention enforceable?
No, only through team discipline, reviews and lint rules.
9Where to document it?
Directly in the stylesheet as a comment block, supplemented by generated docs for larger systems.
10Animation without @property?
Transition skips the intermediate state, because the browser does not know the value type.