# The Binding Contract



The binding layer discovers your inputs through four data attributes, captures answers as visitors type, and shows one step at a time. Exact IDs and answer shapes come from the `get_sdk_binding_map` MCP tool; never invent them.

## The four attributes [#the-four-attributes]

### `data-question-id` [#data-question-id]

On the input itself or on a wrapper; every `input`, `select`, and `textarea` inside a wrapper binds to that question. Bind every answer-recording question; the SDK warns in the console about unbound ones. A control inside a nested wrapper belongs to the inner question only.

Schedulers (`CalendlyScreen`, `DynamicScheduler`) and `FileUploader` need no HTML binding; their answers are written in code, and `validate_form_html` skips them.

### `data-field-name` [#data-field-name]

Which key of the component's answer object this control writes. Resolution: wrapper's attribute, control's attribute, then inference (`"selected"` for a `<select>` on a Dropdown, `"email"` for `input[type=email]`, `"input"` otherwise), and inference only works when the control type matches the component. An email input bound to a `ShortInput` needs an explicit `data-field-name="input"`; every control of a multi-field component needs its own explicit name:

```html
<div data-question-id="q_contact" data-question-type="IdentityInfo">
  <input data-field-name="firstName" placeholder="First name" />
  <input data-field-name="lastName" placeholder="Last name" />
  <input type="email" data-field-name="workEmailAddress" placeholder="Work email" />
</div>
```

<Warning>
  `IdentityInfo` field names are a closed set: `emailAddress` and `workEmailAddress` (not `email`), `phoneNumber` (not `phone`), `firstName`, `lastName`, `companyName`, `websiteUrl`, `title`, `numberOfEmployees`. Bind only the keys the binding map lists.
</Warning>

### `data-question-type` [#data-question-type]

The record's component type. Inferred only for `ShortInput` (the default) and `EmailForm` (email inputs); every other type must be set explicitly or the server rejects the answer.

### `data-step-id` [#data-step-id]

One container per record step; the SDK shows exactly one at a time by setting `hidden` on the rest. Single-step records may omit containers. Author every container after the first with the `hidden` attribute; unhidden steps flash stacked while `init` resolves.

## Value capture [#value-capture]

* Text controls contribute `value`; checkboxes contribute `checked`; radios follow the choice group rules below.
* Values already in the DOM at bind time (server-side rendering, autofill) are captured immediately.
* Multi-field components accumulate one answer object per question, e.g. `{ firstName, lastName, workEmailAddress }`.

## Choice questions bind as a group [#choice-questions-bind-as-a-group]

Five types are group-built: `MultipleOptionsQuestion`, `MultipleChoiceQuestion`, `MMChoiceQuestion`, `GraphicOptions`, `MultipleChoiceSingleAnswer`. Their answers are structured objects built from the container's whole radio or checkbox group:

```html
<fieldset data-question-id="q_role" data-question-type="MultipleOptionsQuestion">
  <label><input type="radio" name="role" value="Revenue / Sales Ops" /> Revenue / Sales Ops</label>
  <label><input type="radio" name="role" value="Sales leadership" /> Sales leadership</label>
</fieldset>
```

* `data-question-type` on the container is required; without it the server rejects the answer.
* Each control's `value` must equal the record's option key exactly (`validate_form_html` cross-checks).
* Never set `data-field-name` on the options.
* Author controls in record order. Multi-select uses checkboxes with the same markup.

The five types have different answer shapes (only `MultipleOptionsQuestion` carries an option `index`). In bound HTML the SDK builds them; in code, copy each question's `answerShape` from the binding map.

<Note>
  `MultipleChoiceQuestion` and `MultipleChoiceSingleAnswer` are legacy types. When porting, create a `MultipleOptionsQuestion` with `isMultiSelect` `true` or `false` respectively.
</Note>

### The "Other" option [#the-other-option]

The group captures the option selection only. Render an extra text control outside the group and mark the answer when used:

```js
otherInput.addEventListener("input", () => {
  const current = form.state().answers["step_role"]?.["q_role"] ?? {};
  form.setAnswer("q_role", { ...current, selectedOther: true });
});
```

The typed text has no slot on the choice answer; if the owner wants it stored, add a `ShortInput` question to the record.

## Dropdowns [#dropdowns]

A Dropdown binds a `<select>` whose option values are the record's option keys; the state key (`selected`) is inferred:

```html
<label>
  Company size
  <select data-question-id="q_size" data-question-type="Dropdown">
    <option>1-10</option>
    <option>11-50</option>
  </select>
</label>
```

## Busy state during submit [#busy-state-during-submit]

While `submit()` or `disqualify()` wait on the finishing write, the binding layer marks the container with the `surface-busy` class, sets `aria-busy="true"` and a `progress` cursor, and disables the clicked button. Style `.surface-busy` for a spinner; nothing else is needed.

## Validating your markup [#validating-your-markup]

Run the `validate_form_html` MCP tool on the markup visitors actually receive; it checks every rule on this page against the record. For a framework page, validate the rendered HTML from your dev server or build output, never a hand transcription. Details in [Building with AI](/docs/forms-sdk/building-with-ai).
