GODRICH

Pricing Table Design: 9 Live Interactions

In pricing table design, the work happens on top of the cards, not inside them. All nine run on vanilla JS and SCSS, and the first is simply prices that really

Auto-plays · click a tile to jump to its section · all nine in one zip

Popularity did not set this order. It follows the path a visitor actually walks while settling on a number. You pick a billing period first (01), convert it into your own currency (02), then dial in how much you will use and watch the fee follow (03). Once the number is settled, the choosing begins: the recommended tier steps forward (04), the card you are looking at leans toward your hand (05), and the list of what is included fills in one line at a time (06). The last three sit right before the decision. You open the table and compare item by item (07), swipe through the cards on a narrow screen (08), and press the button and wait for an answer (09). One rule holds across all nine: the value lives in an attribute such as aria-checked, aria-selected, or a data-* pair, and the CSS reads nothing else. The moment JavaScript starts setting classes and inline styles directly, what the screen shows and what assistive tech reports begin to drift apart.

01Billing toggle that rolls the number

Flip the switch to annual and $29 a month becomes $23 a month, but the figure counts down through every number in between instead of being swapped out. An unregistered custom property cannot be interpolated, so --n is registered as an integer with @property and then drawn through a counter. Across the 24-frame render, 19 frames carry movement and the intensity is 110.4, second only to 03.

@propertycounter-resetaria-checked
@property --n {
  syntax: "<integer>";
  initial-value: 29;
  inherits: false;
}

.bt__num {
  --n: 29;
  counter-reset: n var(--n);
  font-size: 40px;
  line-height: 46px;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
}
.bt__num::after { content: counter(n); }

02Currency switch that changes symbol and amount

Choosing a currency from the list rewrites the trigger's code and symbol along with the amount on the left, all in one pass. The four amounts sit stacked in the same spot, and a single data-cur on the root decides which one shows. Focus returns to the trigger with preventScroll, because this component lives inside an iframe on the gallery page.

aria-expandedaria-selectedpadding-bottom
function pick(li) {
  root.classList.remove('is-demo');
  root.setAttribute('data-cur', li.getAttribute('data-cur'));
  opts.forEach(function (o) { o.setAttribute('aria-selected', String(o === li)); });
  code.textContent = li.getAttribute('data-code');
  tsym.textContent = li.getAttribute('data-sym');
  open(false);
  trig.focus({ preventScroll: true });
}

03Usage slider recalculates the price

Drag the handle and the seat count steps through 3, 5, 10, 20, and 50 while the fee recomputes at eight dollars a seat. What you see is a number, but what reaches a screen reader is the sentence in aria-valuetext, so it announces "5 seats" rather than a bare "5". The measured cumulative area is 5.301% over 21 moving frames, because the fill bar and the handle keep moving between the digit cuts and leave almost no still frame.

aria-valuetextscaleXtransform-origin
var SEATS = [3, 5, 10, 20, 50];
function apply(v) {
  var seats = SEATS[v - 1];
  root.style.setProperty('--p', String(v));
  seatn.textContent = String(seats);
  fee.textContent = '$' + (seats * 8);
  range.setAttribute('aria-valuetext', seats + ' ' + 'seats');
}
range.addEventListener('input', function () {
  root.classList.remove('is-demo');
  apply(parseInt(range.value, 10));
});

04Recommended plan grows, neighbors step back

Hover any card and it scales to 1.06 while the other two settle back to 0.94. The receding rule wins on specificity thanks to :has(), so the rule that brings a card forward has to carry the same :has() to beat it. The ribbon is absolutely positioned and pokes 16px above the card, so the root reserves room up top for that plus the lift and the scale.

:has()scalerotate
.pr__row:has(.pr__card:hover) .pr__card,
.pr__row:has(.pr__card:focus-within) .pr__card { transform: scale(.94); }
.pr__row:has(.pr__card:hover) .pr__card:hover,
.pr__row:has(.pr__card:focus-within) .pr__card:focus-within { transform: scale(1.06); }

.pr.is-demo .pr__card {
  animation-name: pr-lift;
  animation-duration: $dur-loop;
  animation-timing-function: $easing;
  animation-iteration-count: infinite;
  animation-fill-mode: backwards;
}
.pr.is-demo .pr__card[data-plan="starter"]  { animation-delay: 0ms; }
.pr.is-demo .pr__card[data-plan="pro"]      { animation-delay: 300ms; }
.pr.is-demo .pr__card[data-plan="business"] { animation-delay: 600ms; }

05Plan card that tilts toward the cursor

