GODRICH

9 CSS Pricing Table Animations You Can Copy-Paste

A pricing table is the Free/Pro/Team grid every subscription site shows before checkout, and a good CSS pricing table adds a little motion so people actually

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

These nine aren't ranked by flashiness — they follow the order a visitor actually moves through a pricing page. First come the cues that catch the eye before anyone touches anything (01–03), then the two ways a shopper actually changes what they're looking at, swapping monthly for yearly (04–06), then the two that reward a closer read of what's included (07–08), and last, the nudge toward the checkout button itself (09).

01Plans reveal on scroll

Scroll down to where a pricing grid sits on the page, and instead of all three cards appearing the instant the page loads, they rise into place one after another as the section comes into view. A real IntersectionObserver watches the grid itself and adds one class the moment it's about 30% on screen; every card shares that same rise, and only a transition-delay of 140ms then 280ms is what staggers them apart.

IntersectionObservertransition-delayJS 7줄
var grid = document.querySelector('.sr-grid');
var io = new IntersectionObserver(function (entries) {
  entries.forEach(function (e) {
    if (e.isIntersecting) { grid.classList.add('in'); io.unobserve(grid); }
  });
}, { threshold: 0.3 });
io.observe(grid);
.sr-card {
  opacity: 0;
  transform: translateY($sp-4);
  transition: opacity 400ms $easing, transform 400ms $easing;
}
.sr-grid.in .sr-card { opacity: 1; transform: none; }
.sr-grid.in .sr-card:nth-child(2) { transition-delay: 140ms; }
.sr-grid.in .sr-card:nth-child(3) { transition-delay: 280ms; }

02Badge shine sweep

A thin, bright band sweeps across the "Popular" badge every 2.4 seconds, on its own, without anyone hovering anything. Only the band itself moves — a translateX and a skewX on a ::after element clipped inside overflow: hidden — the gradient's colors never change, and the badge's own size never shifts.

::aftertranslateX2.4s
.sh-badge {
  position: relative;
  overflow: hidden;
}
.sh-badge::after {
  content: "";
  position: absolute;
  top: 0; left: 0;
  width: 40%; height: 100%;
  background: linear-gradient(100deg, transparent, rgba(255, 255, 255, .65), transparent);
  animation: sh-sweep 2.4s ease-in-out infinite;
}
@keyframes sh-sweep {
  0% { transform: translateX(-260%) skewX(-12deg); }
  60%, 100% { transform: translateX(360%) skewX(-12deg); }
}

03Popular plan lift

The plan already marked as the recommended one — bigger, darker, with its own badge — lifts a little further and its shadow deepens the moment a visitor's mouse rests on it. Only transform and box-shadow transition, so the neighboring cards hold completely still while this one rises. Of the nine animations measured for this post, this hover lift registered the single strongest motion — a dark card jumping against a bright shadow reads as more intense than the same shift on a pale one.

