E-Commerce Checkout UI: Step-by-Step, Trust Signals, Error States
Build an e-commerce checkout UI that converts — step-by-step flow, inline error states, trust signals, and React code patterns that don't leak revenue.
Why Checkout UI Is Where Revenue Lives and Dies
The average e-commerce cart abandonment rate in 2023 hit 70.19%, according to Baymard Institute. That's not a traffic problem. It's a UI problem. Seven out of ten people who put something in a cart leave before paying, and a huge chunk of them bail specifically because the checkout felt confusing, slow, or sketchy.
Honestly, most checkout flows are a mess of stacked form fields, mystery error messages, and a final payment page that looks completely different from the rest of the site. That visual inconsistency alone is a trust killer. You wouldn't hand your credit card to someone at a market stall who suddenly started speaking a different language — your users feel the same thing when the checkout design switches style mid-flow.
That said, fixing checkout UI isn't magic. It's a set of repeatable patterns: a clear step-by-step structure, inline error handling that doesn't punish the user, and trust signals placed exactly where anxiety spikes. This article walks through all three, with React component patterns you can drop in today.
Worth noting: the visual style of your checkout matters too. A brutalist or cyberpunk aesthetic might be perfect for your brand, but your checkout should dial that back toward clarity. Empire UI ships components across every visual style — you can stay on-brand without sacrificing usability.
Structuring the Step-by-Step Flow
The single biggest structural mistake is showing all checkout fields on one page. Address, shipping, payment, review — all at once, all 28 fields, staring at the user like a tax return. Don't do that. Three steps max. Information → Shipping → Payment. Each step should feel like its own small win.
A progress indicator is non-negotiable. Four to eight pixels of visual height is all a step bar needs, but it pulls enormous psychological weight. Users who can see how far along they are bounce less. Keep the labels short: Cart → Info → Shipping → Payment → Done. That's it. No clever naming.
// CheckoutStepper.tsx
const STEPS = ['Cart', 'Info', 'Shipping', 'Payment'];
interface CheckoutStepperProps {
currentStep: number; // 0-indexed
}
export function CheckoutStepper({ currentStep }: CheckoutStepperProps) {
return (
<nav aria-label="Checkout progress" className="flex items-center gap-0">
{STEPS.map((label, i) => {
const done = i < currentStep;
const active = i === currentStep;
return (
<div key={label} className="flex flex-1 items-center">
<div className="flex flex-col items-center gap-1">
<div
className={[
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold transition-colors',
done ? 'bg-emerald-500 text-white' : '',
active ? 'bg-violet-600 text-white ring-2 ring-violet-300' : '',
!done && !active ? 'bg-gray-200 text-gray-500' : '',
].join(' ')}
aria-current={active ? 'step' : undefined}
>
{done ? '✓' : i + 1}
</div>
<span className="text-xs text-gray-500">{label}</span>
</div>
{i < STEPS.length - 1 && (
<div
className={`h-0.5 flex-1 mx-2 transition-colors ${
done ? 'bg-emerald-500' : 'bg-gray-200'
}`}
/>
)}
</div>
);
})}
</nav>
);
}One more thing — always persist step data in state or session storage. If a user navigates back to fix their email address, their shipping choice shouldn't disappear. That's a rage-quit moment. Use sessionStorage or a checkout context with useReducer so the whole flow is stateful end-to-end.
In practice, the payment step warrants its own visual treatment. Drop the sidebar order summary, bring the payment form front and center, and reduce everything else to a quiet header. Users are making a financial decision at that point — the UI should respect the weight of that moment, not compete with it.
Inline Validation and Error States That Don't Make People Feel Stupid
Validation on submit is the enemy. You fill out a form, hit the button, and then twelve red borders explode at once. Congratulations, you've turned checkout into a game of Whack-a-Mole. Validate on blur instead — when the user leaves a field, check it quietly and show the result right there.
The error message should sit immediately below the input, in 14px text, in red, with an icon. Not above the field, not in a toast at the top of the page. Below. Where the eye naturally goes after leaving a field. The message itself should tell the user *what to do*, not just that they're wrong. "Enter a valid email address" beats "Invalid email" every time.
// FormField.tsx
import { InputHTMLAttributes } from 'react';
interface FormFieldProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
hint?: string;
}
export function FormField({ label, error, hint, id, ...inputProps }: FormFieldProps) {
const fieldId = id ?? label.toLowerCase().replace(/\s+/g, '-');
const errorId = `${fieldId}-error`;
return (
<div className="flex flex-col gap-1">
<label htmlFor={fieldId} className="text-sm font-medium text-gray-700">
{label}
</label>
<input
id={fieldId}
aria-describedby={error ? errorId : hint ? `${fieldId}-hint` : undefined}
aria-invalid={!!error}
className={[
'rounded-lg border px-3 py-2 text-sm outline-none transition-all',
'focus:ring-2 focus:ring-violet-500 focus:border-violet-500',
error
? 'border-red-400 bg-red-50 text-red-900 placeholder-red-300'
: 'border-gray-300 bg-white text-gray-900',
].join(' ')}
{...inputProps}
/>
{error && (
<p id={errorId} role="alert" className="flex items-center gap-1 text-xs text-red-600">
<span aria-hidden="true">⚠</span> {error}
</p>
)}
{hint && !error && (
<p id={`${fieldId}-hint`} className="text-xs text-gray-400">{hint}</p>
)}
</div>
);
}Quick aside: credit card fields are a special case. Format them as the user types — 4242 4242 4242 4242 not 4242424242424242. Move focus automatically from card number to expiry after 16 digits. This is a 30-minute implementation that meaningfully drops payment errors. Libraries like cleave.js or react-payment-inputs handle this without you writing the regex from scratch.
Address autocomplete via Google Places or @mapbox/search-js-react is worth the API cost. People abandon checkout when the address form fights with them. Autocomplete means three keystrokes instead of forty, and it eliminates typo-induced delivery failures that eat into support costs.
Trust Signals: Where to Place Them and Why Most Sites Get It Wrong
A trust signal placed in the wrong location is as useless as no trust signal at all. Most checkouts slap a padlock icon in the footer and call it done. But anxiety peaks at three very specific moments: when the user first lands on the checkout page, when they reach the payment form, and the instant before they click the final submit button. That's where your trust signals need to live.
On the information step, a thin banner directly below your header — 🔒 Secure checkout · SSL encrypted — anchors early trust. It should be subtle, not a giant marketing block. Think 36px tall, grey background, small text. The aesthetic of the glassmorphism components translates well here: a frosted, semi-transparent strip that doesn't scream but still registers.
On the payment step, place trust signals in three spots: directly above the card input (Your card details are encrypted and never stored), alongside the submit button (card logos — Visa, Mastercard, Amex at 24px height), and below the button (a one-line money-back guarantee). The 24px height for card logos is deliberate — smaller and they lose brand recognition, larger and they feel like a distraction.
// TrustBar.tsx
const CARDS = ['visa', 'mastercard', 'amex', 'paypal'];
export function TrustBar() {
return (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-100 bg-gray-50 px-4 py-3">
<span className="flex items-center gap-1.5 text-xs text-gray-500">
<svg className="h-4 w-4 text-emerald-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
</svg>
256-bit SSL encryption
</span>
<div className="flex items-center gap-2">
{CARDS.map(card => (
<img
key={card}
src={`/icons/payment/${card}.svg`}
alt={card}
className="h-6 w-auto opacity-70"
/>
))}
</div>
</div>
);
}Look, star ratings and review counts near the checkout header also work — but only if your product rating is above 4.2. Showing a 3.6-star rating at checkout is an own goal. If your reviews aren't strong enough, skip it and lean harder on the guarantee copy instead.
The Order Summary Sidebar and Mobile Collapse Pattern
On desktop (viewport width above 1024px), a sticky order summary sidebar is the standard. Users need peripheral confirmation of what they're paying for throughout the flow. The sidebar should show product thumbnails at 48×48px, names, quantities, a subtotal, shipping cost line, and a bold total. Keep it under 280px wide — it should support the main form, not compete with it.
On mobile it's a different problem entirely. You can't split the viewport. The pattern that converts best is a collapsed summary bar at the top — showing just the total and a chevron — that expands on tap to reveal full line items. Don't hide this behind a separate page. Accordion-in-place keeps context without navigation.
// MobileOrderSummary.tsx
import { useState } from 'react';
interface OrderItem { name: string; qty: number; price: number; }
interface MobileOrderSummaryProps { items: OrderItem[]; total: number; }
export function MobileOrderSummary({ items, total }: MobileOrderSummaryProps) {
const [open, setOpen] = useState(false);
return (
<div className="border-b border-gray-200 bg-gray-50 lg:hidden">
<button
onClick={() => setOpen(o => !o)}
className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium"
aria-expanded={open}
>
<span className="flex items-center gap-2">
<svg className={`h-4 w-4 transition-transform ${open ? 'rotate-180' : ''}`}
viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
Order summary
</span>
<span className="font-semibold">${total.toFixed(2)}</span>
</button>
{open && (
<ul className="divide-y divide-gray-100 px-4 pb-3">
{items.map(item => (
<li key={item.name} className="flex justify-between py-2 text-sm">
<span>{item.qty}× {item.name}</span>
<span className="font-medium">${(item.qty * item.price).toFixed(2)}</span>
</li>
))}
</ul>
)}
</div>
);
}That said, don't over-engineer the summary. Product thumbnails on mobile are worth keeping — they fire the emotional "yes this is the thing I wanted" response right before payment. Even 40×40px thumbnails pull their weight. Drop them only if your product count is above eight items, at which point a scrollable list becomes more trouble than it's worth.
For the visual style of the sidebar, subtle shadows beat heavy borders. A box-shadow: 0 1px 4px rgba(0,0,0,0.06) and a 1px left border in #f3f4f6 gives the sidebar visual separation without the heavy-handed look. You can fine-tune these values using the box shadow generator to preview in real-time before committing.
Submit Button States and the Post-Click Experience
The submit button does more work than any other element in checkout. It needs to communicate five states: default, hover, focused, loading, and success. Most devs implement default and maybe loading. That's the floor, not the ceiling. Hover and focus states matter for keyboard users and anyone with a mouse. The success state — a green checkmark that replaces the spinner — tells the user the payment worked before the confirmation page loads.
Loading state is the one most teams botch. "Processing..." text with no spinner is fine for a 200ms API call. For a payment that might take 2–3 seconds, you need a spinner *and* a subtle progress cue. Don't disable the button silently — replace its content with the spinner so the user knows something is happening. And for the love of everything, prevent double-submit. One isSubmitting boolean in state, flip it on click, reset it on error.
// SubmitButton.tsx
interface SubmitButtonProps {
isLoading: boolean;
isSuccess: boolean;
children: React.ReactNode;
}
export function SubmitButton({ isLoading, isSuccess, children }: SubmitButtonProps) {
return (
<button
type="submit"
disabled={isLoading || isSuccess}
className={[
'relative flex w-full items-center justify-center gap-2 rounded-xl px-6 py-3.5',
'text-sm font-semibold text-white transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-offset-2',
isSuccess
? 'bg-emerald-500 focus:ring-emerald-400'
: 'bg-violet-600 hover:bg-violet-700 focus:ring-violet-500',
(isLoading || isSuccess) ? 'cursor-not-allowed opacity-90' : '',
].join(' ')}
>
{isLoading && (
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8v8z" />
</svg>
)}
{isSuccess ? '✓ Order placed!' : isLoading ? 'Processing...' : children}
</button>
);
}The confirmation page deserves as much attention as the checkout form itself. Redirect immediately, show a clean order number in large type (Order #8432), recap the items, and show the estimated delivery date. This is not the place for upsells. The transaction just completed — let the user feel good about it for five seconds before you start selling them accessories.
One more thing — email confirmation should fire within 30 seconds of the order. Users who don't get a confirmation email within a minute start questioning whether the payment went through, and that generates support tickets. If your email provider is slow, send a synchronous API call to trigger the confirmation before you redirect, not as a background job.
Accessibility and Keyboard Navigation in Checkout Forms
Checkout is legally sensitive territory in several US states and EU countries. WCAG 2.1 AA compliance isn't optional for e-commerce above a certain revenue threshold — it's a legal floor. The good news is that accessible checkout is also better checkout for everyone, including mobile users on slow connections who never touch a mouse.
Tab order is the first thing to audit. Every field, button, and interactive element should be reachable via keyboard in a logical sequence. Don't use tabindex values above 0 to force a custom order — instead, fix your DOM order. The checkout form should read top-to-bottom in the HTML, matching the visual layout. If your CSS is making elements appear in a different order than the DOM, you've got a keyboard trap waiting to happen.
Screen reader announcements for step changes are often missed. When the user moves from the Info step to the Shipping step, a screen reader user needs to know that happened. An aria-live="polite" region that announces the new step name takes about 10 minutes to implement and covers a real accessibility gap. Pair it with focus() called on the new step's first input so keyboard users land in the right place.
In practice, the best way to check your checkout's keyboard UX is to unplug your mouse and complete a full purchase. You'll find every broken tab order, every missing focus ring, every confirm button that needs Enter to work but only responds to click. Do this with Chromium's built-in accessibility inspector open and you'll catch 80% of issues in a single pass. The react-form-react-hook-form guide on the Empire UI blog goes deep on accessible form patterns that pair well with everything described here.
FAQ
Three is the sweet spot: Info, Shipping, Payment. Adding a fourth step (like a separate Review page) increases drop-off without meaningfully reducing errors. Keep the review inline at the bottom of the Payment step instead.
Three spots: a subtle banner at the top of the checkout page, directly above the card input field, and immediately below the submit button alongside card logos. Anywhere else and they're decorative, not functional.
On blur. Submit-time validation means users discover all their mistakes at once, which is jarring. Blur validation shows one error at a time, exactly when they leave a field, so correction feels immediate and low-stakes.
Show an inline error directly above the payment form — not a modal, not a toast. Reset the card fields, keep everything else the user typed, and give them a specific action: "Check your card number and try again" or "Try a different card."