The pointer's position inside the card is read as a pair of values between 0 and 1, turned into two rotation angles, and reused as the center of the glare. The tilt is capped at nine degrees because anything steeper starts to look like a card seen from its edge. While the card follows the pointer there is no transition at all — it is attached only on the way out, so the card lies back down gently when the pointer leaves.

perspectiverotateYpointermove
var LIMIT = 9; // deg
function tilt(e) {
  root.classList.remove('is-demo');
  card.classList.remove('is-idle');
  var r = card.getBoundingClientRect();
  var x = (e.clientX - r.left) / r.width;
  var y = (e.clientY - r.top) / r.height;
  card.style.setProperty('--ry', ((x - 0.5) * 2 * LIMIT).toFixed(2) + 'deg');
  card.style.setProperty('--rx', ((0.5 - y) * 2 * LIMIT).toFixed(2) + 'deg');
  card.style.setProperty('--mx', (x * 100).toFixed(1) + '%');
  card.style.setProperty('--my', (y * 100).toFixed(1) + '%');
}
scene.addEventListener('pointermove', tilt);

06Included features light up row by row

Five rows switch on from the top, each 160 milliseconds behind the one above. Writing the animation: shorthand here would reset animation-delay to zero and fire all five at once, so only the longhand properties appear. During the delay the animation is not applied yet, so a finished row flashes on the very first frame; animation-fill-mode: backwards prevents that by extending the 0% keyframe back over the gap.

