Let's talk
WritingGSAP · Animation · React · CSS

Recreating Mobbin's Hero Logo Animation with GSAP

By Srikanth V7 min read

Mobbin's homepage has one of my favourite hero details on the web: little app icons sit inside the headline, between words, and every couple of seconds one of them changes. It looks simple. My portfolio's hero ("I craft ⚛ fast, accessible ▣ web apps ▲ people love") took me three attempts to make feel smooth. The first rolled icons around like a slot machine. The second copied Mobbin's timings and still felt harsh. The third is what's live now.

Here's how it's built, including the bug that made attempt two look broken.

1. The slot: a zero-height inline box sized in em

The trick that keeps the text from jumping is that the icon takes up no height. Each slot is an inline-block with height: 0, so the line height never changes. The icon hangs from the baseline, absolutely positioned. Every measurement is in em, so it scales with whatever font size the headline has:

.hero-slot {
  --icon: 0.85em;          /* icon size relative to the headline */
  --icon-drop: 0.075em;    /* how far it hangs below the baseline */
  --icon-gap: 0.2em;       /* breathing room each side */
  --slot-closed: 0.194em;  /* roughly one normal space */
  --slot-open: calc(var(--icon) + 2 * var(--icon-gap));
  position: relative;
  display: inline-block;
  width: var(--slot-open);
  height: 0;
}

.hero-slot-icon {
  position: absolute;
  left: 50%;
  bottom: calc(-1 * var(--icon-drop));
  width: var(--icon);
  height: var(--icon);
  margin-left: calc(var(--icon) / -2);
}

The CSS default is the open width. Without JavaScript, or for anyone who prefers reduced motion, the icons are simply there. The animation code is what closes the slots first.

I used margin-left instead of transform: translateX(-50%) for centring on purpose. GSAP animates transform on the same element, and a hand-written transform would be overwritten.

2. The bug: GSAP can't tween between two var() widths

My second attempt opened each slot like this:

gsap.set(slots, { width: 'var(--slot-closed)' })
gsap.to(slots, { width: 'var(--slot-open)', duration: 0.26, ease: 'expo.out' })

It looked fine in my head and terrible on screen. The icons popped in on top of the neighbouring words, and then the words jumped apart in a single frame. When I logged the slot's width on every animation frame, the problem was obvious:

1207ms  20.1px
1224ms  20.1px
1240ms  129.4px   ← the whole "animation"

GSAP can only interpolate values it can turn into numbers. var(--slot-closed) and var(--slot-open) are strings it can't resolve, so it had nothing to interpolate. The width sat at the closed value for the whole tween, then snapped to the end value when it finished. Meanwhile the icon's own pop animation was running normally, so for a quarter of a second the icon was full size inside a 20px gap, covering the next word.

Lesson: if an animation looks wrong, log the animated value on every frame with requestAnimationFrame before you start tuning eases. I'd have spent a day adjusting timings that were never being applied.

3. The fix: one progress value drives the gap and the icon

The fix was to stop running two animations that only hoped to stay in sync. Now a single tween runs a progress value from 0 to 1. On every frame it sets the slot's width in real pixels, and works out how big the icon can be in the space that exists right now:

const openSlot = (slot, icon, delay) => {
    const font = parseFloat(getComputedStyle(slot).fontSize)
    const closed = 0.194 * font   // em values from the CSS, resolved to px
    const open = 1.25 * font
    const size = 0.85 * font
    const margin = 0.32 * font    // breathing room either side while it grows
    const state = { p: 0 }
    const setIcon = gsap.quickSetter(icon, 'css')

    return gsap.to(state, {
        p: 1,
        duration: 1,
        ease: 'power2.inOut',
        delay,
        onUpdate: () => {
            const width = closed + (open - closed) * state.p
            slot.style.width = `${width}px`
            const fade = Math.min(1, Math.max(0, (state.p - 0.18) / 0.5))
            setIcon({
                scale: Math.max(0.001, Math.min(1, (width - margin) / size)),
                opacity: fade,
                filter: `blur(${(1 - fade) * 6}px)`,
            })
        },
        onComplete: () => { slot.style.width = '' },
    })
}

