Most cross-platform “component parity” efforts fail in the same boring way: the visuals match, the behavior doesn’t.
The web version blocks double-submits, keyboard interaction is clean, and screen readers announce the right state. The native version looks identical but still fires while “disabled,” announces nothing when it enters a busy state, and has focus behavior that feels like an improvised jazz solo (and not the good kind).
This isn’t because people are careless. It’s because the team accidentally agreed on the wrong unit of truth.
If the “spec” for a component is basically “here are the props,” you have not specified a component. You’ve specified a shape and left the meaning to each implementation. Meaning is where drift lives.
Contract-first components fix that by treating a component as a small, testable system with guarantees: API, events, state transitions, invariants, accessibility semantics, i18n behavior, and performance constraints. You write those down, test them, and then let each platform adapter translate inputs/outputs without inventing new behavior.
TL;DR
- Props-only design drifts because critical behavior lives in event semantics, state transitions, focus rules, and accessibility—not in prop types.
- A component contract defines: API, events, state model, invariants, accessibility guarantees, i18n rules, and performance expectations.
- Implement the contract once in a platform-agnostic core (state machine + semantics), then render via thin adapters.
- Test contracts with state-machine tests (transitions + invariants) plus accessibility assertions (role/name/state/focus guarantees).
- Adapters translate IO and semantics to platform primitives; they must not re-implement behavior.
- Version the contract: breaking changes include behavior, event ordering, defaults, and accessibility guarantees—not just type changes.
1. Why “Props-Only” Thinking Creates Drift Across Platforms
Props are inputs. Real components are interactive systems.
Even a “simple” button has:
- Event semantics: When does activation happen? Pointer up or down? Key down or key up? What about long-press, repeat, or gesture cancellation?
- State: Idle, pressed, focused, pending, error, success, toggled, expanded…
- Invariants: “Disabled means inert,” “busy means no duplicate commits,” “focus must be visible with keyboard/remote.”
- Accessibility semantics: role, accessible name, disabled/busy/expanded state exposure, announcements, focus order, and interaction modalities.
- i18n behavior: RTL layout, long labels, truncation rules, pluralization and formatting ownership.
- Performance constraints: measurement frequency, layout thrash avoidance, stable renders, bounded expensive work.
If you only define props, you’ve defined a configuration surface, not a behavioral contract. Each platform team fills in the blanks differently, often reasonably, and the component slowly becomes multiple incompatible components that happen to share a name.
Drift doesn’t show up as a compile error. It shows up as:
- “It works on web but not on native.”
- “It’s accessible on desktop but not on TV.”
- “It’s fine until latency is high.”
- “It breaks in RTL.”
- “It double-submits if you tap fast.”
Contract-first is how you stop debugging the same conceptual component multiple times.
2. What a Component Contract Actually Is
A component contract is a written and testable description of what the component means and what it guarantees, independent of how it renders on any specific platform.
Think of it like an API contract for UI behavior. The contract should cover:
- API (Inputs): props, slots/children, theming hooks, optional imperative methods (with strict constraints).
- Events (Outputs): event names, payloads, ordering, exactly when they fire, and when they must never fire.
- State model: states, transitions, derived flags, and how async influences state.
- Invariants: rules that must always hold (including timing rules like “single-fire per gesture”).
- Accessibility guarantees: role, accessible name computation, states exposed (disabled/busy/expanded/checked), focusability rules, and announcements.
- i18n rules: text ownership, formatting boundaries, RTL expectations, truncation/wrapping behavior.
- Performance expectations: render stability, measurement frequency, and “no expensive work on hot paths” constraints.
- Adapter boundary: what platform adapters are responsible for and what they are forbidden from doing.
- Versioning rules: what counts as breaking, including behavioral and accessibility changes.
If you can’t test it, it’s not a guarantee. It might still be a goal, but label it honestly.
3. The Contract Template (A Reusable Spec You Can Paste Into a Repo)
A contract only works if it’s easy to write and hard to misinterpret. Here’s a template you can reuse verbatim for any interactive component:
+----------------------------------------------------------------------------------+
| COMPONENT CONTRACT: <ComponentName> |
+----------------------------------------------------------------------------------+
| Purpose |
| - Primary user problem it solves |
| - Non-goals (what it explicitly does NOT do) |
| |
| API (Inputs) |
| - Props: <names, types, defaults, required vs optional> |
| - Children/slots: <what is allowed and how it’s interpreted> |
| - Imperative handle (optional): <methods + constraints + lifecycle rules> |
| |
| Events (Outputs) |
| - onX(payload): when it fires, ordering guarantees, dedupe/idempotency rules |
| - Must NOT fire when: <disabled, pending, invalid state, etc.> |
| |
| State Model |
| - States: <Idle, Focused, Pending, Error...> |
| - Transitions: <event -> next state> |
| - Derived flags: <isDisabled, isBusy, isPressed...> |
| |
| Invariants |
| - Always-true rules (e.g., "disabled => no activation") |
| - Timing rules (e.g., "single-fire per gesture") |
| - Consistency rules (e.g., "busy => exposed as busy to assistive tech") |
| |
| Accessibility Guarantees |
| - Role: <button/switch/textbox/...> |
| - Accessible name: <source of label and precedence rules> |
| - States exposed: <disabled/busy/expanded/checked/...> |
| - Focus: <focusability, focus visibility rules, roving tabindex, etc.> |
| - Announcements: <what is announced, when, and with what priority> |
| |
| i18n Guarantees |
| - Text ownership boundaries (no string concatenation inside component) |
| - RTL rules, truncation, wrapping behavior |
| |
| Performance Expectations |
| - Render stability expectations (memoization boundaries) |
| - Measurement/layout constraints (when measurement is allowed) |
| |
| Adapters |
| - Responsible for: IO translation, semantics mapping, pixels/layout |
| - Must NOT: re-implement state transitions or business rules |
| |
| Versioning Notes |
| - Breaking vs non-breaking changes for this contract |
+----------------------------------------------------------------------------------+
This is not “extra documentation.” This is the thing you can point to when a platform diverges and someone says, “But it’s not in the API.”
4. Core vs Adapter: One Behavioral Truth, Many Renderers
Once you commit to a contract, the architecture becomes clearer and calmer.
- The core implements the contract’s behavior: state transitions, invariants, and the semantic model (including accessibility semantics).
- The adapter renders pixels and translates platform-specific inputs/outputs to/from the core.
If adapters decide behavior (“native needs a special rule”), drift comes back immediately. The core must be the only place where behavioral truth lives.
Here’s the split:
Platform IO Contract Core Platform UI
+---------------------+ +------------------------------+ +---------------------+
| pointer / touch | events | state machine + invariants | viewModel | DOM / Native views |
| keyboard / remote +---------->| event ordering + dedupe rules |---------->| layout + styling |
| assistive tech | | semantics: role/name/state | | platform a11y props |
+---------------------+ +------------------------------+ +---------------------+
^ |
| |
| NO platform APIs
| NO pixels/layout
| NO direct DOM/native refs
|
Adapter translates:
- input events -> contract events
- contract semantics -> platform attributes/props
- pixels/layout -> platform primitives
A useful mantra for code reviews:
Core decides behavior. Adapter decides pixels.
If someone adds a behavioral “if” statement in an adapter, it should feel suspicious by default.
5. Designing the Core: State Model, Events, and Invariants
Contracts become real when you encode them as a state machine (or reducer) plus a semantic view model. Even if you don’t use a state-machine library, you can implement the same discipline with a pure transition function.
A common drift magnet is an async action component (submit, save, purchase, follow, etc.). Contracts for these typically want:
- Disabled means inert (no activation, no async start).
- Pending means deduped (single-fire) and exposed as busy.
- Errors are surfaced consistently and accessibly.
- Event ordering is deterministic.
Here’s a minimal contract-first core with tests for transitions, invariants, and accessibility semantics:
// Contract types (portable)
type PressSource = "pointer" | "keyboard" | "assistive";
type ActionState =
| { tag: "idle" }
| { tag: "pending"; startedAt: number }
| { tag: "success"; settledAt: number }
| { tag: "error"; message: string; settledAt: number };
type ActionEvent =
| { type: "PRESS"; source: PressSource; now: number }
| { type: "RESOLVE"; now: number }
| { type: "REJECT"; message: string; now: number }
| { type: "RESET" };
type ActionProps = {
label: string; // accessible name source
disabled?: boolean; // invariant: disabled => ignore PRESS
onCommit: () => Promise<void>; // adapter initiates side-effect based on core transition
announceOnError?: boolean;
};
// Semantic output for adapters to map to platform a11y + UI
type ViewModel = {
label: string;
isDisabled: boolean;
isBusy: boolean;
a11y: {
role: "button";
name: string;
disabled: boolean;
busy: boolean;
liveMessage?: string; // optional: polite announcement text
};
};
function computeViewModel(state: ActionState, props: ActionProps): ViewModel {
const isBusy = state.tag === "pending";
const isDisabled = !!props.disabled || isBusy; // contract choice: busy implies disabled
return {
label: props.label,
isDisabled,
isBusy,
a11y: {
role: "button",
name: props.label,
disabled: isDisabled,
busy: isBusy,
liveMessage:
props.announceOnError && state.tag === "error" ? state.message : undefined,
},
};
}
// Pure transition function: behavioral truth lives here.
function transition(state: ActionState, event: ActionEvent, props: ActionProps): ActionState {
const blocked = !!props.disabled || state.tag === "pending";
// Invariant: disabled or pending => ignore PRESS (no activation, no duplicates)
if (event.type === "PRESS" && blocked) return state;
switch (state.tag) {
case "idle":
if (event.type === "PRESS") return { tag: "pending", startedAt: event.now };
return state;
case "pending":
if (event.type === "RESOLVE") return { tag: "success", settledAt: event.now };
if (event.type === "REJECT") return { tag: "error", message: event.message, settledAt: event.now };
return state;
case "success":
case "error":
if (event.type === "RESET") return { tag: "idle" };
return state;
}
}
// Contract tests: transitions + invariants + a11y semantics
describe("ActionButton contract", () => {
const base: ActionProps = { label: "Save", onCommit: async () => {} };
it("ignores PRESS when disabled", () => {
const props = { ...base, disabled: true };
const s0: ActionState = { tag: "idle" };
const s1 = transition(s0, { type: "PRESS", source: "pointer", now: 1 }, props);
expect(s1).toEqual(s0);
});
it("dedupes PRESS while pending", () => {
const props = base;
const s0: ActionState = { tag: "idle" };
const s1 = transition(s0, { type: "PRESS", source: "keyboard", now: 1 }, props);
expect(s1.tag).toBe("pending");
const s2 = transition(s1, { type: "PRESS", source: "pointer", now: 2 }, props);
expect(s2).toBe(s1);
});
it("exposes busy + disabled semantics while pending", () => {
const props = base;
const pending: ActionState = { tag: "pending", startedAt: 10 };
const vm = computeViewModel(pending, props);
expect(vm.a11y.role).toBe("button");
expect(vm.a11y.name).toBe("Save");
expect(vm.a11y.busy).toBe(true);
expect(vm.a11y.disabled).toBe(true);
});
});
One important takeaways:
- The core contains no platform APIs and no pixels. That’s how it stays portable and testable.
Accessibility isn’t a side quest. The core emits semantic intent (role/name/state) as part of its contract output.
6. Testing the Contract: State Machines + Accessibility Assertions
Contract-first testing has two layers that catch different classes of bugs.
Layer A: Core contract tests (fast, exhaustive)
You should be able to test:
- State transitions for all relevant events.
- Invariants (“disabled means inert,” “pending dedupes press,” “busy is exposed”).
- Event ordering rules (what must precede what).
- Semantic output (a11y role/name/state fields always align with state).
For senior teams, the next step is generating event sequences and asserting invariants never break (property-based testing). You don’t need it to start, but it’s a sharp tool when components become truly stateful.
Layer B: Adapter mapping tests (small, platform-specific)
Adapters don’t need to be tested like full applications. They need tests that prove correct translation:
- Web mapping: semantic role/name/state becomes appropriate attributes, focusability rules are correct, keyboard activation matches the contract, disabled prevents activation.
- Native mapping: semantic role/name/state becomes native accessibility props, press handling respects contract signals, announcements (if any) use platform mechanisms appropriately.
- TV/remote mapping (if applicable): focus and activation semantics are consistent.
A good rule: if a bug involves “when does it fire?” or “what state is it in?”, it should be catchable in the core tests. If a bug involves “is this attribute/prop correct on this platform?”, it should be catchable in the adapter tests.
7. Building Adapters: Translate IO and Pixels Without Re-Implementing Behavi
or
Adapters have a deceptively simple job:
- Convert platform inputs (click/tap/key/assistive activation) into contract events.
- Render the UI based on the core view model.
- Map semantic intent into platform accessibility primitives.
- Handle pixels: layout, styling, animations, and platform-specific affordances.
Adapters should not:
- invent new state transitions,
- add “special-case” dedupe rules,
- change event ordering,
- reinterpret the meaning of disabled/busy/focus.
Here’s a thin adapter pattern that uses one core hook and renders both web and native implementations without forking behavior:
import * as React from "react";
// Reuse the core functions/types from the contract module:
// - transition(state, event, props)
// - computeViewModel(state, props)
function useActionCore(props: ActionProps) {
const [state, setState] = React.useState<ActionState>({ tag: "idle" });
const vm = React.useMemo(() => computeViewModel(state, props), [state, props]);
async function activate(source: PressSource) {
const now = Date.now();
const next = transition(state, { type: "PRESS", source, now }, props);
if (next === state) return; // core rejected activation (disabled/pending)
setState(next);
try {
await props.onCommit();
setState(s => transition(s, { type: "RESOLVE", now: Date.now() }, props));
} catch (e: any) {
setState(s =>
transition(s, { type: "REJECT", message: String(e?.message ?? e), now: Date.now() }, props)
);
}
}
return { vm, activate };
}
// Web adapter (DOM)
export function ActionButtonWeb(props: ActionProps) {
const { vm, activate } = useActionCore(props);
return (
<button
type="button"
disabled={vm.isDisabled}
aria-busy={vm.a11y.busy || undefined}
aria-disabled={vm.a11y.disabled || undefined}
onClick={() => activate("pointer")}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") activate("keyboard");
}}
>
{vm.label}
</button>
);
}
// Native adapter (React Native-style pseudocode)
export function ActionButtonNative(props: ActionProps) {
const { vm, activate } = useActionCore(props);
return (
<Pressable
disabled={vm.isDisabled}
accessibilityRole={vm.a11y.role}
accessibilityLabel={vm.a11y.name}
accessibilityState={{ disabled: vm.a11y.disabled, busy: vm.a11y.busy }}
onPress={() => activate("pointer")}
>
<Text>{vm.label}</Text>
</Pressable>
);
}
What makes this “contract-first” isn’t the code style. It’s the control flow:
- The core decides whether a press is accepted.
- The adapter respects that decision.
- The adapter does not implement its own “disabled means…” logic beyond wiring
disabledinto the platform primitive.
If you keep adapters thin, adding a new platform becomes mostly translation work, not “rebuild the component again but differently.”
8. Versioning Rules: What Counts as Breaking in a Contract-First World
If you version only by TypeScript compatibility, you will ship breaking behavior under “patch” releases and consumers will treat your design system as unstable.
Contract-first versioning is stricter because it respects user-visible behavior and accessibility as first-class guarantees.
Breaking changes include:
- API breaks: removing/renaming props, incompatible type changes, changing default values that alter meaning.
- Event contract breaks: renamed/removed events, incompatible payload changes, changed event ordering, changed firing conditions (including “now fires while disabled” or “no longer fires when pressed”).
- State model breaks: removing or repurposing observable states, changing transition rules that consumers depend on.
- Invariant breaks: loosening/tightening rules in a way that changes user-facing behavior (dedupe rules, focus rules, disabled/busy semantics).
- Accessibility breaks: role changes, accessible name computation changes, focusability changes, changes to exposed states (disabled/busy/expanded/checked), or removing announcements the contract guaranteed.
- i18n breaks: changes to RTL behavior, truncation/wrapping rules that were part of the contract.
- Performance contract breaks: if you explicitly promised constraints (e.g., “no measurements on every keystroke”) and then violate them.
Non-breaking changes can include:
- Adding optional props with safe defaults.
- Adding new events that don’t alter existing behavior.
- Bug fixes that bring behavior into alignment with the existing contract.
The pragmatic approach: version the contract, not the renderer. If different platforms ship separately, they should still target the same contract version and prove it via the same core tests.
Pitfalls & Fixes
- Pitfall: The contract is vague (“should,” “generally,” “ideally”).
- Fix: Convert vague statements into invariants and test cases. If it can’t be tested, label it as a goal, not a guarantee.
- Pitfall: Adapters start accumulating behavior (“just one platform tweak”).
- Fix: Move the rule into the core and expose it via the semantic view model. Adapters translate; they don’t decide.
- Pitfall: The API exposes platform mechanics instead of user intent.
- Fix: Design the contract API around intent (“activation,” “busy,” “dismiss”) and let adapters map to platform primitives.
- Pitfall: Accessibility is documented but not enforced.
- Fix: Include a11y semantics in the core output and assert them in contract tests. Treat role/name/state as required outputs.
- Pitfall: Focus and keyboard/remote behavior is an afterthought.
- Fix: Make focus rules part of the contract (focusability, visible focus, activation keys, roving focus if needed). Test it at the adapter boundary.
- Pitfall: i18n is treated as “string props exist.”
- Fix: Specify ownership boundaries (no string concatenation that breaks localization), RTL expectations, and truncation/wrapping behavior.
- Pitfall: The contract becomes a stale document.
- Fix: Treat contract tests as the living source of truth. Updating behavior without updating tests should be impossible or loudly failing.
- Pitfall: Consumers fear upgrades because versioning is inconsistent.
- Fix: Publish explicit breaking-change rules (including behavior and a11y). Make releases predictable and boring.
Adoption checklist
- Pick one high-drift interactive component (buttons, toggles, text inputs, dialogs are classic).
- Write the contract before refactoring code: API, events, state model, invariants, a11y, i18n, performance, adapter boundary.
- Implement a platform-agnostic core (pure transitions + semantic view model).
- Add core contract tests: transitions, invariants, event ordering, semantic a11y outputs.
- Build thin adapters per platform: translate IO, map semantics, render pixels.
- Add adapter mapping tests: verify correct platform attributes/props and activation behavior.
- Enforce “no behavior in adapters” in reviews (and ideally with linting/conventions).
- Define contract versioning rules and treat behavioral/a11y changes as breaking when they change user experience.
Scale the pattern: extract shared scaffolding (contract template, test harness, adapter guidelines ?
Conclusion
Contract-first components work because they turn invisible expectations into explicit, testable guarantees: API surface, state machines, accessibility semantics, and styling boundaries. Treat those guarantees like you would a backend API—version them, lint them, test them, and provide migration paths.
If you do that, you get cross-platform reuse without cross-platform chaos: your design system becomes a set of stable contracts with multiple renderers, not a pile of copy-pasted implementations.
