# We dropped GSAP and got 31kB back for thirty lines of code

> By Lawrence Dauchy, Founder, DFYe. Published 2026-09-15. 5 min read. Engineering.
> Source: https://dfye.com/blog/we-dropped-gsap/
> Language: en

**TL;DR.** GSAP core plus SplitText costs 31kB gzipped. On this site it was buying exactly one effect: a staggered word reveal on the hero headline. That is about thirty lines of DOM and one CSS transition, so we wrote it and took the 31kB back. Homepage JavaScript went from roughly 46kB gzipped to 14.6kB.

We dropped GSAP from this site and got 31kB gzipped back. It was buying one effect, a
staggered word reveal on the hero headline, which turned out to be about thirty lines of
DOM work and a single CSS transition. Homepage JavaScript went from roughly 46kB to 14.6kB.

> Measured on 8 September 2026. The widget has grown since (the redesign added
> host isolation and a generated palette); the live figure is on the
> [features page](/features/). The argument below is about the 31kB we removed,
> which has not changed.

The DFYe homepage sells a chat widget on the grounds that it is 7.1kB gzipped and does not
slow your site down. It would be embarrassing to make that argument on a page carrying
31kB of animation library, so we measured what ours was actually costing.

## What the build said

```
dist/_astro/index.js       70.85 kB │ gzip: 27.88 kB   ← gsap core
dist/_astro/SplitText.js    7.34 kB │ gzip:  3.47 kB
dist/_astro/lenis.js       18.71 kB │ gzip:  5.45 kB
```

GSAP core plus SplitText: **31.35kB gzipped**. Lenis, the smooth-scroll library: 5.45kB.

The honest question is what each one was buying.

Lenis was buying smooth scroll, which is a genuinely hard thing to do well, is the single
biggest reason a site feels expensive, and would take far more than 5kB to reimplement
badly. It stayed.

GSAP was buying one effect: the words of the hero headline rising into place with a stagger.

## What that effect actually is

Walk the headline's DOM, wrap each word in a span with `overflow: hidden`, wrap the word
itself in a second span, push the inner span down by 108%, then release them all with a
staggered transition delay.

That is the whole thing:

```js
const wrapWords = (node) => {
  for (const child of Array.from(node.childNodes)) {
    if (child.nodeType === Node.TEXT_NODE) {
      const frag = document.createDocumentFragment();
      for (const part of (child.textContent ?? '').split(/(\s+)/)) {
        if (!part) continue;
        if (/^\s+$/.test(part)) { frag.appendChild(document.createTextNode(part)); continue; }
        const outer = document.createElement('span');
        outer.className = 'split-line';           // inline-block, overflow hidden
        const inner = document.createElement('span');
        inner.className = 'split-word';
        inner.textContent = part;
        outer.appendChild(inner);
        frag.appendChild(outer);
      }
      node.replaceChild(frag, child);
    } else if (child.nodeType === Node.ELEMENT_NODE && child.tagName !== 'BR') {
      wrapWords(child);                            // so <em> and <br> survive
    }
  }
};
```

Then set the initial state, and release on the second animation frame so the browser has
actually painted the start position before the transition begins:

```js
words.forEach((w, i) => {
  w.style.transform = 'translateY(108%)';
  w.style.opacity = '0';
  w.style.transition =
    `transform .9s cubic-bezier(.22,.7,.2,1) ${i * 28}ms, opacity .7s ease ${i * 28}ms`;
});
requestAnimationFrame(() =>
  requestAnimationFrame(() =>
    words.forEach((w) => { w.style.transform = ''; w.style.opacity = '1'; })));
```

## The two details that are easy to get wrong

**The wrapper must be `inline-block`, not `block`.** SplitText's own line wrapper is a block
element because it wraps a *line*. Ours wraps a *word*, and a block wrapper puts every word
on its own line. That bug is invisible in code review and obvious in a screenshot, which is
a decent argument for screenshotting your own work before you ship it.

**You need two animation frames, not one.** With one, the browser can batch the initial and
final styles into the same layout pass and the transition never runs. The element simply
appears in its final position.

## What it cost and what it saved

Thirty lines, one CSS rule, and about twenty minutes including the inline-block bug.

Homepage JavaScript before: **~46kB gzipped**. After: **~14.6kB gzipped**, of which 7.1kB is
the product itself and 5.45kB is Lenis.

## When should you keep GSAP instead?

GSAP is superb, it is free for commercial use since April 2025 including the plugins that
used to be paid, and if you are building timeline-driven scroll choreography you should use
it rather than hand-rolling a worse version.

The argument is narrower: we imported a 31kB dependency for one effect, and we only found
out because we looked at the build output. Most projects never look. If your bundle has a
library in it that is doing one job, it is worth ten minutes to find out how big that job
actually is.

## Quick answers

### How much does GSAP actually cost in bundle size?

On this build, 27.88kB gzipped for the core plus 3.47kB for SplitText, so 31.35kB in total.
That figure comes from the Vite build output rather than from a documentation page, which
matters: tree shaking and your own import pattern change it, so the number on someone else's
blog is not your number. Run your build, read the gzip column, and you have the real figure for
your site.

### Can you replace SplitText with plain JavaScript?

For a word-level reveal, yes, in about thirty lines. Walk the DOM, wrap each word in a clipping
span and an inner span, set a transform and a staggered transition delay, then release.
SplitText does far more than this, including line-level splitting that reflows correctly when
the container resizes, so the answer changes the moment you need lines rather than words, or
need the split to survive a font swap.

### Why does the word wrapper need to be inline-block?

Because it wraps a word, not a line. SplitText's own line wrapper is a block element, and
copying that puts every single word on its own line. The bug is invisible in code review, since
the CSS is correct in isolation and the JavaScript is correct in isolation, and it is
completely obvious in a screenshot. That is a decent argument for looking at your own work in a
browser before shipping it.

### Why do you need two animation frames instead of one?

With one, the browser can batch the initial and final styles into the same layout pass and the
transition never runs: the element simply appears in its final position. Waiting two frames
guarantees the start position is painted before the change to the end position is applied. It
is the same class of problem as reading offsetHeight to force a reflow, handled by waiting
rather than by measuring.

### Is this an argument against using GSAP?

No. GSAP is excellent and has been free for commercial use, including its former Club plugins,
since April 2025. The argument is narrower: we imported a 31kB dependency for one effect and
only found out because we read the build output. Most projects never look. If you are building
timeline-driven scroll choreography, reach for GSAP rather than hand-rolling a worse version of
it.

The same habit applied to a market instead of a bundle produced [the WordPress chat plugin
scan](/blog/wordpress-chat-plugin-market-2026/), and applied to the product category
itself it produced [what an AI chatbot for a website actually is](/blog/ai-chatbot-website/). The size budget this protects is
described on [the features page](/features/), and you can watch the result on
[the live demo](/demo/).

## Sources

- [GSAP standard licence, free for commercial use](https://gsap.com/community/standard-license/)
- [Webflow: GSAP becomes free, including the former Club plugins](https://webflow.com/blog/gsap-becomes-free)
- [MDN: using CSS transitions](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_transitions/Using_CSS_transitions)

---
*Published by [DFYe](https://dfye.com/). Free to read, index, quote and cite with attribution and a link.*