animation-delayanimation-fill-mode--i
.fc.is-demo .fc__row {
  animation-name: fc-line;
  animation-duration: $dur-loop;
  animation-timing-function: $easing;
  animation-iteration-count: infinite;
  animation-delay: calc(var(--i) * #{$stagger});
  animation-fill-mode: backwards;
}
@keyframes fc-line {
  0%   { opacity: .26; transform: translateX(-14px); }
  12%  { opacity: 1; transform: translateX(0); }
  74%  { opacity: 1; transform: translateX(0); }
  86%  { opacity: .26; transform: translateX(-14px); }
  100% { opacity: .26; transform: translateX(-14px); }
}

07Comparison table folds group by group

Press a group heading and the rows beneath it fold away without a single line of code measuring a height. The box inside each cell is a one-track grid, and that track is what interpolates from 1fr to 0fr. The markup stays a real table with <th scope="col"> and <th scope="row">, so the header relationships survive even while a group is closed.

grid-template-rowsscope="row"aria-controls
.ca__box {
  display: grid;
  grid-template-rows: 1fr;
  overflow: hidden;
  transition: grid-template-rows $duration $easing;
}
.ca__group[data-open="0"] .ca__box { grid-template-rows: 0fr; }
.ca__group[data-open="0"] .ca__caret { transform: rotate(-90deg); }

08Plan cards you swipe with a thumb

There are three cards and nothing else: no clone, no wrap-around. While you drag, the track drops its transition and only --dx is updated; on release the traveled distance is divided by one card's width plus the gap and rounded to land on the nearest card. The scrolling box and the track have to be separate elements, because that box is the overflow: hidden window doing the clipping: push the box and the window travels along with its contents, so no scrolling happens at all.

setPointerCapturetranslateXaria-current
.sw__view {
  overflow: hidden;
  width: 100%;
  border-radius: $r-card;
  touch-action: pan-y;
  cursor: grab;
}

.sw__track {
  --i: 0;
  --dx: 0px;
  --base: calc(-1 * var(--i) * (100% + #{$gapx}));
  display: flex;
  transform: translateX(calc(var(--base) + var(--dx)));
  transition: transform $duration $easing;
}
.sw__track.is-drag { transition: none; }

09Checkout button waits, then shows the result

Three faces sit stacked in the same place, and one data-state decides which one is visible. While it waits, the button is both aria-busy="true" and disabled, so a second press cannot land, and the hidden faces carry aria-hidden so the live region reads exactly one sentence. Loading runs for 1.2 seconds, the success mark holds for another 1.2, and then the button returns to its idle face.

aria-busysteps(1, end)aria-live
function state(name) {
  root.classList.remove('is-demo');
  root.setAttribute('data-state', name);
  btn.setAttribute('aria-busy', String(name === 'busy'));
  btn.disabled = name !== 'idle';
  hidden.forEach(function (el) {
    if (el.getAttribute('data-face') === name) { el.removeAttribute('aria-hidden'); } else { el.setAttribute('aria-hidden', 'true'); }
  });
}
btn.addEventListener('click', function () {
  timers.forEach(clearTimeout);
  timers = [];
  state('busy');
  timers.push(setTimeout(function () { state('done'); }, 1200));
  timers.push(setTimeout(function () { state('idle'); }, 2400));
});

Where does this break? A sliding value and a cut label drift apart

The longest fight in this set was not the code; it was the still image. The card image on this blog is picked as the single frame that differs most from the one before it, out of the 24 that two seconds are sliced into, and the first build of 03 walked straight into that rule. The handle glides between ticks as --p interpolates while the seat count and the fee are cut over with steps(1, end), so putting the cut at the arrival time leaves the digits trailing for all four sliding frames. The frame that got picked showed the handle almost at the fifth tick while the label still read 20 seats and $160. The fix was to move the cut off the arrival time and onto the midpoint between two ticks, which is where a stepped slider actually changes its value; the drift halved and the picked frame landed cleanly on the second tick at 5 seats and $40. The same problem showed up in the dots of 08, where the indicator switched six percent before the card arrived, producing a still of card one under a dot pointing at card two. Aligning the dot cuts with the arrival times of 36% and 64% was the whole fix. Item 06 had the opposite problem: clearing all five rows to transparent from the parent to start the next lap made that one frame overwhelmingly the biggest change, and the card image came out as a nearly empty panel. Leaving a dimmed row at 26% opacity and handing the switch-off back to each row's own delay leaves the measured figures at 3.764% cumulative area over 17 moving frames, with no single frame dominating the rest. All three are invisible in the source and only show up when you open the frames one by one, so nothing moved on until all nine still images had been reviewed. The archive password for these nine is whhp3vgx, and inside it the vanilla set and the React ports sit under matching folder names. The last one was 07: give a collapsing cell vertical padding and 12px of it survives even when the track reaches 0fr. Build the row height out of line-height alone and put min-height: 0 on the cell inside, and it closes all the way down.

Accessibility and what reduced-motion leaves behind

Every one of the nine drops its autoplay loop and decorative animation under prefers-reduced-motion: reduce while keeping the state values intact. The switch in 01 snaps into position instead of sliding, and aria-checked still flips; 07 opens and closes immediately with no grid-template-rows interpolation. Items 04 and 05 drop the transition outright, so the size and the angle still answer the pointer but jump there instead of easing. Everything that speaks the value survives regardless of motion: the price areas in 01 and 03 are aria-live="polite", the slider in 03 carries aria-valuetext, and the button in 09 reports through aria-busy alongside an aria-live note. The table in 07 keeps scope="col" on its column headers and scope="row" on its row headers, so the relationships hold even while a group is folded.

Text contrast was calculated directly from the WCAG relative-luminance formula. Semi-transparent card backgrounds were measured as the effective color after compositing against the stage.

Where Text Background Ratio
01 price figure, blue on white card #2f6df6 #ffffff 4.53:1
01, 03, 04, 07 body ink on white card #17141a #ffffff 18.24:1
02 amount, mint on ink card #4cd4a6 #252228 8.43:1
04 recommended card text, pure white on blue #ffffff #2f6df6 4.53:1
05 plan name, lilac on ink card #b9a0ff #2a272c 6.69:1
06 feature row, cream on darkened orange card #fff7e6 #b83716 5.48:1
08 price, sky on ink card #6ec8ff #27242a 8.29:1
09 success text, mint on ink button #4cd4a6 #17141a 9.79:1

Two of these missed the bar on the first pass and were changed. The cream text on the recommended card in 04 came out at 4.25:1 over that blue, so it went to pure white for 4.53:1, and the unit label on the same card dropped to 3.10:1 at 72% opacity until the opacity came off. In 06 the card over the orange stage was darkened by only 16% black and measured 4.25:1, so it went to 28% black and reached 5.48:1. A pricing table is a screen where people spend money, and giving up contrast there has an obvious cost. The neighboring CSS pricing table animations cover the same ground with CSS alone, and the pricing category collects the rest of them. The registration syntax follows the MDN @property reference.

Frequently asked questions

Can the price roll without @property?

Without registration the browser treats a custom property as a plain token, so there is no midpoint between 29 and 23 to animate through. Declaring syntax: "<integer>" through @property is what fills that gap. If registration is off the table, the only route left is writing the number yourself inside requestAnimationFrame, which means replacing a text node on every frame.

Why role="switch" instead of a checkbox for the billing toggle?

A billing period is not really an on/off value but one of two choices, so a radio group is a fair candidate too. When there are exactly two options and the screen shows a single pill, a switch produces the shorter announcement. Once a third option appears, move to a radio group instead.

How would I wrap around from the last card to the first?

Item 08 deliberately does not wrap. The usual trick is to append a clone of the first card and quietly swap positions at the seam, but at that moment a screen reader reads the same card twice and keyboard focus can end up trapped on the copy. With only three tiers in a pricing table, stopping at both ends costs less than that.

Enter the archive password

The password is inside this article. You will find it as you read.