---
title: "Recreating Mobbin's Hero Logo Animation with GSAP"
description: "How I built the hero on my portfolio, inspired by mobbin.com: app icons inside a headline that open from a hairline, grow into the gap and crossfade in place. Covers em-based slot sizing, the var() width bug that made it jank, GSAP code and accessibility."
url: "https://iamsrikanth.com/blogs/gsap-mobbin-hero-logo-animation"
author: "Srikanth V"
published: "2026-10-16"
updated: "2026-10-24"
tags: "GSAP, Animation, React, CSS"
---

# Recreating Mobbin's Hero Logo Animation with GSAP

By Srikanth V · 2026-10-16 · 7 min read

> How I built the hero on my portfolio, inspired by mobbin.com: app icons inside a headline that open from a hairline, grow into the gap and crossfade in place. Covers em-based slot sizing, the var() width bug that made it jank, GSAP code and accessibility.

[Mobbin](https://mobbin.com)'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:

```css
.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:

```js
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:

```txt
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**:

```js
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:

```js
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:

```js
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](https://simpleicons.org).
