Skip to content
Twenty One Media
webSeptember 14, 2026

The Hero Stagger That Lives in CSS, Not JavaScript

The hero heading on the homepage staggers. "Less busywork." slides in first, then "More business." follows 70 milliseconds later. Two lines, two timings, one spring easing curve. Zero JavaScript.

Here is the complete HTML:

<h1>
  <span>Less busywork.</span>
  <em>More business.</em>
</h1>

And the CSS that makes it move:

@keyframes studio-rise {
  from { opacity: 0; transform: translateY(24px); }
  to   { opacity: 1; transform: translateY(0); }
}

.studio-hero h1 > span,
.studio-hero h1 > em {
  display: block;
  animation: studio-rise .7s var(--studio-spring) both;
}

.studio-hero h1 > em {
  animation-delay: 70ms;
}

That is the whole thing. No wrapper components. No useEffect. No initial / animate props.

Why this matters

The homepage (page.tsx) is a React Server Component. It has no "use client" directive. Every component on the page that needs interactivity is imported from a client component file. The heading animation is not one of them.

We built a LineReveal component earlier in the same codebase. It wraps each line in an overflow: hidden box and uses Framer Motion to animate each line from y: "110%" to y: 0, producing a masked reveal where text emerges from beneath a clip edge. The technique works, but it requires a client component and adds Framer to the render path for that element.

The CSS approach sidesteps all of that. The browser fires the animation on its own as soon as the element paints. No hydration required. The heading is visible and animated even if JavaScript never loads.

The stagger mechanism

A stagger in Framer Motion usually means staggerChildren on a parent with variant orchestration. Here the stagger is one line of CSS: animation-delay: 70ms on the second element.

The em tag was already there. It carries semantic emphasis, triggers the Fraunces italic in another CSS rule, and gets its color token. Adding animation-delay costs nothing because the element already exists. The same selector block that sets animation on both span and em is then overridden for just the em to add the delay.

both prevents the flash

animation-fill-mode: both means the element holds the from state before the animation fires and holds the to state after it finishes. Without it, the heading renders at full opacity for a frame before the animation starts, then jumps to invisible and rises in. With it, the element is invisible from the start.

The shorthand animation: studio-rise .7s var(--studio-spring) both includes both as the fill mode. It is easy to drop that token by accident when editing; dropping it brings the flash back.

The spring

--studio-spring is a linear() function approximating a spring curve, stored as a CSS custom property:

--studio-spring: linear(0, .104, .348, .62, .832, .958, 1.012, 1.024, 1.016, 1.006, 1);

CSS linear() lets you define a piecewise linear easing by listing output values at evenly spaced input steps. This one overshoots 1.024 and settles back, which reads as a physical spring without any JavaScript physics. Storing it as a token means every animation on the page shares one curve and changing it in one place updates everything.

The tradeoff

The clip-box approach in LineReveal looks more polished at large type sizes: text emerges from behind an edge rather than fading and rising in open space. If the heading were 120px editorial display type, the clip reveal would be worth the client component. At the sizes here, opacity + translateY reads cleanly and the Server Component keeps the initial HTML simpler.