GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

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). Moving the same table with component state is laid out in CSS animations in React without a library, and if a photo strip belongs next to the plans, the CSS image reveal gallery is its natural pair.

01Plans reveal on scroll

The moment it fires

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.

Plans reveal on scroll
Open in a new tab

01 · Plans reveal on scroll — Measured area 37.606% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

IntersectionObservertransition-delayJS 7줄

The idea behind it

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.

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

Where this helps css pricing cards

A thin, bright band sweeps across the "Popular" badge every 2.4 seconds, on its own, without anyone hovering anything.

Badge shine sweep
Open in a new tab

02 · Badge shine sweep — Measured area 1.628% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

::aftertranslateX2.4s

Why it is done this way

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.

.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

Where this shows up

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.

Popular plan lift
Open in a new tab

03 · Popular plan lift — Measured area 5.008% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateYbox-shadow240ms

Worth noting

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.

.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

What happens on screen

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.

Monthly/yearly toggle
Open in a new tab

04 · Monthly/yearly toggle — Measured area 5.783% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

:checkedtranslateX50%

The key property

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.

.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

What it looks like

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.

Price digit flip
Open in a new tab

05 · Price digit flip — Measured area 9.398% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

rotateXbackface-visibilityperspective

What to read in the code

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.

.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

When to use it

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.

Price swap via :has(:checked), zero JS
Open in a new tab

06 · Price swap via :has(:checked), zero JS — Measured area 1.061% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

:has():checkedopacity

How it is built

There's still no JavaScript, and unlike the toggle, the number on screen actually changes rather than just which label lights up.

.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

What you see

Hover a plan card and its list of included features checks itself off top to bottom, one row every 120ms.

Feature checks stagger in
Open in a new tab

07 · Feature checks stagger in — Measured area 2.008% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

transition-delayopacitytranslateX

How it works

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.

.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

Where it fits

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.

Compare column highlight
Open in a new tab

08 · Compare column highlight — Measured area 5.153% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

scaleborder-color180ms

How to wire it up

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.

.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

What moves

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.

CTA glow
Open in a new tab

09 · CTA glow — Measured area 6.998% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

radial-gradientscale1.8s

The core of it

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.

.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?

What goes wrong

The first real bug here — one a pricing table tailwind css build would hit just the same — 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.

How to avoid it

The rule that a table stays readable only while its shadows hold their edge is pushed to its limit in the neobrutalism landing kit, where shadows work as borders.

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

What reduced motion leaves

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.

Related reading

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 a pricing table html css build?

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. Past the toggle — monthly and yearly math, a live checkout button — the pricing plan interactions are that bigger build.

Enter the archive password

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