# Animated Pricing Cards in Tailwind Without Framer Motion

> Most pricing card motion needs no library. Use @property to animate a conic gradient border on the featured plan, transform and shadow transitions for hover, a CSS keyframe with a per-card delay for the reveal (started by one IntersectionObserver or by animation-timeline: view() where supported), and stroke-dashoffset for the checkmarks. Reach for Motion when cards change layout or need exit animations.

- URL: https://qodesign.dev/blog/animated-pricing-cards-tailwind-without-framer-motion
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-22
- Updated: 2026-10-22
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

When Cursor or v0 builds a pricing section and you ask for "some animation", it often adds Framer Motion (now called Motion) and wraps every card in `motion.div` with `whileHover` and `whileInView`. That works, but it ships the library to a page that is mostly static, and every card becomes a client component. I make animated React components at qodesign, and for pricing cards I reach for plain CSS first.

Here are the four motions most pricing sections want, each in Tailwind v4 and a few lines of CSS.

![Pricing Switch from qodesign: plan prices roll like an odometer when billing changes](/previews/pricing-switch.webp)

That is the [Pricing Switch](/components/pricing-switch), a free piece on qodesign with an odometer roll on the numbers. The cards below are a lighter base you can build on.

## The card markup

Prices come from your data, so the component never hard-codes them:

```tsx
type Plan = {
  id: string;
  name: string;
  price: string; // already formatted, e.g. from Intl.NumberFormat
  period: string;
  features: string[];
  featured?: boolean;
};

export function PricingCards({ plans }: { plans: Plan[] }) {
  return (
    <div className="grid gap-6 md:grid-cols-3">
      {plans.map((plan, i) => (
        <article
          key={plan.id}
          style={{ "--i": i } as React.CSSProperties}
          className={`pricing-card reveal group relative rounded-2xl p-px ${
            plan.featured ? "featured" : "bg-zinc-800"
          }`}
        >
          <div className="h-full rounded-[15px] bg-zinc-950 p-6">
            <h3 className="text-sm font-medium text-zinc-400">{plan.name}</h3>
            <p className="mt-3 text-4xl font-semibold tabular-nums text-white">
              {plan.price}
              <span className="ml-1 text-sm font-normal text-zinc-500">/{plan.period}</span>
            </p>
            <ul className="mt-6 space-y-2 text-sm text-zinc-300">
              {plan.features.map((f) => (
                <li key={f} className="flex items-center gap-2">
                  <svg viewBox="0 0 16 16" className="size-4 shrink-0 text-emerald-400" aria-hidden="true">
                    <path className="check" d="M3 8.5l3 3 7-7" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
                  </svg>
                  {f}
                </li>
              ))}
            </ul>
          </div>
        </article>
      ))}
    </div>
  );
}
```

The outer `article` has `p-px` and the inner `div` has the card background. That 1px gap is where the border shows, which lets the border be a gradient.

## 1. A rotating gradient border on the featured plan

`@property` registers `--angle` as an angle, so the browser can interpolate it. Without the registration, a custom property jumps from start to end with no frames in between.

```css
@property --angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

.pricing-card.featured {
  background: conic-gradient(from var(--angle), #3f3f46 0%, #a3e635 12%, #3f3f46 28%, #3f3f46 100%);
  animation: spin-border 4s linear infinite;
}

@keyframes spin-border {
  to { --angle: 360deg; }
}
```

A thin bright arc travels around the featured card and the rest of the border stays grey. Keep the arc short; a full rainbow border pulls attention from the price. `@property` works in current Chrome, Edge, Safari and Firefox, and older browsers just show a still gradient.

## 2. Hover lift without layout shift

```css
.pricing-card {
  transition:
    translate 250ms cubic-bezier(0.2, 0.8, 0.2, 1),
    box-shadow 250ms ease;
}

@media (hover: hover) {
  .pricing-card:hover {
    translate: 0 -4px;
    box-shadow: 0 20px 40px -20px rgb(163 230 53 / 0.25);
  }
}
```

Three choices matter here. The `translate` property moves the card without pushing its neighbours, so the grid stays still. It is separate from `transform`, which the reveal animation in the next step holds with `both`; if the hover used `transform` too, the finished animation would win and the lift would never show. And `@media (hover: hover)` keeps the lift off touch screens, where a tap would leave the card stuck in the raised state.

