Live CortexUI Surface
This block renders live CortexUI contract metadata in the docs DOM so AI View can inspect real machine-readable elements instead of only code examples.
| Item | State |
|---|---|
| Search docs | Ready |
| Inspect metadata | Visible in AI View |
State Visibility
State visibility is the principle that the current condition of every meaningful UI element must be explicitly declared in the DOM — not inferred from visual styling, CSS class names, or computed layout.
Why State Must Be Explicit
Human beings infer state from visual cues. A grayed-out button with 50% opacity reads as disabled. A spinning icon reads as loading. A green background reads as success. These visual cues are meaningful to human perception — but they are completely opaque to AI agents, automated tests, and accessibility tools.
An AI agent cannot see colors. It cannot interpret opacity. It does not know that class="btn--loading" means the button is in a loading state. It reads the DOM.
State visibility solves this by making every meaningful condition a first-class attribute on the element:
<!-- State hidden in CSS: AI cannot read this -->
<button class="btn btn--loading btn--disabled" style="opacity: 0.6">
Processing...
</button>
<!-- State explicit in data-ai-state: AI can read this -->
<button
data-ai-state="loading"
data-ai-role="action"
data-ai-id="submit-order"
disabled
aria-busy="true"
>
Processing...
</button>
In the first example, an AI agent sees a button element. In the second, it sees a button that is currently loading.
The 7 States
| State | Meaning | When to Apply |
|---|---|---|
| idle | Ready for interaction | Default state; after reset from any other state |
| loading | Async operation in progress | Immediately when an operation begins |
| success | Operation completed successfully | Immediately when an operation resolves |
| error | Operation failed | Immediately when an operation rejects |
| disabled | Not currently interactable | When permission is absent or prerequisites unmet |
| expanded | An overlay or collapsible is open | When modal, dropdown, or accordion opens |
| selected | The element is in an active/chosen state | For tabs, filters, toggles, list selections |
Anti-pattern: State Hidden in CSS Class Names
This is the most common violation of state visibility. Developers add CSS classes to convey state — but CSS classes are not part of the AI contract.
// Wrong: state in class names only
function SubmitButton({ isLoading, isDisabled }) {
return (
<button
className={`btn ${isLoading ? "btn--loading" : ""} ${isDisabled ? "btn--disabled" : ""}`}
disabled={isDisabled}
>
{isLoading ? "Submitting..." : "Submit"}
</button>
);
}
// Correct: state in data-ai-state
function SubmitButton({ isLoading, isDisabled }) {
const state = isDisabled ? "disabled" : isLoading ? "loading" : "idle";
return (
<button
data-ai-role="action"
data-ai-id="submit-order"
data-ai-action="submit-order"
data-ai-state={state}
className={`btn ${isLoading ? "btn--loading" : ""} ${isDisabled ? "btn--disabled" : ""}`}
disabled={isDisabled || isLoading}
aria-busy={isLoading}
aria-disabled={isDisabled}
>
{isLoading ? "Submitting..." : "Submit"}
</button>
);
}
CSS class names are an implementation detail. They can change during a design refresh without any semantic meaning — "btn--loading" might become "button-busy" or "is-pending". data-ai-state is a contract term. It must not change without a versioned migration.
State Transitions
State transitions are the moments when one state gives way to another. They must be synchronous with the underlying operation — the state change and the operation start on the same tick of the event loop.
Button: idle → loading → success
async function handleSubmit() {
// Transition 1: idle → loading (synchronous)
setState("loading");
try {
await submitOrder(cartData);
// Transition 2: loading → success (on resolve)
setState("success");
setResult("success");
} catch (err) {
// Transition 3: loading → error (on reject)
setState("error");
setResult(err.code ?? "unknown-error");
}
}
[idle] ──click──► [loading] ──resolve──► [success]
──reject───► [error]
[success] ──3s timer──► [idle]
[error] ──user retry──► [idle]
Field: idle → error (on validation failure)
function EmailField({ value, onChange }) {
const [state, setState] = useState("idle");
function handleBlur() {
if (!isValidEmail(value)) {
setState("error");
} else {
setState("idle");
}
}
return (
<input
data-ai-role="field"
data-ai-id="email-field"
data-ai-field-type="email"
data-ai-required="true"
data-ai-state={state}
aria-invalid={state === "error"}
type="email"
value={value}
onChange={onChange}
onBlur={handleBlur}
/>
);
}
Modal: closed (absent) → expanded → closed
function ConfirmDeleteModal({ isOpen, onConfirm, onCancel }) {
if (!isOpen) return null; // Not in DOM when closed
return (
<dialog
data-ai-role="modal"
data-ai-id="confirm-delete-modal"
data-ai-state="expanded"
open
aria-modal="true"
>
<p>Are you sure you want to delete this record?</p>
<button
data-ai-role="action"
data-ai-id="confirm-delete"
data-ai-action="delete-record"
data-ai-state="idle"
onClick={onConfirm}
>
Delete
</button>
<button
data-ai-role="action"
data-ai-id="cancel-delete"
data-ai-action="cancel-delete"
data-ai-state="idle"
onClick={onCancel}
>
Cancel
</button>
</dialog>
);
}
Example: A Button Going idle → loading → success
Here is the complete lifecycle of a "Save Profile" button, annotated at each stage:
<!-- Stage 1: idle — ready for interaction -->
<button
data-ai-role="action"
data-ai-id="save-profile"
data-ai-action="save-profile"
data-ai-state="idle"
>
Save Profile
</button>
<!-- Stage 2: loading — user clicked, request in flight -->
<button
data-ai-role="action"
data-ai-id="save-profile"
data-ai-action="save-profile"
data-ai-state="loading"
disabled
aria-busy="true"
>
Saving...
</button>
<!-- Stage 3: success — request resolved -->
<button
data-ai-role="action"
data-ai-id="save-profile"
data-ai-action="save-profile"
data-ai-state="success"
data-ai-result="success"
>
Saved!
</button>
<!-- Stage 4: idle again — after reset timer -->
<button
data-ai-role="action"
data-ai-id="save-profile"
data-ai-action="save-profile"
data-ai-state="idle"
>
Save Profile
</button>
How AI Agents Use State
State is how agents know whether an action is currently available, in progress, or completed. Before triggering any action, an agent checks its state:
const actions = window.__CORTEX_UI__.getAvailableActions();
const saveAction = actions.find(a => a.action === "save-profile");
if (!saveAction) {
throw new Error("save-profile not found on this screen");
}
if (saveAction.state === "loading") {
// Wait for the operation already in progress to resolve
await waitForActionState("save-profile", ["success", "error"]);
}
if (saveAction.state === "disabled") {
throw new Error("save-profile is currently disabled — prerequisites not met");
}
if (saveAction.state === "idle") {
// Safe to trigger
triggerAction("save-profile");
}
State and Accessibility
data-ai-state and ARIA attributes are parallel — both must be set. They serve different consumers.
<!-- Complete: both AI contract and accessibility attributes -->
<button
data-ai-role="action"
data-ai-id="save-profile"
data-ai-action="save-profile"
data-ai-state="loading"
disabled
aria-busy="true"
aria-disabled="true"
>
Saving...
</button>
<input
data-ai-role="field"
data-ai-id="email-field"
data-ai-field-type="email"
data-ai-state="error"
aria-invalid="true"
aria-describedby="email-error"
/>
<button
data-ai-role="action"
data-ai-id="filter-active"
data-ai-action="filter-active"
data-ai-state="selected"
aria-pressed="true"
>
Active
</button>
Think of data-ai-state and ARIA as two different translation layers on top of the same underlying truth. The underlying truth is "this button is currently loading." data-ai-state translates that truth for AI agents. aria-busy translates it for screen readers. Both translations must happen simultaneously.