translateYbox-shadow240ms
.pl-card--popular {
  box-shadow: $shadow-press;
  transition: transform $duration $easing, box-shadow $duration $easing;
}
.pl-card--popular:hover {
  transform: translateY(-#{$sp-4});
  box-shadow: $shadow-raised;
}

04Monthly/yearly toggle

A pill-shaped background slides between the Monthly and Yearly labels the moment either one is tapped, showing at a glance which billing period is selected. Two radio inputs sit hidden underneath; a sibling selector reads which one is :checked and slides the indicator with nothing but transform: translateX(100%) — no JavaScript involved.

:checkedtranslateX50%
.mt-indicator {
  position: absolute;
  top: $sp-1;
  left: $sp-1;
  width: calc(50% - #{$sp-1});
  height: calc(100% - #{$sp-1} * 2);
  transition: transform $duration $easing;
}
#mt-yearly:checked ~ .mt-indicator { transform: translateX(100%); }

05Price digit flip

Hover the price and the whole card spins like a flashcard to reveal a different number printed on its back. A parent element sets perspective: 600px, the inner card rotates on rotateX, and both faces carry backface-visibility: hidden so only one side is ever showing — the back face starts pre-rotated 180deg so it lands right-side up once the flip finishes. Of the nine, this flip moves for the fewest captured frames — the whole rotation fits inside its 600ms duration, so most of a longer capture window is just the front or back face already holding still.

rotateXbackface-visibilityperspective
.pf-flip__inner {
  transform-style: preserve-3d;
  transition: transform $duration $easing;
}
.pf-flip:hover .pf-flip__inner { transform: rotateX(180deg); }
.pf-flip__face {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
}
.pf-flip__face--back { transform: rotateX(180deg); }

06Price swap via :has(), zero JS

This looks similar to the toggle above, but the trick is different: a :has() selector reads that same hidden radio's checked state from the card's outer wrapper, then swaps which of two absolutely-positioned price rows is visible using nothing but opacity and translateY. There's still no JavaScript, and unlike the toggle, the number on screen actually changes rather than just which label lights up.

:has():checkedopacity
.hc-price {
  position: absolute;
  inset: 0;
  opacity: 0;
  transform: translateY($sp-2);
  transition: opacity $duration $easing, transform $duration $easing;
}
.hc-price--monthly { opacity: 1; transform: none; }
.hc-card:has(.hc-radio--y:checked) .hc-price--monthly {
  opacity: 0;
  transform: translateY(-#{$sp-2});
}
.hc-card:has(.hc-radio--y:checked) .hc-price--yearly { opacity: 1; transform: none; }

07Feature checks stagger in

Hover a plan card and its list of included features checks itself off top to bottom, one row every 120ms. The checkmark itself never moves — it's a single element built from just a border-right and a border-bottom rotated 45deg into an L shape, deliberately one piece instead of two bars that could drift out of alignment — only the row around it slides in with opacity and translateX.

transition-delayopacitytranslateX
.fc-check {
  width: 6px;
  height: 10px;
  border-right: 2px solid $color;
  border-bottom: 2px solid $color;
  transform: rotate(45deg);
}
.fc-row {
  opacity: 0;
  transform: translateX(-#{$sp-3});
  transition: opacity $duration $easing, transform $duration $easing;
}
.fc-card:hover .fc-row { opacity: 1; transform: none; }
.fc-card:hover .fc-row:nth-child(2) { transition-delay: 120ms; }
.fc-card:hover .fc-row:nth-child(3) { transition-delay: 240ms; }

08Compare column highlight

In a side-by-side comparison table, only the column under the cursor brightens its border and grows slightly, so it's obvious which plan is currently being read. It's a plain :hover, not the :has() trick from item 06 — scale, border-color, and background all transition together, and the columns on either side never move.

scaleborder-color180ms
.ch-col {
  border: 2px solid transparent;
  transition: transform $duration $easing, border-color $duration $easing, background $duration $easing;
}
.ch-col:hover {
  transform: scale(1.05);
  border-color: $color;
  background: rgba(23, 20, 26, .08);
}

09CTA glow

Behind the checkout button, a soft blue glow swells and settles on a 1.8-second loop, entirely on its own — no hover required, the same as item 02's badge. Underneath, it's a ::before pseudo-element built from radial-gradient(), animating only transform: scale() and opacity, positioned just outside the button's edges with a negative inset.

radial-gradientscale1.8s
.cg-btn {
  position: relative;
}
.cg-btn::before {
  content: "";
  position: absolute;
  inset: -#{$sp-3};
  background: radial-gradient(circle, rgba(47, 109, 246, .9), transparent 65%);
  filter: blur(3px);
  animation: cg-pulse 1.8s $easing infinite;
}
@keyframes cg-pulse {
  0%, 100% { transform: scale(1); opacity: .2; }
  50% { transform: scale(1.45); opacity: .95; }
}

Where do these pricing table animations break?

The first real bug here showed up in item 09. The glow was first sent behind the button with z-index: -1 on its ::before, but .cg-btn only carries position: relative — it never declares a z-index of its own, so that rule alone never creates a new stacking context for the button. Without one, the -1 doesn't stay pinned just behind the button; it escapes upward to the nearest ancestor that does establish a stacking context, which turned out to be the demo stage's own background, and the glow sank below that instead — measured cumulative area 0% and intensity 0, meaning the frame comparison never caught a single visible pixel of it. Deleting that one z-index line was the entire fix: once .cg-btn::before had nothing pulling it out of the button's own layer, it settled back on top of the button's background where it belonged, and the same demo measured 7.17% cumulative area and an intensity of 19.9 on the next pass. Item 09 isn't the only one of these nine that moves without a hover, either — item 02's badge sweep also loops on its own, 2.4 seconds start to finish — but 09 is the one that shipped invisible the first time, purely because of where a single negative z-index landed with no stacking context to catch it. Item 06's :has() selector carries its own limit worth knowing before copying it: a browser that doesn't support :has() — an older Safari build, mostly — just skips that whole rule block and leaves the card showing whatever the monthly price row already had, not some broken or blank state, so the number on screen is never wrong; the toggle just quietly stops doing anything. The zip linked below needs a password to open, and it's sitting in this paragraph as an ordinary word rather than a code block: prqmpcmj. Type it exactly as it appears here and the files unlock.

Variants worth trying

Variant Changed value Feel
Faster reveal Item 01's transition-delay cut from 140ms/280ms to 70ms/140ms Cards arrive almost together instead of visibly one after another
Calmer glow Item 09's pulse range narrowed from scale(1 → 1.45) to scale(1 → 1.15) The CTA still breathes but stops pulling focus away from the price above it
Snappier flip Item 05's duration swapped from $dur-enter (600ms) to $dur-base (300ms) The flip lands almost instantly, though at 300ms it sits right at this site's minimum-motion measuring floor

Accessibility

All nine drop their motion under prefers-reduced-motion: reduce, but what's left behind differs by item. For six of them — the scroll reveal, the popular-plan lift, both billing switches, the checklist stagger, and the CTA glow — the end state alone still carries every bit of information: cards sit fully revealed, the popular plan keeps its static shadow, the correct price shows for whichever period is checked, every checkmark is already visible, and the glow just holds steady instead of pulsing. Two items genuinely lose something: item 05's flip is blocked from rotating at all under reduced motion, so the back-face price becomes impossible to reach without motion turned back on, and item 08 forces border-color to stay transparent even on :hover, so a reduced-motion visitor loses the highlight that shows which comparison column they're reading. Item 02's badge keeps its text fully readable with the sweep simply switched off — nothing there was ever load-bearing information.

The :has() trick behind item 06 is covered from the toggle side in 9 CSS Toggle Switches, and the wider set of :checked-driven patterns it belongs to gets its own full write-up in 9 CSS Checkbox Radio Animations. Selector support for :has() and for backface-visibility, the property item 05's flip depends on, are both documented on MDN if a target browser list needs checking before shipping any of this.

FAQ

Do I need JavaScript for an animated pricing table?

Only item 01 does — its scroll reveal runs on 7 lines of real IntersectionObserver code. The other eight, including the monthly/yearly billing switch in item 06, run on pure CSS: :hover, :checked, and :has() selectors, with zero JavaScript.

Which of these still work well on a touch screen, with no hover?

Items 02 and 09 loop on their own regardless of touch or hover. Items 04 and 06 respond to tapping a label rather than hovering, which makes them a safer pick for phones than 03, 07, or 08, which depend entirely on a mouse resting somewhere.

Is the React code in the zip any different from the plain CSS version?

The motion on screen is identical. The difference is under the hood: the React components skip the is-demo capture loop entirely and ship only the real :hover/:checked/:has() paths, and for items 04 and 06 — where each card's radio group needs a unique name — they take a name prop as a string prefix instead of React's useId, since this project's toolchain doesn't declare useId for its type-checked components.

Enter the archive password

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