## 3. Reveal the cards in order when they scroll into view

The cards already carry `--i`. One keyframe uses it for the delay:

```css
.reveal {
  opacity: 0;
  transform: translateY(16px);
}
.reveal.is-visible {
  animation: reveal 500ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
  animation-delay: calc(var(--i) * 80ms);
}
@keyframes reveal {
  to { opacity: 1; transform: translateY(0); }
}
```

One small client component adds the class. It watches the grid once, so the rest of the section can stay a server component:

```tsx
"use client";

import { useEffect, useRef } from "react";

export function RevealOnView({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting) return;
        el.querySelectorAll(".reveal").forEach((c) => c.classList.add("is-visible"));
        io.disconnect();
      },
      { threshold: 0.2 },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);

  return <div ref={ref}>{children}</div>;
}
```

Wrap the grid: `<RevealOnView><PricingCards plans={plans} /></RevealOnView>`.

There is also a CSS-only route. `animation-timeline: view()` ties an animation to the element's position in the viewport, with no JavaScript. Browser support is still uneven, so put it behind `@supports (animation-timeline: view())` and keep the observer as the fallback.

## 4. Draw the checkmarks

Each check path is about 14 units long. Hide it with a dash offset and draw it once the card is visible:

```css
.check {
  stroke-dasharray: 16;
  stroke-dashoffset: 16;
}
.is-visible .check {
  transition: stroke-dashoffset 400ms ease-out;
  transition-delay: calc(var(--i) * 80ms + 250ms);
  stroke-dashoffset: 0;
}
```

The checks finish just after each card lands. It is a small detail, and the list reads like it was just ticked off.

## Reduced motion

One block turns all of it off:

```css
@media (prefers-reduced-motion: reduce) {
  .pricing-card.featured { animation: none; }
  .reveal { opacity: 1; transform: none; }
  .reveal.is-visible { animation: none; }
  .check { stroke-dashoffset: 0; }
}
```

The content is fully visible without any motion. That also covers a failure case: if JavaScript fails to load, cards hidden by `.reveal` would stay invisible. If that worries you, add the `reveal` class from the client component instead of the markup.

## When Framer Motion is still the right call

CSS covers entrance, hover and loops. Use Motion when the cards themselves change:

- Switching monthly and yearly reorders plans or changes card heights, and you want the layout change to animate (`layout` and `layoutId`).
- A plan card should animate out when removed, which CSS can't do without keeping the element mounted (`AnimatePresence`).
- You want gestures, such as dragging a slider to pick seats, with spring physics.

Even then, keep Motion inside the one part that moves and leave the rest of the page static.

## Related

The billing switch above the cards has its own rules: radio inputs, no price jump, honest savings. The [monthly/yearly pricing toggle guide](/blog/monthly-yearly-pricing-toggle-react-tailwind) covers it. If you want the full section ready to paste, the [Pricing Switch](/components/pricing-switch) is free in the [free kit](/free), with a PROMPT.md for Cursor, v0 and Lovable. After the plan is picked, the [Receipt Morph Checkout](/components/checkout-card) turns the card into a paper receipt. For the trade-offs in general, see [Framer Motion vs CSS for micro-interactions](/blog/framer-motion-vs-css-micro-interactions).

## FAQ

### Can I animate pricing cards without Framer Motion?

Yes. Hover lift, staggered reveal, gradient borders and drawn checkmarks all work with CSS transitions and keyframes in Tailwind. A small IntersectionObserver starts the reveal, and the rest of the section can stay a server component.

### How do I make an animated gradient border in Tailwind?

Register a custom property with @property as an angle, use it in a conic-gradient on a wrapper with 1px padding, and animate the property from 0deg to 360deg with a keyframe. The inner element keeps the solid card background.

### How do I stagger cards with CSS only?

Set a custom property with each card's index, such as --i, and use calc(var(--i) * 80ms) as the animation-delay. Every card shares one keyframe and starts a little later than the one before.

### When should I use Framer Motion for a pricing section?

Use it when the layout itself changes, for example when cards reorder or resize between monthly and yearly, or when cards need exit animations. For entrance, hover and looping effects, CSS is lighter and enough.
