Design a secure payment form
The card-entry component a payment provider ships to merchants, like Stripe's Payment Element or Braintree Hosted Fields. The hard parts are keeping card numbers out of the merchant's page while still letting the merchant style it, reacting to every keystroke with formatting and brand detection across a frame boundary, and confirming a payment exactly once when 3-D Secure, wallets, and flaky networks get in the way.
The question
You work at a payment provider. Design the card form we give to merchants: a merchant adds our script and a few lines of code, and their customers can type a card number, expiry date, and security code on the merchant's own checkout page, styled to look like the rest of the site.
The card number must never reach the merchant's page or servers, so the merchant stays in the lightest PCI DSS scope. Walk me through how the form is built, how the merchant talks to it, how card data becomes something the merchant can charge, and what happens with 3-D Secure, Apple Pay, double clicks, and a network that drops while the payment is being confirmed.
Real-life examples: Stripe Payment Element and Card Element, Braintree Hosted Fields, Adyen Secured Fields, Checkout.com Frames (replaced by Flow in 2026)
Requirements: whose form is this?
Start by pinning down who you are. This guide is written from the payment provider's side: we build the secure card form, publish it as a script, and thousands of merchants embed it in their checkout pages. Stripe's Payment Element and Card Element, Braintree Hosted Fields, Adyen Secured Fields, and Checkout.com Frames (retired in June 2026 in favour of Checkout.com's Flow) are all this product. The merchant is our customer, and the shopper is our customer's customer.
That framing matters because the merchant's checkout page (the cart summary, the shipping address, the order total, the place-order button) is a different problem. The checkout-flow guide covers it: the checkout session, server-side pricing, idempotent order placement, picking a hosted payment page or embedded fields, decline messages, the 3-D Secure return URL, and the Content Security Policy of the checkout page. This guide opens the box that guide treats as a black box: the inside of the card form.
A good interviewer will let you pick the framing, but will expect you to say it out loud. If you design the merchant's page instead, you will spend the hour on things the interviewer did not ask about.
The questions that change the design
| Question | Answer we assume | What it changes |
|---|---|---|
| Who embeds the form? | Any merchant, from a one-person shop on a template to a large retailer with its own front-end team and its own Content Security Policy. | The form must work on pages we do not control, next to scripts we have never seen. |
| Must card data stay out of the merchant's page? | Yes. The merchant's page must never be able to read the card number or security code. | The fields live in our own cross-origin iframes. This single answer drives most of the design. |
| One field or separate fields? | Both: a single combined card field and split fields (number, expiry, security code) that the merchant places anywhere in their layout. | Split fields mean several iframes that must coordinate with each other. |
| How much styling control? | Fonts, colours, sizes, focus and error colours, placeholder text. No arbitrary CSS. | We need a style object that crosses the frame boundary and is applied inside the frame. |
| Which payment methods? | Cards of the major networks, plus Apple Pay and Google Pay buttons. | Wallets have their own browser APIs and their own domain rules. |
| Which regions? | Global, including the European Economic Area, where Strong Customer Authentication (SCA) rules, which require two independent checks of the payer for many online payments, make banks ask for 3-D Secure. | The form must run a bank challenge inside the merchant's page and resume afterwards. |
| Who confirms the payment? | The merchant's server creates a payment on our API. The browser confirms it with the card through our script. | A clear split between what the browser may do with a publishable key and what needs the secret key. |
| What devices and networks? | Mobile first. Many shoppers pay on a phone, on a train, with a weak signal. | A small script, numeric keyboards, autofill, and confirmation that survives a dropped connection. |
Functional requirements
- A merchant loads one script from our domain, creates the form with a publishable key (a public key that identifies the merchant and is safe to put in a web page), and mounts it into an element on their page.
- The shopper types a card number, expiry date, and security code (CVC). The number is formatted with spaces as they type, the card brand logo appears after the first digits, and errors appear at the right moment.
- The merchant gets state, not data: whether each field is empty, complete, or invalid, the detected brand, and an error message. Never the digits.
- On submit, the form sends the card to our servers and the merchant gets back a token or payment method id that their server can charge.
- If the bank asks for 3-D Secure, the form shows the bank's challenge and continues when it is done.
- Apple Pay and Google Pay buttons appear when the shopper's device supports them.
- The merchant can style the fields to match their page and listen to events (ready, focus, blur, change).
Non-functional requirements
- Isolation: no script on the merchant's page, including a malicious one, can read what the shopper types. This is the requirement everything else bends around.
- Responsiveness: formatting and brand detection run on every keystroke inside the frame, well within one frame of 16 ms. Nothing that reacts to typing may wait for the network.
- Fast to appear: the fields should be visible and typeable within about one second on a mid-range phone on 4G (our target, not a standard). The loader script should be small, with the heavy parts loaded only when the form is mounted.
- Exactly-once confirmation: a double click, a retry, or a dropped connection must never charge the shopper twice.
- Accessible: labels, errors, and focus order work with screen readers and keyboards even though the inputs live in other documents.
- Compatible: current Chrome, Safari, Firefox, and Edge on desktop and mobile, including in-app browsers that block third-party cookies.
Out of scope, and where it lives
- The merchant's checkout page, tax, and placing the order: see checkout-flow. The cart before it, with guest carts and price changes: see shopping-cart.
- How 3-D Secure works between the card networks and banks: see amazon. Here we only cover how the form hosts it.
- One-time codes typed into a bank's challenge: see otp-input.
- Multi-page form state and form builders: see multi-step-form and form-builder.
- Shopper accounts, sign-in, and the session that lets a merchant show saved cards: see auth-flow. Our form never handles sign-in; it shows saved cards only when the merchant's server says which customer is paying.
- The general SDK pattern of a cross-origin iframe plus a postMessage bridge: see embeddable-widget. We build on it and focus on what payments add.
- The backend: the card vault, the card network connections, settlement, and payouts.
Check your understanding · Exercise 1
Which answers from the merchant or interviewer would change the design of the card form itself? Select all that apply.
What makes this hard
A card form looks like three text inputs. Building one that a bank, a merchant, and an attacker would all accept is a different job. Five properties of the problem push every decision in the rest of this guide.
- It runs in a hostile house. Our code runs inside a page we do not own. That page may load 30 third-party scripts (analytics, chat, A/B testing, ads), any of which could be compromised. We must assume the parent page is hostile and still protect the card number.
- It is split across documents. Isolation means the inputs live in our iframes, on our origin. But the merchant wants the fields to look native, sit in their grid, follow their focus order, and report errors next to their own labels. Every one of those needs a message across a frame boundary.
- It is regulated. PCI DSS (the Payment Card Industry Data Security Standard) decides how much security paperwork and testing the merchant must do. Our architecture is the reason the merchant can fill in a short questionnaire instead of a long audit. A design change on our side can move thousands of merchants into a heavier scope.
- It reacts to every keystroke. Formatting, brand detection, and validation happen as the shopper types, and the caret must stay where the shopper expects it. Getting this wrong on a phone is the difference between a sale and an abandoned cart.
- It must charge exactly once. A confirm can involve our API, the card network, the shopper's bank, a 3-D Secure challenge in yet another iframe, and a redirect. Any of these can stall or fail. The shopper may click twice or reload. The money must move once.
Notice that the first three are about trust boundaries and the last two about user experience. Most of the interesting trade-offs are where they meet: splitting the form into many frames is safer and more flexible, but it makes autofill, focus, and accessibility harder. Keep that tension in mind; it comes back in nearly every chapter.
Think first
If the parent page is hostile, why not just ask merchants to keep their checkout page free of third-party scripts?
Because we cannot enforce it, and most merchants cannot either. A typical checkout page carries analytics, tag managers, chat widgets, and A/B test tools, often added by marketing teams.
Even a clean page today can load a compromised script tomorrow if a vendor is breached. The form has to stay safe without relying on the merchant's discipline. That is why the isolation is built into the architecture, and why PCI DSS v4 still asks merchants to watch the scripts on their payment pages.
PCI DSS scope: the requirement that shapes the architecture
Every merchant that accepts cards must show its bank that it follows PCI DSS, the security standard run by the PCI Security Standards Council (PCI SSC). Small and mid-size merchants usually prove it with a Self-Assessment Questionnaire (SAQ).
Which questionnaire they fill in depends on one thing: how close their systems get to card data. The shortest questionnaire has a few dozen requirements; the longest has a few hundred, plus scans and penetration tests. Our product exists largely to keep merchants on the short one.
Start with the naive design to see why. The merchant renders a normal HTML form with an input named cardNumber, and posts it to their own server, which calls our API. It works on day one.
But now the merchant's server receives, processes, and transmits card numbers, so the merchant is in full scope (SAQ D): network segmentation, logging, quarterly scans, key management. And every script on the page can read input.value. For a small shop, that is fatal.
Decision How should card data get from the shopper to the provider?
What we pick: Ship cross-origin iframe fields as the main product and a hosted page as the simple alternative. Iframes are the only option that gives merchants both their own layout and the lightest scope. The design work in the rest of this guide is the price of that combination. Never ship inputs in the merchant's DOM as a default: it silently moves every merchant who uses it into SAQ A-EP.
PCI DSS v4: why iframes alone are no longer the whole story
Magecart-style attacks (skimming scripts injected into checkout pages, covered in the security chapter) taught the industry that the parent page matters even when the card fields are in a frame. A malicious script on the parent cannot read inside our frame, but it can hide our frame and draw a fake form over it, or point the frame at a look-alike domain. PCI DSS v4.0 answered with two requirements for payment pages. They were future-dated: best practice until 31 March 2025, then mandatory.
- Requirement 6.4.3 (script management): every script loaded and run on the payment page must be authorized, must have its integrity assured, and must be listed in an inventory with a written reason why it is needed.
- Requirement 11.6.1 (change and tamper detection): a mechanism must alert staff to unauthorized changes to the security-impacting HTTP headers and the script contents of the payment page as the shopper's browser receives it, run at least weekly or at a frequency set by the merchant's targeted risk analysis.
Both apply to the page that hosts our iframe, not only to pages with raw inputs: that is the parent page an attacker would tamper with. For SAQ A merchants, the PCI SSC changed course in January 2025. The revised SAQ A for PCI DSS v4.0.1 removed 6.4.3 and 11.6.1 from the questionnaire and added an eligibility criterion instead: "The merchant has confirmed that their site is not susceptible to attacks from scripts that could affect the merchant's e-commerce system(s)."
PCI SSC FAQ 1588 (February 2025) then said a merchant can meet it in one of two ways: use techniques such as those in 6.4.3 and 11.6.1 (their own or a vendor's), or get confirmation from their PCI compliant provider that the embedded form, set up as instructed, includes techniques that protect the payment page from script attacks. The criterion applies to embedded forms such as iframes, not to full redirects.
| Merchant setup | Usual questionnaire | Payment page script rules |
|---|---|---|
| Redirect or link to the provider's hosted page | SAQ A | Not applicable to the merchant's page. |
| Provider iframe fields on the merchant's page | SAQ A | Eligibility criterion: the site must be protected from script attacks (FAQ 1588 options). |
| Provider script, inputs in the merchant's DOM, direct post | SAQ A-EP | 6.4.3 and 11.6.1 apply to the payment page. |
| Merchant server receives the card | SAQ D | 6.4.3 and 11.6.1 apply, plus the full standard. |
This turns into concrete product work for us. Our script must be loaded from our domain, never bundled or self-hosted by the merchant, so it is one known entry in their script inventory. We publish the exact list of origins our frames and script need, so merchants can write a strict Content Security Policy. Our frames send Content-Security-Policy: frame-ancestors only where it helps (more in the security chapter), and we document the setup the merchant must follow for our confirmation under FAQ 1588 to hold.
Check your understanding · Exercise 2
A merchant uses our iframe fields and qualifies for SAQ A. Which changes could push them into a heavier questionnaire? Select all that apply.
Hosted fields: frames, a controller, and a message protocol
We have decided the inputs live in iframes served from our origin, say https://js.pay.example. The same-origin policy then does the heavy lifting: a script on https://shop.example cannot read the DOM, the input values, or the JavaScript variables of a document from another origin. Everything the two sides share must go through window.postMessage, which sends a copy of a plain object to another window and lets the receiver check who sent it. The embeddable-widget guide covers this pattern for any third-party widget; here we look at what payments add.
Decision How many iframes should the form use?
What we pick: Offer the first two, built from the same field code. The single-frame form is the default because autofill and accessibility come almost free. Split fields are for merchants who need layout control, and they need a controller frame: one place for shared state, one network call to the vault, one owner of the card data. Reject the third: it moves the coordination problem to the server and makes it worse.
change state and a pm_ id, never digits.Who talks to whom
- Merchant page to our SDK: plain function calls. The SDK is our script, running in the merchant's origin, so it is just as exposed as any other script there. It must never hold card data.
- SDK to frames:
postMessagewith a channel id. The SDK sends commands (mount, update style, focus, confirm) and receives events (ready, change, focus, blur, resize, result). - Field frames to controller:
postMessagebetween two documents on our origin, withtargetOriginset to our exact origin. Card digits travel only on this path. - Controller to our API:
fetchover HTTPS with the publishable key. The response is a token or payment method id.
The SDK creates every frame with a URL like https://js.pay.example/field.html#ch=8f3c...&parent=https://shop.example. The channel id (ch) is a random value the SDK makes for this form instance. It ties messages to one form, so two forms on the same page (a card form and a saved-card form) do not mix up their events.
It sits in the URL fragment, which the browser does not send to our server, so it stays out of logs. The parent origin in the fragment tells the frame where to send its events.
type Field = "number" | "expiry" | "cvc";
type Brand = "visa" | "mastercard" | "amex" | "discover" | "unknown";
// Frame (or controller) to the merchant's page
type ToParent =
| { ch: string; v: 1; type: "ready"; field: Field }
| { ch: string; v: 1; type: "change"; field: Field;
empty: boolean; complete: boolean; brand?: Brand;
error?: { code: "incomplete" | "invalid_number" | "expired"; message: string } }
| { ch: string; v: 1; type: "focus" | "blur" | "submit"; field: Field }
| { ch: string; v: 1; type: "resize"; field: Field; height: number }
| { ch: string; v: 1; type: "wallets"; applePay: boolean; googlePay: boolean }
| { ch: string; v: 1; type: "result"; requestId: string;
status: "succeeded" | "processing" | "redirecting" | "failed";
paymentId?: string; paymentMethodId?: string; errorCode?: string };
// Merchant's page to the frames
type FromParent =
| { ch: string; v: 1; type: "style"; style: StyleObject }
| { ch: string; v: 1; type: "focus" | "clear"; field: Field }
| { ch: string; v: 1; type: "confirm"; requestId: string; clientSecret: string };
Three rules make the protocol safe. First, check the sender on every message: the frame accepts commands only when event.source === window.parent and event.origin equals the parent origin it was created for, the controller accepts digits only when event.origin is our own origin, and the SDK accepts events only from frames it created on our origin. Second, always pass an exact targetOrigin when posting, never "*", so the browser drops the message if the receiving window has been navigated elsewhere.
Third, design the schema so there is no message that could carry the card, even by mistake: no generic getValue, no debug echo. The brand and complete flags are enough for the merchant to build a good form.
const params = new URLSearchParams(location.hash.slice(1));
const channel = params.get("ch")!;
const parentOrigin = params.get("parent")!;
window.addEventListener("message", (event) => {
if (event.source !== window.parent) return;
if (event.origin !== parentOrigin) return;
const msg = event.data as FromParent;
if (msg?.ch !== channel || msg.v !== 1) return;
handleCommand(msg);
});
function emit(msg: ToParent) {
window.parent.postMessage(msg, parentOrigin);
}
Can a hostile parent lie about its origin in the fragment? It can put anything there, but it gains nothing: the only things we ever send to the parent are state flags and a payment method id, which the merchant's page was going to receive anyway. That is the point of designing the schema around state. The interactive lab below lets you send messages across each boundary and see what an injected skimmer on the parent can and cannot see.
Normal flow. Digits cross only between two documents on js.pay.example. The parent learns that the number is complete and the brand.
What the skimmer on the merchant page has collected so far:
- change event: complete, brand visa
Sizing is one more message. The frame cannot change its own size in the parent's layout, so it measures its content with a ResizeObserver and posts resize with the height in CSS pixels. The SDK sets the iframe's height. Without this, a larger merchant font or a wrapped error line gets clipped inside a fixed-height frame.
Check your understanding · Exercise 3
Which of these protocol choices are safe for a field frame? Select all that apply.
Tokenization: turning a card into something the merchant can charge
The merchant never sees the card, yet their server must charge it. Tokenization bridges the gap: our controller frame sends the card details to our vault (a tightly locked-down service that stores card numbers), and the vault returns a stand-in value, a token, that means "this card" only to us. Stripe calls the result a PaymentMethod (pm_...); Braintree calls it a payment method nonce; Adyen returns encrypted card fields. The merchant passes the token to their server, and their server uses it with our secret API key.
A stolen token is far less useful than a stolen card. It only works with our API, only with the merchant's secret key, and a single-use token is spent after one charge. That is why the parent page may receive it: a skimmer that grabs the token cannot charge it from the browser.
Decision Who drives the payment: the browser or the merchant's server?
What we pick: Support both, and make server-create plus browser-confirm the recommended path. Regulations like Strong Customer Authentication make the extra bank step common, and handling it inside one browser call is both simpler for merchants and better for shoppers. Keep tokenize-first for merchants who need to inspect the card (brand, country) before deciding the amount. Stripe offers both, plus a deferred variant that creates the PaymentIntent at submit time.
result arrives.const pay = await loadPay("pk_live_123");
const form = pay.cardForm({ style: theme });
form.mount("#card");
submitButton.addEventListener("click", async () => {
submitButton.disabled = true;
// 1. The merchant's server creates the payment and returns its client secret.
const { clientSecret } = await fetch("/api/payments", {
method: "POST",
body: JSON.stringify({ cartId }),
}).then((r) => r.json());
// 2. Our frames tokenize the card and confirm, including any 3-D Secure step.
const result = await form.confirm({ clientSecret, returnUrl: location.href });
if (result.status === "succeeded") location.assign("/thanks?order=" + result.paymentId);
else if (result.status === "processing") showPending(); // never invite a second payment
else if (result.status === "failed") {
showError(result.error.message);
submitButton.disabled = false;
}
// "redirecting": the page is leaving for the bank; keep the spinner.
});
Single-use and reusable tokens
A single-use token stands for one charge. Stripe's legacy card tokens (tok_...) can be used once, and Braintree's nonces are single-use and expire after a few hours. This limits the damage if one leaks.
A reusable credential is what a merchant needs to save a card for next time or for a subscription: it is created on our side by attaching the payment method to a customer record, only on the server, with the secret key. The card form only ever produces the single-use kind. Saving is a server decision, with the shopper's consent captured in the merchant's UI (a "Save this card" checkbox whose value goes to the merchant's server, not into our frame).
| Value | Who sees it | What it can do if stolen |
|---|---|---|
| Card number, expiry, CVC | Our frames and our vault only | Everything. This is what we protect. |
Publishable key pk_... | Public, in the merchant's page | Create tokens, and confirm an existing payment only together with its client secret. Cannot move money on its own. |
Single-use token or pm_... | Merchant's page and server | Charge once, and only with the merchant's secret key. |
| Client secret of one payment | Merchant's page | Confirm that one payment for its fixed amount. Should not be logged or stored. |
Secret key sk_... | Merchant's server only | Full API access. Never in the browser. |
Check your understanding · Exercise 4
A skimmer script on the merchant's checkout page captures values as they pass through the merchant's JavaScript. Which captures are harmless or close to harmless? Select all that apply.
Inside the number field: formatting, brands, and validation
Now open a single field frame. It is one input, but it carries most of the user experience. The shopper types 16 digits on a phone keyboard, often while walking; every keystroke must format the number, detect the brand, update the CVC rule in a sibling frame, and decide whether to show an error. All of this runs inside our frame with no network call.
The right input element
<label for="num" class="sr-only">Card number</label>
<input id="num" name="cardnumber" type="text"
inputmode="numeric" autocomplete="cc-number"
autocorrect="off" spellcheck="false"
aria-invalid="false" aria-describedby="num-error" />
<p id="num-error" role="alert"></p>
<!-- Sibling frames -->
<input autocomplete="cc-exp" inputmode="numeric" placeholder="MM / YY" />
<input autocomplete="cc-csc" inputmode="numeric" placeholder="CVC" />
type="number" is wrong for card numbers: it drops leading zeros, shows spinner arrows, accepts e, and cannot hold spaces. inputmode="numeric" gives the digit keypad on phones while keeping a text input. The autocomplete tokens cc-number, cc-exp, and cc-csc tell the browser's saved-card autofill which field is which.
Brand detection from the first digits
The first 6 to 8 digits of a card are its IIN or BIN (issuer or bank identification number). The first few are enough to tell the network, and the network decides the length, the grouping, and the CVC length. We detect locally for instant feedback, from a small table shipped in the frame.
| Brand | Starts with | Length | Grouping | CVC |
|---|---|---|---|---|
| Visa | 4 | 16 (some 13 or 19) | 4 4 4 4 | 3 digits |
| Mastercard | 51 to 55, 2221 to 2720 | 16 | 4 4 4 4 | 3 digits |
| American Express | 34, 37 | 15 | 4 6 5 | 4 digits |
| Discover | 6011, 644 to 649, 65 | 16 to 19 | 4 4 4 4 | 3 digits |
The 2221 to 2720 Mastercard range is the classic trap: many older regexes only check 5[1-5], and they reject valid cards. The industry also moved from 6-digit to 8-digit BINs in 2022, so the local table is a hint for the UI, not the truth. Treat unknown as allowed until the number is complete; do not block a card because our table is out of date.
Formatting as you type without losing the caret
The naive approach reformats input.value on every input event. It looks fine when typing at the end.
Then the shopper fixes a typo in the middle: they put the caret after the 6th digit and type, we rewrite the value, and the browser jumps the caret to the end. The next digit lands in the wrong place. The fix is to count digits before the caret, reformat, and put the caret back after the same number of digits.
function onInput(el: HTMLInputElement) {
const caret = el.selectionStart ?? el.value.length;
const digitsBefore = el.value.slice(0, caret).replace(/\D/g, "").length;
const brand = detectBrand(el.value);
const digits = el.value.replace(/\D/g, "").slice(0, maxLength(brand));
const formatted = group(digits, gapsFor(brand)); // "4242 4242 42"
el.value = formatted;
let pos = 0;
for (let seen = 0; pos < formatted.length && seen < digitsBefore; pos++) {
if (formatted[pos] !== " ") seen++;
}
el.setSelectionRange(pos, pos);
report(digits, brand); // validate, then emit state to the parent
}
The same function handles paste: 4242-4242-4242-4242 or a number with spaces from a password manager is stripped to digits, cut to the brand's maximum length, and grouped. Backspace needs one extra rule: deleting the space after 4242 removes nothing that counts, so we treat a Backspace right after a gap as deleting the digit before it. Spot it with the beforeinput event's inputType of deleteContentBackward: Android keyboards often report an unidentified key in keydown, so a Backspace check there (which the lab below uses) is not enough on phones.
Validation: Luhn, expiry, CVC
- Luhn check: card numbers end with a check digit. From the right, double every second digit, subtract 9 from any result above 9, and sum everything; a valid number sums to a multiple of 10. It catches every single wrong digit and most swaps of two neighbours. It does not tell you the card exists.
- Expiry: a card is valid through the last day of its expiry month.
04 / 26is still good on 30 April 2026. Reject past months, months above 12, and dates absurdly far in the future (we use 20 years; an estimate, not a rule). Use the shopper's local date: being off by a timezone on the last day is better than rejecting a valid card. - CVC: 3 digits, or 4 for American Express. The number frame tells the controller the brand, and the controller tells the CVC frame its new length. If the shopper typed 4 digits before the number, re-check the CVC when the brand arrives.
function luhnValid(digits: string): boolean {
let sum = 0;
for (let i = 0; i < digits.length; i++) {
let d = Number(digits[digits.length - 1 - i]);
if (i % 2 === 1) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
}
return digits.length >= 12 && sum % 10 === 0;
}
When to show an error
Showing "Your card number is invalid" after the first digit is hostile: the number is not invalid, it is unfinished. The rule we use is late to complain, early to forgive.
An error appears on blur if the field is incomplete or invalid, or immediately when the input can no longer become valid (a full-length number that fails Luhn, a past expiry, a first digit no supported network uses). Once an error is showing, it clears on the very keystroke that fixes it. The input-validation lesson covers this pattern for forms in general.
change event with empty, complete, brand, and the error, if any.Try it in the lab below. Type, move the caret, paste, and blur; watch the formatted value, the caret position, the brand, the Luhn result, and when the error appears. To stay small, the lab knows only the common lengths: 16 digits, and 15 for American Express.
- Luhn check: not checked until complete
- CVC field: 3 digits
- Error: hidden until blur, unless the number can no longer become valid
- Type a test number, or use a button.
Autofill across frames
Saved-card autofill is the fastest way to pay on a phone, and split fields are where it breaks. With a single card frame, all three fields are in one document and browsers fill them together. With one frame per field, the browser sees three separate cross-origin documents.
Browsers are careful here, because a malicious frame could otherwise collect a card meant for another one. Chrome limits which cross-origin frames it will fill card numbers and security codes into. It has also proposed a shared-autofill permission that lets the parent mark trusted frames, but it is testable only behind a flag. Safari and Firefox behave differently again, and behaviour changes between versions.
So we design for the case where only the focused frame gets filled. One technique some providers use: the number frame also contains visually hidden cc-exp and cc-csc inputs. If the browser fills them along with the number, the number frame passes those values to the controller (same origin), which forwards them to the expiry and CVC frames. Then we test the matrix of browsers on every release, because this is the part most likely to break silently.
Local networks, co-badged cards, copy, and direction
A global form meets local card rules. The brand table above grows per region, and some rules change the UI, not only the copy. The locale-formatting lesson covers numbers and dates in general; these are the payment-specific parts.
- Local networks: JCB in Japan, UnionPay (numbers starting
62) in China, Cartes Bancaires in France, and others. Some UnionPay numbers do not pass the Luhn check, so validators such as Braintree's card-validator skip Luhn for UnionPay by default. Unknown is not invalid. - Co-badged cards: a French card can be both Cartes Bancaires and Visa. EU rules let the shopper choose the network, so the number field shows a small brand picker once the server's BIN lookup reports two networks.
- Postal code: shown or hidden based on the card's country, with the right keyboard: digits for a US ZIP, letters and digits for a UK postcode.
- Copy: error messages, labels, and placeholders come from our translated strings for the
localethe merchant passes, or the browser's language withauto. The merchant can override labels to match their page. - Direction: in right-to-left pages the frame mirrors its layout, but the card number, expiry, and CVC stay left to right (
dir="ltr"on the inputs), because that is how they are printed on the card.
Keep all of this data in the frames, loaded with the frame's code, so a new network or a new translation ships to every merchant without them changing a line.
Check your understanding · Exercise 5
The shopper types 2223 0000 4841 0010. Which statements are true? Select all that apply.
Styling and accessibility across the frame boundary
Isolation has a cost the merchant feels at once: their CSS stops at the iframe. Their font, their input colours, their focus ring, their error colour: none of it reaches inside. And assistive technology sees two kinds of documents stitched together. This chapter is about making a set of foreign documents look and behave like one native form.
A style object, not a stylesheet
Decision How does the merchant style what is inside our frames?
What we pick: Use a style object for single fields and themes plus variables for the full form, both validated against an allowlist. Raw CSS is a data leak and a compatibility trap. Every value is checked: colours must parse as colours, sizes as lengths, and nothing may contain url( or a semicolon.
- Fonts: the frame cannot use a font the parent loaded. The merchant passes a font source (a stylesheet URL from an allowed host such as Google Fonts, or a font file URL), and the frame loads it with its own
@font-face. Until it loads, usefont-display: swapand a close system fallback so the field is usable at once. - State classes on the parent: the SDK adds classes like
pay-field--focus,--invalid,--complete, and--emptyto the merchant's container element. The merchant's own CSS then draws the border and focus ring around the iframe, in their exact style. This is the cheapest way to make split fields match the rest of the form. - Size: as described earlier, the frame reports its content height with
resizemessages, so larger fonts and error lines are never clipped. - Colour scheme: the merchant's theme may differ from the system theme, so the frame follows the style object, not only
prefers-color-scheme.
Accessibility when the input lives in another document
Screen readers handle iframes well today: they read into the frame as if it were part of the page. What does not cross the boundary are ID references. A label for="num" in the parent cannot point at an input in our frame, and aria-describedby in the frame cannot point at an error message in the parent. So every frame must be accessible on its own.
- Names: each input has a real label inside the frame (visually hidden if the merchant shows their own visible label). The merchant passes the label text, so it matches their language. The
iframeelement gets atitlelike "Secure card number input", which some screen readers announce when entering the frame. - Errors: the frame sets
aria-invalid="true"and links the input to an error message inside the frame witharia-describedby. That satisfies WCAG 3.3.1 Error Identification (the error is identified and described in text). Messages say how to fix it, such as "Your card's expiry date is in the past. Check the month and year.", which is WCAG 3.3.3 Error Suggestion. - Announcing: put the message in a live region once, either inside the frame (default) or in the merchant's DOM (if the merchant renders errors from
changeevents). Never both, or screen readers read every error twice. Make it an explicit option. - Clicking a merchant label: the SDK listens for clicks on the merchant's label and sends a
focuscommand, so tapping "Card number" focuses our input as it would a native one.
Tab order works without our help: browsers include iframe content in sequential focus navigation at the iframe's position in the parent's DOM. Tab from the merchant's email field enters the number frame, Tab again leaves it for the expiry frame, and so on to the merchant's Pay button.
Two things break it: a merchant using tabindex values above 0 on their own inputs, and a hidden autofill input inside a frame that can receive focus. Hidden helper inputs need tabindex="-1" and aria-hidden="true".
Programmatic focus, such as moving to the expiry field once the number is complete, goes through the controller. Be careful with auto-advance: it surprises screen reader users and people fixing a typo, so only advance when the field just became complete by typing, never after a paste or an edit in the middle. The focus-management and aria-patterns lessons go deeper.
Check your understanding · Exercise 6
A merchant reports that screen reader users hear "edit text, blank" on our CVC field and hear each error twice. Which fixes are right? Select all that apply.
Wallets and 3-D Secure inside the form
Two flows interrupt the simple "type card, press Pay" path. Wallets (Apple Pay, Google Pay) skip typing entirely; the browser or operating system shows its own payment sheet. 3-D Secure (3DS) adds a step after the shopper presses Pay, when the bank wants to check who they are. Both run partly outside our frames, and both must end in the same result event the merchant already handles.
Apple Pay, Google Pay, and the Payment Request API
- Payment Request API: a W3C standard (
new PaymentRequest(methods, details)) where the page describes what it wants to charge and the browser shows a native sheet. MDN lists support in Chromium browsers and Safari, not in Firefox. In practice it is a carrier for wallets: in Safari the method is Apple Pay, in Chrome it can be Google Pay. - Apple Pay on the web: Safari's Apple Pay JS (
ApplePaySession) or Payment Request with the Apple Pay method. Every domain that shows the button must be registered with Apple, which checks a file served at/.well-known/apple-developer-merchantid-domain-association. Providers usually host the merchant identity and ask merchants to register each domain through the provider's dashboard or API. Since Safari 17, Apple Pay works inside a cross-origin iframe if the parent grants it withallow="payment". - Google Pay: Google's
pay.jslibrary shows the button and returns an encrypted payment token for our gateway. It works in most browsers, not only Chrome.
For us, the decisions are where the button lives and how it reaches the same confirm path. We render the wallet button inside our own frame (with allow="payment" set by the SDK when it creates the iframe), check availability first (ApplePaySession.canMakePayments(), Google Pay's isReadyToPay) and tell the parent through a wallets event, so the merchant can hide the "or pay with card" divider when no wallet is available.
When the shopper authorizes in the sheet, the wallet hands an encrypted token to our frame, the controller exchanges it for a payment method id, and from there it is the same confirm call as a card. One flow, one result event.
3-D Secure: frictionless and challenge
The amazon guide explains the 3DS2 protocol between merchant, card network, and bank. Here is what the form must do. When confirm reaches our API, the payment may come back as requires_action: the bank wants authentication. Two paths follow.
- Device data collection: our controller loads the bank's 3DS Method URL in a hidden iframe. The bank's page collects browser data (screen size, timezone, language) and posts back. The EMVCo spec gives it up to 10 seconds; if it does not finish, authentication goes on without it.
- Frictionless: the bank's risk engine decides the shopper is who they say they are. No UI. The shopper sees only a slightly longer spinner.
- Challenge: the bank wants proof, such as a one-time code or a push approval in the banking app. The controller has no UI, so it asks the SDK to draw a modal over the merchant's page. The modal holds one of our frames, with the bank's page (served by its Access Control Server, or ACS) nested inside. When the shopper finishes, the bank's page posts its result to our frame, and the controller asks our API for the payment status.
| challengeWindowSize | Width by height (px) | When we pick it |
|---|---|---|
01 | 250 by 400 | Very narrow containers; rarely used. |
02 | 390 by 400 | Phones in a modal; the common default. |
03 | 500 by 600 | Desktop modal with room to spare. |
04 | 600 by 400 | Wide but short containers. |
05 | Full screen | Small phones, or when the modal would not fit. |
Some payments still need a full-page redirect instead: some 3-D Secure setups, some wallets, and many local payment methods, such as bank redirects. Then the whole page leaves, and the merchant's returnUrl receives the shopper back with the payment id in the query string. The checkout-flow guide covers that return page. On our side, confirm must say clearly whether it resolved in place or is about to navigate, so the merchant does not show a spinner forever.
Check your understanding · Exercise 7
Which statements about wallets and 3-D Secure in our form are correct? Select all that apply.
Confirming exactly once: double clicks, idempotency, and dropped connections
The checkout-flow guide covers the merchant's side of this: a guard on the Pay button, an idempotency key for placing the order, and a confirmation page that asks the server. This chapter is about our side. Thousands of merchants call form.confirm(), and some of them will call it twice, retry it in a loop, or unmount our frames halfway. Our SDK, controller, and API must turn all of that into at most one charge.
The naive version: confirm posts a message, the controller calls our confirm endpoint, and any fetch error becomes "Payment failed". Now a shopper on a train presses Pay for 81.40. The request reaches our API, the bank approves, and the response is lost in a tunnel. The merchant shows "Payment failed, try again".
The shopper presses Pay again. If the merchant creates a new payment on every click, a common mistake, the shopper is charged twice. If not, the second confirm fails with a confusing "already succeeded" error. Both are our problem, because both start with us reporting a failure we did not know was a failure.
Decision Where do we stop a second confirm?
What we pick: Use all three, each for a different job. The server is the guarantee, the SDK guard stops a burst of clicks from becoming a burst of requests, and the merchant's disabled button tells the shopper something is happening. Never rely on the browser layers alone: a reload wipes them.
let inFlight: Promise<ConfirmResult> | null = null;
export function confirm(opts: ConfirmOptions): Promise<ConfirmResult> {
if (inFlight) return inFlight;
const requestId = crypto.randomUUID();
inFlight = request(controller, {
type: "confirm", requestId, clientSecret: opts.clientSecret,
}).finally(() => {
inFlight = null;
});
return inFlight;
}
confirm is accepted only in ready, which is what makes the SDK guard work. A timeout leads to processing, which asks the server, so the shopper is never invited to pay again for a payment that may have gone through.When the response never comes
A confirm that times out has three possible histories: it never reached us, it reached us and failed, or it reached us and succeeded. The browser cannot tell them apart. So the controller does two things, in order.
First it retries with the same idempotency key, a few times with backoff (the retries-and-backoff lesson covers the timing). This is safe: if the first attempt went through, the API replays the stored result. Stripe, for example, keeps idempotency keys for at least 24 hours and rejects a reused key with a different request body.
Second, if the network is still down, it asks instead of guessing: when the online event fires, and on a slow timer as well (the event only means a network interface came back, not that our API is reachable), it fetches the payment's status with the client secret.
Until it knows, the result we hand the merchant is processing, not an error. Our docs say what to show: "We are checking your payment. Do not pay again." A bank authorization normally answers in a few seconds; we give a confirm 30 seconds before we report processing (our estimate, tune it with real latency data).
Two more provider-side traps. The merchant's framework may re-render and remove our frames mid-confirm, so the SDK attaches the controller to document.body, outside the merchant's container, and keeps it alive until the confirm settles. And if the page reloads, nothing in the browser survives: the merchant's server reads the payment status on the next load, as checkout-flow describes.
processing, then succeeded: never a failure followed by a second charge.Check your understanding · Exercise 8
The controller sent a confirm, waited 30 seconds, and got no response. Which next steps are safe? Select all that apply.
Threats to the payment page, and what the provider can do
The frames stop one attack completely: a script on the merchant's page reading what the shopper types. Attackers know that, so they go around the frames.
The checkout-flow guide covers web skimming and a strict Content Security Policy from the merchant's side. Here we list the attacks against our form and what we, the provider, build against each. The clickjacking-and-iframes, csp, xss, and third-party-scripts lessons cover the mechanisms.
- Skimmer on the parent (Magecart, formjacking): a compromised script on the checkout page. In 2018 one such script on British Airways' booking site sent the card details of hundreds of thousands of customers to a look-alike domain. Against our frames it cannot read inputs, so it draws a fake card form over ours, or hides our frame and shows its own. Our defence is limited, which is why PCI now asks the merchant to control the scripts on the page. What we add: change detection and script monitoring tools for merchants, and a strict published list of our origins.
- Swapped frame: the skimmer changes the iframe's
srcto a look-alike domain, or loads a fake copy of our SDK that creates fake frames. The page looks identical. The defences are the merchant's: a CSPframe-srcandscript-srcthat only allow our exact origins, which stops the swap even if a skimmer runs. - Stolen results: the skimmer reads the token, the brand, or the client secret as they pass through the SDK. As the tokenization chapter showed, these are designed to be close to useless without the merchant's secret key.
- Clickjacking: a hostile page loads our frame invisibly and tricks the shopper into clicking our button. For plain inputs the risk is small: the shopper still has to type a card. It matters for one-click buttons we render, such as a wallet button or "Pay with saved card". Those need a real user gesture, and a saved-card payment from an unknown page should require a fresh CVC or 3-D Secure. Chromium's
IntersectionObserverwithtrackVisibilitycan report whether our frame is actually visible; other browsers do not support it yet, so treat it as an extra signal. - Malicious parent: a fraudster builds their own page around our real frames with a publishable key, to test thousands of stolen card numbers (card testing). Our frames work exactly as designed, so the defence is on our servers: rate limits per key, per IP, and per device, a CAPTCHA step when patterns look automated, and declines that do not reveal which check failed.
Who may embed our frames: frame-ancestors
Content-Security-Policy: frame-ancestors tells the browser which origins may put a page in a frame. For our field frames we cannot say 'none': being framed by merchants is the product.
Allowing any HTTPS origin is the simple choice; a stricter one sends a per-merchant list built from domains the merchant registered, like Apple Pay domain registration, so a phishing site with a stolen key cannot embed the fields at all. That costs a registration step for every merchant and every new domain. Our other pages are different: the dashboard, the login page, and the full-page hosted checkout send frame-ancestors 'none', because no one should ever frame them.
Decision Should merchants pin our script with Subresource Integrity?
What we pick: Pick one per product and be clear about it. For a provider that changes fraud signals and browser workarounds weekly, the evergreen URL plus a strong release process (signed builds, staged rollouts, monitoring of the served file) is the better default. Offer versioned, hashed URLs for merchants whose security team requires SRI, with a published support window for each version.
Fraud signals come from the same script. Providers collect device and behaviour signals (browser features, how the page was reached, typing cadence in our fields) and feed them to a risk model such as Stripe Radar. Stripe recommends loading Stripe.js on every page of the site, not only checkout, so the model sees more of the visit.
How much any one signal is worth is not public, and the design does not depend on it. What matters is that the risk script runs in our origin or frames, never asks the parent for card data, and is listed in our privacy documentation for merchants.
Check your understanding · Exercise 9
A skimmer runs on a merchant's checkout page, which uses our iframe fields. The merchant has a strict CSP whose frame-src and script-src allow only our origins for payments. Which attacks still work? Select all that apply.
Architecture, data model, and interface
Time to put the pieces in one picture. The rule that decides every boundary: card digits exist only in documents on our origin. Everything the merchant's code can touch, including our own SDK, gets state and results. Each part below owns one job and has one thing it must never hold.
| Part | Runs in | Owns | Never holds |
|---|---|---|---|
SDK pay.js | Merchant's page | Creating frames, the channel id, the public API, state classes on containers | Card data |
| Field frames | Our origin, one per field | One input: formatting, brand, local validation, label, error text | Network calls to the vault |
| Controller frame | Our origin, hidden | Form state, cross-field rules, tokenize and confirm calls, retries, 3-D Secure steps | Anything from the merchant's DOM |
| Wallet frame | Our origin | Wallet buttons, availability checks, the wallet token | The merchant's amount logic |
| Challenge frame | The bank's origin, in our modal | The bank's 3-D Secure page | Anything of ours |
| API, vault, risk | Our servers | Tokens, payment state, idempotency records, risk scores | Secrets in responses to the browser |
Why a separate controller instead of making the number frame the boss? Because the merchant can destroy any field frame (a re-render, a layout change), and the payment in flight must not die with it. That is why the controller has no UI and sits outside the merchant's container, as the confirming chapter showed. Being the only frame that calls the vault also gives us one place to audit for where card data goes.
Data model: what lives where
type FieldState = {
field: Field;
status: "pristine" | "editing" | "valid" | "invalid";
empty: boolean;
complete: boolean;
brand?: Brand; // number field only
error?: { code: string; message: string };
};
type FormState =
| { status: "incomplete" | "ready" }
| { status: "confirming" | "processing"; requestId: string }
| { status: "requires_action"; requestId: string; action: "challenge" | "redirect" }
| { status: "succeeded"; paymentId: string }
| { status: "failed"; code: string; retryable: boolean };
// Only inside our frames, only in memory, never posted to the parent.
type CardDraft = { number: string; expMonth: number; expYear: number; cvc: string };
CardDraft appears only in the two columns on our frames' origin, and only in memory; the merchant column holds copies of state and nothing else.| Data | Source of truth | Copies | Lifetime |
|---|---|---|---|
CardDraft | Field frames (memory) | Controller, only while tokenizing | Until tokenized or the page closes. Never in storage. |
FieldState | Its field frame | Controller; a summary in the SDK for change events | The form's life |
FormState | Controller | SDK mirror, so confirm can refuse a second call | The form's life |
| Payment status | Our API | None that the UI trusts | Days; asked again after any doubt |
| Channel id | SDK | Every frame's URL fragment | One form instance |
Interface: the SDK and the API behind it
const pay = await loadPay("pk_live_123", { locale: "auto" });
const form = pay.cardForm({
layout: "split", // or "single"
style, fonts, labels, // validated allowlist, see styling
announceErrors: "frame", // or "merchant"
});
form.mount({ number: "#cc-number", expiry: "#cc-exp", cvc: "#cc-cvc" });
form.on("change", (e) => setPayEnabled(e.formComplete));
form.on("submit", () => payButton.click()); // Enter pressed in a frame
const result = await form.confirm({ clientSecret, returnUrl });
// { status: "succeeded", paymentId } | { status: "processing" }
// | { status: "redirecting" } | { status: "failed", error: { code, message } }
form.destroy();
| Call (from the controller) | Auth | Returns |
|---|---|---|
POST /v1/payment_methods | Publishable key | { id: "pm_...", brand, last4, country } |
POST /v1/payments/:id/confirm with Idempotency-Key | Publishable key plus client secret | { status, next_action? }, where next_action holds the 3-D Secure method and challenge URLs |
GET /v1/payments/:id?client_secret=... | Publishable key plus client secret | { status }, used after timeouts and challenges |
Version the frame messages on purpose. The SDK and the frames ship from our origin, but a merchant's page may hold an SDK loaded an hour ago while a fresh frame loads today. Every message carries v, a frame accepts the current and previous version, and new fields are added, never renamed. The embeddable-widget guide goes deeper on versioning a postMessage SDK.
Check your understanding · Exercise 10
Which design choices keep card data out of the merchant's reach? Select all that apply.
Running it in the interview
This problem has more depth than an hour can hold, so the skill is choosing. Say the framing in the first minute: "I am the provider, building the embedded card form; the merchant's checkout page is a client of mine." Then spend most of the time where the trust boundary and the user experience meet: frames, the message protocol, tokenization, and confirming once.
| Minutes | What to do | What to draw |
|---|---|---|
| 0 to 5 | Framing, requirements, the PCI goal (SAQ A) | Nothing yet; list the five hard properties |
| 5 to 15 | Hosted fields: one frame or many, the controller | Merchant page, SDK, field frames, controller, API |
| 15 to 25 | The message protocol and tokenization | Message arrows labelled with what crosses; the confirm sequence |
| 25 to 35 | Card input: formatting, brand, Luhn, error timing, autofill | The field state machine |
| 35 to 45 | Exactly once: SDK guard, idempotency, lost response | The form state machine with processing |
| 45 to 55 | Threats, 3-D Secure, wallets, accessibility | The threat map |
| 55 to 60 | Trade-offs you would revisit | Nothing new |
Say out loud what crosses each boundary every time you draw an arrow. "This arrow carries complete: true and the brand, never digits" is the sentence that shows you understand the design. Expect these follow-ups.
| Follow-up | A strong answer |
|---|---|
| Why not let the merchant's own inputs post straight to your API? | That is the SAQ A-EP setup: every script on their page can read the inputs. Frames keep the merchant in SAQ A and keep skimmers out of the inputs. |
| How does the CVC field know it needs 4 digits? | The number frame detects American Express and tells the controller; the controller tells the CVC frame. Both on our origin, with exact targetOrigin. |
| A shopper says they were charged twice. What could have happened? | Most likely the merchant created a new payment per click, or retried with a new idempotency key after a timeout. Our side: SDK guard, same key on retries, processing instead of an error. |
| How do you ship a fix to 100,000 merchants? | An evergreen script and frames on our origin, staged rollout, and versioned messages so old SDKs keep working with new frames. |
| Can the merchant's page see the last four digits? | Only after tokenization, from the payment method, which is the same thing a receipt shows. |
Summary
- Frame it as the provider: you build the card form merchants embed. The merchant's checkout page is a client of yours and has its own design problem.
- Card digits live only in cross-origin frames on the provider's origin. The merchant's page, including your own SDK, gets state (empty, complete, brand, error) and a token, never digits.
- Iframe fields keep merchants on SAQ A, but since PCI DSS v4 the hosting page's scripts still matter: requirements 6.4.3 and 11.6.1, or the SAQ A script-attack eligibility criterion.
- Use a hidden controller frame for split fields: one owner of shared state, cross-field rules, and the only network call carrying card data.
- Check
event.originandevent.sourceon every message, always post to an exacttargetOrigin, and design a schema with no message that could carry the card. - Format by counting digits before the caret, detect brands from IIN ranges including Mastercard
2221to2720, run Luhn and expiry checks locally, and show errors late but clear them early. - Exactly once needs three layers: an SDK guard against double calls, idempotency keys on every retry, and a server payment object that can succeed only once. A timeout is
processing, not a failure. - Skimmers go around the frames: fake overlays, swapped frames, card testing. Defend with exact origins for CSP, frame-ancestors on pages that must never be framed, server-side rate limits, and risk signals.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- PCI SSC document library: PCI DSS v4.0.1 and the Self-Assessment Questionnaires The standard, SAQ A and SAQ A-EP, and the requirements 6.4.3 and 11.6.1.
- PCI SSC blog: FAQ clarifies new SAQ A eligibility criteria for e-commerce merchants The exact SAQ A script criterion and the two ways FAQ 1588 allows a merchant to meet it.
- Stripe docs: Stripe.js reference A real hosted-fields SDK: elements, events, confirmPayment, and loading rules.
- Braintree docs: Hosted Fields overview One iframe per field, styling, and events from another provider.
- Stripe API reference: idempotent requests How idempotency keys behave, including how long they are kept.
- MDN: Window.postMessage() targetOrigin, event.origin, and the security notes for cross-origin messaging.
- MDN: Content-Security-Policy frame-ancestors Who may embed a page.
- Chrome for Developers: Shared autofill across iframes, an initial proposal Why browsers limit autofill into cross-origin card frames, and the proposed fix.
- MDN: Payment Request API The browser payment sheet and its support table.
- WCAG 2.2: Understanding Error Identification (3.3.1) What an accessible error message must do.
- MDN: Subresource Integrity The integrity attribute and why it needs a fixed file.
Concepts behind it
- Clickjacking and iframesIframes power embeds, payment fields, and widgets, and also let an attacker hide your page under a fake one to steal a click: control both directions with framing headers, the sandbox attribute, and checked postMessage.
- Content Security PolicyA response header that tells the browser which scripts, frames, and connections a page may use, so an injected script is blocked even when an XSS bug slips through.
- Third-party scripts and supply chainEvery script you load from a vendor and every package you install runs with your page's full power, so you pick them carefully, pin and verify them, isolate what you can, and pay attention to what they cost in speed and privacy.
- Validating and sanitizing inputChecks in the browser make forms friendly; checks on the server keep the data and the system safe, from file uploads to redirect URLs to the URLs your server fetches for users.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Retries, timeouts, and backoffHow a client survives a flaky network and a struggling server: give every request a deadline, retry only the failures that can heal, space the retries out with exponential backoff and jitter, and stop retrying when the server or the network is clearly down.