Aduok Code

FIFA Bouncing Ball Title Animation with Pure HTML & CSS

Introduction

In this article we build the FIFA bouncing ball title animation — a large display wordmark where a soccer ball continuously bounces off the dot of the letter I. The I squashes and springs back on every impact, and the ball spins as it travels. The whole effect runs as two independent CSS animations on one element: a translate-based bounce looping on alternate, and a linear rotate looping forward. No JavaScript, no canvas, no SVG paths — just CSS keyframes, a relative-positioned wrapper, and a single emoji character rendered with Noto Emoji for crisp cross-platform display.

The effect relies on three CSS mechanisms working in concert: CSS individual transform properties (translate and rotate) so the bounce and spin keyframes never conflict with each other — absolute positioning with bottom: 100% so the ball always anchors to the exact top edge of the I regardless of font size — and a synced scaleY squish animation on the letter I that compresses on impact and stretches on release, giving the collision physical weight.

What you will learn

How to use the standalone translate and rotate CSS properties so two animations on the same element never interfere — how bottom: 100% plus translate: -50% anchors an absolutely positioned child to its parent's top center — how to sync a scaleY squish on a sibling element to match the bounce timing — how Noto Emoji renders a consistent soccer ball across Windows, macOS, Android, and iOS — and how alternate animation-direction turns a one-way keyframe into a seamless back-and-forth loop.

01
Part One
Design Tokens

Step 1 — CSS Custom Properties

All timing and sizing values live as custom properties on :root. The two most important are --ball-size, which controls the emoji container dimensions, and --bounce-duration, which is shared by both the ball bounce and the letter I squish so they stay perfectly in sync. Changing --bounce-duration from 0.35s to 0.5s slows both together — no hunting through separate animation declarations.

CSS
/* ── FIFA title tokens ─────────────────────── */
:root {
  --ball-size:        50px;
  --ball-font-size:   calc(var(--ball-size) * 0.8);
  --ball-gap:         25px;   /* space between ball rest and top of I */

  --bounce-duration:  0.35s;
  --bounce-easing:    ease-out;

  --spin-duration:    4s;

  --font-size:        100px;
  --letter-spacing:   8px;
}
02
Part Two
HTML Structure

Step 2 — The Markup

The wordmark is a flex container with three children: the letter F, an i-wrap span, and the letters FA. The i-wrap span is position: relative and display: inline-block — this is the positioning context for the absolutely placed ball. Inside i-wrap sit two siblings: the ball span (absolutely positioned, renders above the I) and the letter-i span (in normal flow, is the I itself). Source order places the ball before the letter in the DOM but above it visually via position: absolute.

HTML
<div class="wordmark" aria-label="FIFA">
  <span>F</span>

  <span class="i-wrap">
    <!-- ball is absolute, sits above the I -->
    <span class="ball" role="img" aria-label="soccer ball">⚽</span>
    <!-- letter I is in normal flow -->
    <span class="letter-i">I</span>
  </span>

  <span>FA</span>
</div>
ElementRolePosition
.wordmarkFlex container — aligns F, i-wrap, FA to flex-end (baseline)static
.i-wrapPositioning context for the ball — shrink-wraps the I glyphrelative
.ballEmoji soccer ball — anchored to top of i-wrap, bounces upwardabsolute
.letter-iThe letter I — squashes on ball impact, stretches on releasestatic (in flow)
03
Part Three
Font Setup

Step 3 — Choosing Fonts

The wordmark uses Bebas Neue from Google Fonts — a condensed all-caps display face with tight natural letter spacing that makes FIFA read as a solid typographic block. The ball emoji uses Noto Emoji, also from Google Fonts, loaded separately. Without an explicit emoji font, browsers fall back to the OS emoji set, which renders inconsistently: the soccer ball looks correct on Android but flat on Windows and slightly too large on macOS. Loading Noto Emoji explicitly gives every platform the same crisp, correctly sized glyph.

HTML
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Noto+Emoji:wght@400&display=swap" rel="stylesheet" />
Why Noto Emoji?

CSS font-family declarations fall through to the next family in the stack for any codepoint the current font does not cover. Emoji codepoints are not covered by Bebas Neue, so the browser falls back to the OS emoji font. Noto Emoji is designed to sit in that fallback slot explicitly — it covers all emoji codepoints with consistent rendering across platforms, and its weight axis (300–700) lets you match the visual weight of the emoji to the surrounding typeface.

04
Part Four
Ball Positioning

Step 4 — Anchoring the Ball to the Top of the I

The ball is positioned with bottom: 100% and left: 50%. bottom: 100% places the bottom edge of the ball flush with the top edge of i-wrap, which is the top of the letter I. left: 50% aligns the ball's left edge to the horizontal center of i-wrap. The translate property in the bounce keyframe then shifts it left by 50% of its own width (translate: -50% 0 at rest) to truly center it. margin-bottom: 25px adds a fixed gap so the ball hovers just above the letter cap height at rest rather than sitting flush against it.

CSS
.ball {
  position:      absolute;
  bottom:        100%;          /* bottom edge = top of .i-wrap */
  left:          50%;           /* left edge = horizontal center of .i-wrap */
  margin-bottom: var(--ball-gap); /* 25px gap above the I at rest */

  width:         var(--ball-size);      /* 50px */
  height:        var(--ball-size);      /* 50px */
  font-size:     var(--ball-font-size); /* 40px */
  font-family:   'Noto Emoji', sans-serif;

  display:       flex;
  justify-content: center;
  align-items:   center;
}

