Checkout Page Design — 9 Cart Patterns, No Library
Checkout page design covers every screen between "I want this" and the pay button.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Quantity change that tints the line total and the grand total together
- 02 Line item that slides down into the saved-for-later group
- 03 Discount line that wedges into the order summary
- 04 Quantity that springs back when it passes the stock limit
- 05 Payment method card that opens only the option you picked
- 06 Shipping fields that fold away and fill themselves in
- 07 Arrival chip whose date slides over when you change the shipping speed
- 08 Agree-to-all switch that lights the items below in turn
- 09 Bottom pay bar that steps aside once the summary is on screen
The nine follow the path of a checkout, not a difficulty ladder. That path starts in the cart, where the quantity and subtotal tint together (01), a line slides down into the saved-for-later group (02), a coupon wedges into the summary (03), and a stock limit bounces the quantity back (04). It continues through payment, where the chosen method card opens (05), the shipping fields fold away when they match the orderer (06), the arrival date rolls over with each shipping speed (07), and the consent items light up in turn (08), before the bottom pay bar finally steps aside for the summary (09). All nine share one set of colors, spacing, and easing values, so a handful of them on one checkout screen still keeps a single tone.
01Quantity change that tints the line total and the grand total together
Change the quantity, and the line subtotal and the grand total recalculate in the same tick, while only those two number cells tint with background-color and cool back down through the transition. The numbers use font-variant-numeric: tabular-nums so a wider digit never shakes the cell, and the render logged a cumulative changed area of 2.699%. Use it on cart line items or booking screens where the headcount changes.
// Number cell — tabular-nums + min-width so a wider digit never shakes the cell
.qs__sum {
min-width: 58px;
padding: $sp-1;
border-radius: $r-xs;
text-align: right;
font-variant-numeric: tabular-nums;
// Real click path: paint once (transition: none), then this transition cools it
background-color: rgba($stage-yellow, 0);
transition: background-color $dur-enter $ease-out;
&.is-hot {
background-color: rgba($stage-yellow, .62);
transition: none;
}
}
02Line item that slides down into the saved-for-later group
Saving for later collapses the row's height while the same item settles into the saved group below, and both list counts change together. One row folds from grid-template-rows: 1fr to 0fr at the same moment its twin opens from 0fr to 1fr, so the card's overall height never moves, and the render logged 11.938% changed area at intensity 104.9. Use it for save-for-later lists or moving an item to a wishlist.
// One row = a cell that trades its height. The real click path folds via transition
.mv__row {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows $dur-base $easing;
}
.mv__row.is-out { grid-template-rows: 0fr; }
// The folding cell carries no padding — vertical padding survives 0fr.
// This inner wrapper reaches height 0 and clips the padded real row inside
.mv__box {
min-height: 0;
overflow: hidden;
}
03Discount line that wedges into the order summary
Apply a coupon code, and a discount row unfolds from zero height between the item total and the grand total, pushing the rows below it down. The result sentence is announced from an aria-live region, the grand total recalculates in the same pass, and the render logged 15.799% changed area at intensity 110.8. Use it on any order summary with a coupon or promo code field.
.cp__disc {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows $dur-base $easing;
}
.cp.is-on .cp__disc { grid-template-rows: 1fr; }
function cpApply() {
cp.classList.remove('is-demo');
var code = cpCode.value.trim().toUpperCase();
var on = code === cpValidCode;
cp.classList.toggle('is-on', on);
cpTotalValue.textContent = cpMoney(cpAmount(cpItems.textContent) - (on ? cpAmount(cpOff.textContent) : 0));
cpMsg.textContent = on ? cpOkText : (code ? cpWrongText : cpEmptyText);
}
04Quantity that springs back when it passes the stock limit
Type more than the remaining stock, and the number bounces back to the maximum once while a warning strip drops over the row to say how many are left. The bounce is a translateY on a cubic-bezier overshoot, the strip carries role="alert" so screen readers hear the same message, and a reserved 24px slot above the row keeps the strip from covering the product name. The render logged 5.05% changed area at intensity 123.9, and the pattern fits limited-stock products or per-customer purchase caps.
// Bounce — it passes through its own seat once on the way back (overshoot easing)
@keyframes cs-bounce {
0%, 25% { transform: translateY(0) scale(1); }
29% { transform: translateY(-10px) scale(1.16); }
40%, 100% { transform: translateY(0) scale(1); }
}
05Payment method card that opens only for the option you picked
Pick one of the three payment method cards, and only that card's detail panel unfolds from zero height while the previously open one closes over the same duration. The radio's :checked does the opening and closing, aria-expanded tracks keyboard arrow moves in real time, and the panel expands inside the document flow so it never pokes out at phone width, logging 17.7% changed area over 8 of 23 sampled frames. Use it wherever a checkout chooses between card, wallet, and bank transfer.
// Detail panel — closed 0fr, open 1fr. No pixel guessing, so longer text still fits
.pm__fold {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows $duration $easing;
}
.pm__item:has(.pm__radio:checked) .pm__fold { grid-template-rows: 1fr; }
.pm__detail {
min-height: 0;
overflow: hidden;
}
06Shipping fields that fold away and fill themselves in
Turn on "same as orderer" and the shipping fieldset folds into a single summary line that already carries the orderer's name and address. JavaScript copies the values, the folded fields leave the focus order through aria-hidden="true" and inert so Tab never wanders through them, and the render logged 22.416% changed area at intensity 55.7. Use it on order forms that collect orderer and shipping details separately, or on billing address forms.
function syncShipping() {
sa.classList.remove('is-demo'); // a human touch ends the preview loop
var folded = same.checked;
fold.setAttribute('aria-hidden', folded ? 'true' : 'false');
fold.toggleAttribute('inert', folded); // folded cells are unreachable by Tab
if (folded) {
document.getElementById('saName').value = whoName.textContent.trim();
document.getElementById('saAddr').value = whoAddr.textContent.trim();
sum.textContent = whoName.textContent.trim() + ' · ' + whoAddr.textContent.trim();
}
}
07Arrival chip whose date rolls over when you change the shipping speed
Choose standard, express, or dawn delivery, and the date inside the arrival chip is pushed up and out while the new date rises into its place. The chip is an odometer — a strip pushed by translateY inside an overflow: hidden window, so no middle frame shows two overlapping dates — while an aria-live cell reads the new arrival date and fee as one sentence and the fee follows the picked speed, logging 9.291% changed area over 12 of 23 frames. Use it for per-speed arrival estimates or pickup time selection.
// The window shows one row only — the old row is pushed up, the next rises from below
.sp__win {
height: 16px;
overflow: hidden;
}
.sp__roll {
display: block;
transform: translateY(calc(var(--i, 0) * -33.3333%));
transition: transform $dur-base $easing;
}
08Agree-to-all switch that lights the items below in turn
Turning on "agree to all" draws the four check marks below it, one after another, and switching any single item off quietly clears only the master switch. Each check is an SVG polyline drawn by stroke-dashoffset: 1 → 0, the cascade is one line of transition-delay, and the in-between state of "some are on" lives on the switch's indeterminate property, with 1.8% changed area at intensity 122.3 over 19 of 23 frames. Use it for terms blocks before payment or consent groups at signup.
<label class="ta__mark" for="ta-1" aria-hidden="true">
<svg viewBox="0 0 24 24" aria-hidden="true">
<polyline class="ta__tick" points="5 12.5 10 17 19 7" pathLength="1"
fill="none" stroke="currentColor" stroke-width="3"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</label>
.ta__tick {
stroke-dashoffset: 1;
transition: stroke-dashoffset $dur-base $easing;
transition-delay: calc(var(--i, 0) * 60ms);
}
.ta__box:checked ~ .ta__mark .ta__tick { stroke-dashoffset: 0; }
09Bottom pay bar that steps aside once the summary is on screen
The total-and-pay bar pinned to the bottom drops away the moment the order summary enters the viewport, and climbs back when the summary leaves. An IntersectionObserver decides visibility, the bar sits outside the scroll box with position: absolute so it still stands at scroll zero, and the summary card fills the seat the bar left so the poster is never an empty screen, logging 25.205% changed area at intensity 85.2 over 16 of 23 frames. Use it for sticky pay buttons on mobile checkout or total bars on long order forms.
if (window.IntersectionObserver) {
var watcher = new IntersectionObserver(function (entries) {
for (var i = 0; i < entries.length; i++) {
co.classList.toggle('is-down', entries[i].isIntersecting);
}
}, { root: view, threshold: 0.45 });
watcher.observe(summary);
}
Where it breaks — the trap
The first thing to break was 0fr itself. Four items (02, 03, 05, 06) fold cells with grid-template-rows: 0fr ↔ 1fr, and the first attempt put vertical padding on the folding cell itself, which left an 8px sliver behind instead of closing it. 0fr zeroes the cell's content height but leaves its padding alone. The fix splits the structure in two: the outer cell carries only grid-template-rows and min-height: 0, while the padding lives on the inner row. The outer cell reaches height 0 and clips the padded row inside, so the cell closes completely and quietly.
The second problem was posters that showed the disappearing side. A poster picks the frame with the largest change from the one before it, and an exit usually changes more pixels than an entrance, so 04's warning strip leaving kept winning. Spreading the retreat over four slow frames while keeping the arrival a one-frame cut let the standing strip win again, and every loop returns within 89% of its two seconds. Items 02 and 03 unwind their return slowly across 70–86% for the same reason, and 09 was redesigned outright so the summary card fills the seat the bar vacates — otherwise the poster is an empty screen with no bar.
The third was overlapping numbers turning into translucent ghosts. Item 01 previews a 2→3 quantity change with two numbers stacked in one cell, and the first crossfade left both numbers half-readable on the poster. Every number and label swap now goes through a steps(1, end) cut, and because a cut alone leaves too few moving frames for the measurement, each one carries a small translateY lift alongside. Item 05 opens with a cut and lets the unfolded text rise once to fill frames.
The fourth only appeared after the preview loop was removed. Item 03 overlays the code text SAVE3000 on the input for the preview, and hiding it with display: none reshapes the input in the static path. The overlay keeps its seat with color: transparent, and item 04's "typed value" does the same. Item 06 dodged a different trap in advance: raising the summary line vertically would have the overflow: hidden cell clip it half-visible on the poster, so it slides in horizontally instead.
The last traps lived outside the stage rather than on it. Item 04's warning strip is absolutely positioned over the product row, and the first version covered the row so the poster lost the product name; reserving a 24px slot above the row and moving the background onto the row fixed it. Item 09's bar is also absolutely positioned over the scroll box — it has to stand at scroll zero, which rules out sticky, because a sticky bar never engages in the preview's zero-scroll state and appears in none of the 24 frames. The sources of all nine ship inside the zip with the exact values quoted here, and the password that opens it is be3rd37n.
Accessibility (reduced-motion)
What each of the nine turns off under prefers-reduced-motion: reduce differs item by item, but the underlying principle is shared: self-running motion stops, and the result of the shopper's action — numbers, states, sentences — stays. Item 01 stops the digit swap and tint but shows the current value; 02 stops the fold and shows both list counts; 03 stops with the discount row open; 04 stops the bounce and keeps the strip, the red outline, and the clamped maximum; 05 shows one card picked and expanded; 06 shows the folded shipping block and the one-line summary; 07 stops the roll and shows the current date; 08 stops the drawing and shows four items on; 09 stops with the bar down. Every declaration in these reduce blocks carries !important, because the preview-loop rules stack classes and would otherwise win on specificity. The reference is MDN's prefers-reduced-motion page, and the observer that item 09 uses is documented in MDN's Intersection Observer API guide.
What screen-reader users get varies by item.
| Item | What is actually attached | What it announces |
|---|---|---|
| 01 Quantity sync | aria-label on quantity buttons |
Which product's quantity rises or falls |
| 02 Save for later | aria-label on move buttons |
Which item moves to which list |
| 03 Coupon row | aria-live="polite" |
Applied, or unknown code |
| 04 Stock limit | role="alert" |
Remaining stock and the clamped maximum |
| 05 Payment method | aria-expanded + radio group |
Which method is expanded |
| 06 Same as orderer | aria-hidden + inert |
Folded fields leave the focus order |
| 07 Arrival chip | aria-live="polite" |
The new arrival date and fee |
| 08 Consent bundle | indeterminate |
How many items are on |
| 09 Pay bar | role="region" + focusable order list |
The order form region and the bar |
If the forms themselves aren't built yet, 9 form validation UI patterns is the input-side neighbor, and a multi-step order form is covered in 9 multi-step form wizard patterns. The broader input roundup sits in 9 input design ideas, and tabular order lists sit next to 9 data table interactions. How this site renders and measures demos before each article is written is described on the about page.
FAQ
Why fold the cart line instead of removing it?
Removing the row from the DOM makes everything below it jump at once, and the shopper loses track of what disappeared. Folding with grid-template-rows: 0fr shrinks the row's space smoothly so the eye follows the motion, and the same product appearing in the twin list explains the move as movement itself.
The discount row never appears when I apply the coupon. Why?
The cell the row lives in starts at grid-template-rows: 0fr, but without min-height: 0 on the inner element the cell opens to the content's height and stays there. Pair min-height: 0 with overflow: hidden on the inner element — as .pm__detail does here — and the cell travels between 0fr and 1fr both ways.
Do I need all nine on one checkout screen?
No, the nine are ingredients. The minimum set is 01 (quantity and total), 08 (consent), and 09 (pay bar); add 03 if you take coupons and 06 if shipping differs from the orderer. One screen carrying all nine gets busy, so spread them one or two per step of the checkout flow.