UI Copy and States skill for Codex
No blank screens, vague errors or mute buttons. Codex builds a state matrix for every component that waits, fails or changes, chooses spinners, skeletons, progress or optimistic updates by expected wait time, and writes error copy that names the problem and the next step without losing input. Disabled controls explain themselves, success feedback matches its importance, and live regions and focus moves make screen readers hear the same story.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- ui-copy-states Installs to ~/.agents/skills/ui-copy-states/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install ui-copy-states The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project ui-copy-states Installs to .agents/skills/ui-copy-states/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/ui-copy-states.zipunzip -o ui-copy-states.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) ui-copy-states PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $ui-copy-states, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Designs and writes the non-happy-path states of an interface - loading (spinner, skeleton, progress, optimistic), error (validation, network, server, permission, not found), success and confirmation, disabled and read-only, and partial or stale data - with timing thresholds, copy patterns, placement, ARIA live-region behaviour and a state matrix per component. Use when screens show blank or frozen UI while loading, errors say "Something went wrong", buttons are disabled with no explanation, or a feature needs all its states specified.
Use it when
- A feature needs its loading, error, success and disabled states specified
- Users see blank screens, flickering spinners or frozen buttons
- Error messages are generic or show raw codes
- Screen readers miss status changes and errors
Use something else when
- Designing first-run and empty states for onboarding: use onboarding-empty-states
- Implementing error boundaries and retry logic in code: use frontend-error-handling
How to check the result
Each state is triggered with network throttling, blocked requests and a restricted role, every message states a next step, and a screen reader announces loading, success and errors once.
The mistake it prevents
Disabling a submit button with no visible reason, which blocks keyboard users from even discovering why they cannot proceed.
Every component that waits, fails or changes has designed states with words that tell the user what is happening and what to do next. Done well: no blank screens, no generic errors, no unexplained disabled controls, input never lost, and screen readers hear the same story sighted users see.
Workflow
- List the components that fetch, submit or change (
rg -n "useQuery|useMutation|fetch\(|isLoading|isPending|error" src). - Build a state matrix for each (template below).
- Pick the loading pattern by expected duration.
- Write error copy per failure type; decide placement and recovery action.
- Define success feedback and disabled-state explanations.
- Wire ARIA: live regions,
aria-busy, focus management. - Trigger every state and check it visually and with a screen reader.
Read AGENTS.md and existing copy for voice (sentence case or title case, “we” or not),
and reuse existing toast, alert and skeleton components.
Loading
| Expected wait | Pattern |
|---|---|
| Under ~300 ms | Nothing. Delay any indicator ~300 ms so fast responses do not flicker |
| 300 ms - 2 s | Inline spinner in the control, or skeleton if layout is known |
| 2 - 10 s | Skeleton for content; for actions, a determinate progress or step text (“Uploading 3 of 8”) |
| Over 10 s | Progress with estimate, allow leaving; notify when done (“We’ll email you when the export is ready”) |
| User action likely to succeed | Optimistic update, with rollback and an error message on failure |
Rules:
- Skeletons match the real layout’s shape and size to avoid layout shift. Never show a skeleton for content that may be empty; show the empty state instead.
- Button loading: keep the label (or change to the progressive verb, “Saving…”),
add a spinner, keep width fixed, prevent double submit, set
aria-busy="true"oraria-disabled="true". - Once a spinner shows, keep it at least ~500 ms to avoid a flash.
- After 10-15 s with no response, change the message (“This is taking longer than usual”) and offer cancel or retry.
Errors
Every error message answers: what happened, why (if known and useful), and what to do.
| Type | Placement | Copy pattern | Recovery |
|---|---|---|---|
| Field validation | Under the field, linked via aria-describedby | “Enter a date after 1 Jan 2026” (the rule, phrased as an instruction) | Keep input, focus field on submit |
| Form-level (several fields) | Summary at top listing errors as links, plus inline | “2 fields need attention” | Focus summary on submit |
| Network / offline | Inline at the component, or banner | “You’re offline. Changes will sync when you reconnect.” | Auto-retry plus manual Retry |
| Server 5xx | Inline where the action was | “We couldn’t save your changes. Try again in a moment.” | Retry; keep input; reference ID if support needs it |
| Permission (403) | Replace content area | “You don’t have access to Billing. Ask a workspace admin.” | Link to request access or go back |
| Not found (404) | Page | “This project was deleted or moved.” | Search, recent items, home |
| Conflict (409) | Inline | “Priya edited this while you were working.” | Show both, let user choose |
| Rate limit (429) | Inline | “Too many attempts. Try again in 30 seconds.” | Countdown, then enabled |
Copy rules:
- Plain words, no codes as the message (
ERR_CONN_RESET, “Error 500”). Add a reference ID in small text when support needs it. - No blame (“You entered an invalid…”); describe the fix.
- No false comfort (“Oops!”, jokes) for errors that cost the user something.
- Never clear the user’s input after an error.
- Specific beats generic: map known backend error codes to messages; reserve the generic fallback for truly unknown errors, and still give a next step.
Success
| Significance | Pattern |
|---|---|
| Expected, visible result (item appears in list) | The result itself is the feedback; maybe a brief highlight |
| Not visible where the user is (saved settings, sent invite) | Toast, 4-6 s, polite live region; include Undo where possible |
| Important or irreversible (payment, deletion done) | Confirmation page or inline panel with details and next steps |
Toasts are not for errors that need action; they disappear. Toasts must not cover primary controls on mobile, and must pause on hover/focus if they contain actions.
Disabled and read-only
- Prefer enabled controls with validation on submit over disabled buttons that never explain themselves.
- If a control must be disabled, say why next to it: “Add a payment method to upgrade”.
- Native
disabledremoves the control from tab order, and tooltips on it are unreachable for keyboard users. When the reason matters, usearia-disabled="true"and keep it focusable, blocking the action in the handler. - Read-only (viewer role) differs from disabled: show values as text, explain the role once (“You have view access”).
- Disabled styling still needs to be identifiable; it is exempt from contrast rules but should not vanish.
Partial, stale and empty data
- Partial failure: render what loaded and mark the failing widget with its own error and retry, rather than failing the whole page.
- Stale data: show “Updated 5 min ago” and a refresh when freshness matters.
- Empty: explain what will appear here and the action that fills it. Distinguish
“no data yet” from “no results for this filter” (offer clear filters). First-run design
belongs to
$onboarding-empty-states.
Accessibility wiring
<div role="status" aria-live="polite" class="sr-only" id="announcer"></div> <!-- success, progress -->
<div role="alert" id="form-error"></div> <!-- urgent errors -->
<input id="email" aria-invalid="true" aria-describedby="email-err">
<p id="email-err">Enter an email address like [email protected]</p>
<section aria-busy="true">...skeleton...</section>
- Live regions must exist in the DOM before content is injected, or announcements are missed.
- Use
role="alert"sparingly; it interrupts. - After a failed submit, move focus to the error summary or first invalid field.
- Announce completion of long operations (“Export ready”).
State matrix template
### <Component>: Invite teammates form
| State | Trigger | Visual | Copy | ARIA / focus |
|---|---|---|---|---|
| Idle | default | Enabled Send | "Send invites" | - |
| Validating | blur | Inline error | "Enter a valid email address" | aria-invalid, describedby |
| Submitting | click Send | Spinner in button, fixed width | "Sending..." | aria-busy, disable double submit |
| Success | 200 | Toast + pending rows in list | "Invites sent to 3 people" | role=status |
| Partial | 207 | Inline list of failed emails | "2 sent. 1 couldn't be sent: x@y (already a member)" | focus list |
| Server error | 5xx | Inline alert above button | "We couldn't send invites. Try again." | role=alert, keep input |
| Seat limit | 402/limit | Inline panel with upgrade link | "Your plan has 5 seats. Upgrade to add more." | - |
| Disabled | viewer role | Hidden form, explanation | "Only admins can invite people." | - |
Verification
- Trigger each state: throttle network (DevTools “Slow 4G” and “Offline”), force errors with a mock or by blocking the request URL, test with a viewer-role account.
- Read every message aloud: does it say what to do next?
- Screen reader pass (VoiceOver or NVDA): loading, success and errors are announced once, at the right time.
- Check copy against the product’s voice and casing rules.
Related: wording of error messages in depth in $error-messages; error boundaries and
retry logic in code in $frontend-error-handling.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "UI Copy and States"
short_description: "No blank screens, vague errors or mute buttons"
default_prompt: "$ui-copy-states Design all the loading, error and success states for our invite form" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.