Blog

Frontend

2 days ago

CSS animations without Framer Motion: what I learned

Framer Motion is great. I used it for a year on my personal site and it worked perfectly — smooth animations, declarative API, stagger effects with almost no code. But at some point I opened Lighthouse and saw that the library alone was adding ~40KB to the bundle. For a personal site with a few page transitions and entrance animations, that felt like too much.

So I decided to remove it entirely and rewrite everything with native CSS.

Bundle comparison between Framer Motion and native CSS

What I was replacing

The animations on the site were:

  • Page transitionAnimatePresence
    • motion.main with opacity and y on enter/exit
  • Section entrancemotion.section with initial/animate/exit and a configurable delay prop
  • Text animation — custom AnimateText component that split text into words and animated each one
  • Stagger listsmotion.ul with staggerChildren in variants
  • Scroll-linked parallaxuseScroll
    • useTransform on the greeting block

All of that had to go.

Page transitions

The hardest thing to replicate natively is page exit animations. Framer Motion gives you AnimatePresence which keeps the old page mounted until its exit animation finishes. CSS has no equivalent.

I made a pragmatic call: keep enter animations, drop exit animations. On a personal site nobody notices the exit. Here's what the enter looks like:

@keyframes pageEnter {
  from {
    opacity: 0;
    transform: translateY(12px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.main {
  animation: pageEnter 0.4s ease both;
}

To re-trigger the animation on route change I pass key={pathname} to the wrapper component. React remounts the element, the animation fires again.

Stagger without a parent component

Framer Motion's staggerChildren works by having a parent motion component that delays each child. In pure CSS you can do the same with :nth-child:

.item:nth-child(1) {
  animation-delay: 0.1s;
}
.item:nth-child(2) {
  animation-delay: 0.2s;
}
.item:nth-child(3) {
  animation-delay: 0.3s;
}

But that only works if you know how many items there are. When the list is dynamic I pass an index prop down and set animationDelay inline:

<div
  className={styles.item}
  style={{ animationDelay: `${0.1 + index * 0.1}s` }}
>

Not as elegant, but it works for any length list.

Two CSS patterns for staggered animations: nth-child and CSS variables

Text animation with CSS custom properties

The AnimateText component split text into <span> elements per word and animated each with a stagger. I kept the same idea but moved the delay into a CSS custom property:

words.map((word, i) => (
  <span
    key={i}
    className={styles.word}
    style={{ '--delay': `${i * 0.06}s` } as React.CSSProperties}
  >
    {word}
  </span>
));
.word {
  display: inline-block;
  animation: textSlideUp var(--duration, 0.5s) ease both;
  animation-delay: var(--delay, 0s);
}

CSS custom properties flow through the cascade so you can set them inline and read them in any nested selector. Very clean.

Scroll-linked parallax

This one I had to do in JavaScript anyway since CSS scroll timelines don't have broad enough browser support yet. I replaced useScroll + useTransform with a plain scroll listener:

useEffect(() => {
  const handleScroll = () => {
    const progress =
      window.scrollY / (document.body.scrollHeight - window.innerHeight);
    el.style.transform = `translateY(${progress * 200}px)`;
  };
  window.addEventListener('scroll', handleScroll, { passive: true });
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

The key detail: { passive: true }. Without it the browser can't optimize scrolling because it has to wait for your handler to potentially call preventDefault(). With it, scroll is butter smooth.

Results

The bundle went from ~280KB to ~215KB for the main page. Not revolutionary, but the site feels noticeably snappier on slow connections. More importantly — there are fewer moving parts. When something breaks I know exactly where to look.

Would I recommend this approach for a product with complex animations? No. Framer Motion earns its weight when you have shared layout animations, drag interactions, or physics-based spring effects. But for a portfolio site with simple entrances and transitions, native CSS is more than enough.

Share this post

Send it to someone who might find it useful.