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

1. Why “Props-Only” Thinking Creates Drift Across Platforms

Props are inputs. Real components are interactive systems.

Even a “simple” button has:

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:

Contract-first is how you stop debugging the same conceptual component multiple times.

2. What a Component Contract Actually Is

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:

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.

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:

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:

  1. 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:

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:

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:

Adapters should not:

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:

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:

Non-breaking changes can include:

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

Adoption checklist

 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.