bottom: 100% is the key insight. It means "place my bottom edge at my parent's top edge" — and because i-wrap shrink-wraps the letter I exactly, that top edge is always the cap height of the I, at any font size. The ball never needs to know the font size; it just follows the parent.

05
Part Five
The Bounce Animation

Step 5 — Building the Bounce with translate and alternate

The bounce keyframe uses the standalone translate CSS property — not transform: translate(). This matters because the spin animation also runs on the same element using the standalone rotate property. If both used transform, the second animation's transform value would override the first. Using the individual properties (translate, rotate, scale) lets multiple independent animations target the same element simultaneously without conflict.

CSS
@keyframes ball-bounce {
  from { translate: -50% 100%; }  /* ball at rest — resting on I (shifted down by 100%) */
  to   { translate: -50% 0;    }  /* ball at peak — back to bottom:100% anchor point */
}

.ball {
  animation:
    ball-bounce var(--bounce-duration) var(--bounce-easing) both infinite alternate,
    ball-rotate var(--spin-duration)   linear               both infinite;
}
How alternate turns one keyframe into a loop

animation-direction: alternate means the animation plays from→to on odd iterations and to→from on even iterations. The bounce keyframe goes from translate(-50% 100%) — ball resting on I — to translate(-50% 0) — ball at its highest point. On the return iteration, alternate reverses it automatically: ball falls back down from peak to rest. A single two-keyframe @keyframes block produces an infinite up-down loop without any need for a midpoint keyframe.

06
Part Six
The Spin Animation

Step 6 — Spinning the Ball Independently

The spin is a separate linear animation using the standalone rotate property. Because rotate is independent of translate, the spin continues at a constant 4-second pace even as the bounce easing accelerates and decelerates the vertical position. This gives the ball a more physical quality — a real ball keeps spinning at roughly its own rate regardless of how fast it is moving vertically.

CSS
@keyframes ball-rotate {
  from { rotate: 0deg;   }
  to   { rotate: 360deg; }
}

/* The spin runs at 4s linear — independent of the 0.35s bounce */
/* Both animations run simultaneously on .ball with no conflict  */
07
Part Seven
Letter I Squish

Step 7 — Syncing the Squish to the Impact

The letter I has its own animation — a scaleX/scaleY squish that runs at the same duration and timing function as the ball bounce. At frame 0% the letter is wide and short (scaleX 1.2, scaleY 0.75), simulating the moment of maximum compression when the ball lands. By 45% it has returned to normal scale. transform-origin: bottom center ensures the letter squashes and stretches from its baseline, not its vertical midpoint — so the text baseline stays fixed and the rest of the wordmark does not jump.

CSS
.letter-i {
  display:          inline-block;
  transform-origin: bottom center; /* squash from the baseline, not the midpoint */
  animation:        squish var(--bounce-duration) ease both infinite alternate;
}

@keyframes squish {
  0%   { transform: scaleX(1.2) scaleY(0.75); } /* compressed — ball just landed */
  45%  { transform: scaleX(1)   scaleY(1);    } /* restored — ball has left */
}

The squish animation uses the same --bounce-duration and alternate direction as the ball bounce. This means both animations are always in the same phase of their cycle — when the ball is at the bottom (resting on I), the letter is maximally compressed, and when the ball is at the top, the letter is at full height. Sharing the token is what keeps them visually locked together without any JavaScript synchronisation.

Tuning Reference

Token / PropertyDefaultEffect
--ball-size50pxWidth and height of the ball container — increase for a larger emoji
--ball-gap25pxMargin between ball resting position and top of the I — increase for more air under the ball at rest
--bounce-duration0.35sSpeed of one up or down stroke — shared by ball bounce and letter squish, change both together
--bounce-easingease-outEasing of the bounce — try cubic-bezier(0.33, 0, 0.66, 0) for a sharper deceleration at the top
--spin-duration4sTime for one full 360° rotation — reduce for a faster spin, increase for a lazy roll
translate: -50% 100% → -50% 0100% travelThe vertical travel of the ball — 100% means it travels exactly one ball-height upward from rest
scaleX(1.2) scaleY(0.75)at 0%Maximum compression of the I — increase scaleX or decrease scaleY for a more exaggerated squash
transform-origin: bottom centerbaselineAnchor point for the squish — changing to center makes the letter grow in both directions

Full Source Code

Save the following as fifa-title.html and open in any modern browser. The animation works in all browsers that support CSS individual transform properties (translate, rotate) — Chrome 104+, Firefox 103+, Safari 14.1+. No build step, no dependencies, no JavaScript.

HTML — fifa-title.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/fifa-title.html

One flex container, one relative wrapper, one absolute emoji, one in-flow letter. The entire FIFA bouncing ball effect — spinning ball, elastic bounce, synced squish — is driven by three @keyframes blocks, two animation declarations, and roughly 50 lines of CSS. Zero JavaScript event listeners. Both animations run natively on the GPU via the compositor thread.

Want to see it in action?Watch the full build on YouTube