The details that make it smooth:

  • The icon can't outgrow its gap. Its scale is (width - margin) / size, so it's always a little smaller than the space being opened. It grows exactly as the words move apart and reaches full size just as the slot finishes.
  • power2.inOut instead of a sharp ease-out. Mobbin opens its slots in 260ms with an aggressive ease-out. At my headline size that moved the words about 100px in a few frames, which reads as a shove. Over one second with ease-in-out, the words glide apart.
  • A 140ms stagger between lines. The three gaps open one after another instead of all at once.
  • Blur and opacity trail the scale. The icon is still soft for the first part of its growth, so it seems to come into focus rather than appear.
  • gsap.quickSetter. It sets several properties per frame without creating a new tween each time.
  • onComplete clears the inline width. After the intro, the CSS em width takes over again, so the layout keeps responding to font size and window size.

The headline lines fade up from opacity: 0.15, blur(8px) and y: 0.25em over 0.9s with an 80ms stagger, and the slots start opening 0.55s in, while the text is still settling.

4. The swap: crossfade in place, one slot at a time

All of a slot's icons are stacked on top of each other, and a swap is a crossfade in the same spot. Nothing slides. The old icon softens away while the new one grows in just behind it:

const swap = (slot) => {
    const layers = slot.querySelectorAll('[data-layer]')
    const current = Number(slot.dataset.current || 0)
    const next = (current + 1) % layers.length
    slot.dataset.current = next
    gsap.killTweensOf([layers[current], layers[next]])
    gsap.to(layers[current], { opacity: 0, scale: 0.82, filter: 'blur(6px)', duration: 0.55, ease: 'power2.inOut' })
    gsap.fromTo(layers[next],
        { opacity: 0, scale: 0.82, filter: 'blur(6px)' },
        { opacity: 1, scale: 1, filter: 'blur(0px)', duration: 0.75, ease: 'power3.out', delay: 0.12 })
}

The 120ms delay on the incoming icon matters. Both icons are half transparent and blurred at the same moment, so you never see two sharp logos on top of each other.

Only one slot changes at a time. A single timer moves round-robin through the slots every 2.4 seconds, so your eye always has exactly one thing to follow:

const tick = () => {
    if (!paused && !document.hidden) swap(slots[turn++ % slots.length])
    timer = setTimeout(tick, 2400)
}

killTweensOf stops a half-finished swap from fighting a new one if someone clicks quickly. Clicking an icon swaps that slot early.

5. Small things that make it polished

  • Hover pauses it. If your pointer is on the headline, you're probably reading it, so the timer skips swaps until the pointer leaves.
  • It pauses in background tabs. document.hidden stops swaps from piling up while the tab isn't visible.
  • It starts on page:enter, not on mount. My site has a preloader and page transitions, so the hero waits for a custom event fired when the page is actually revealed. Otherwise the intro would play behind the curtain.
  • Server-rendered markup. The headline, slots and every icon layer are rendered on the server in a server component. Only the small HeroMotion client component adds behaviour. The text is in the HTML for search engines and screen readers from the first byte.
  • The icons are decoration. Each slot is aria-hidden, so a screen reader hears "I craft fast, accessible web apps people love", not a list of logo names.
  • Reduced motion means no motion. If prefers-reduced-motion is set, nothing closes, pops or swaps. The open slots from the CSS are what you get.

What I'd tell myself before starting

  • Measure before you tune. Logging one value per frame found in minutes a bug I'd been "fixing" with easing curves.
  • Copying timings is a starting point, not the answer. Mobbin's numbers are tuned for Mobbin's type size. On a bigger headline, the same 260ms looked harsh.
  • Tie related motion to one source of truth. Two animations that are supposed to match will drift apart. One progress value can't.

The hero on the home page runs this code. The icon tiles come from Simple Icons.

More writing