Skip to content
B Bryant AI
astro-rocket animation css ux performance

Animations in Astro Rocket — How the Premium Motion Design Works

A deep dive into every animation layer in Astro Rocket: the spring-curve hero entrance, staggered cascades, scroll-reveal, and how it all stays smooth without a JS animation library.

H

Hans Martens

3 min read

Motion is one of those things that is easy to get wrong and invisible when it is right. Too fast and the page feels cheap. Too slow and it feels heavy. The wrong easing curve and something technically correct still feels off — like a door that swings open and then stops dead instead of settling.

Astro Rocket ships a complete, layered animation system built entirely on CSS and a few dozen lines of JavaScript. No Framer Motion, no GSAP, no animation library of any kind. Every curve, every delay, every stagger is written by hand and tuned to feel like premium software.

This post walks through every layer of that system: what plays, when it plays, and exactly how it is built.

The spring curve

Every entrance animation in Astro Rocket uses one easing curve:

cubic-bezier(0.22, 1.6, 0.36, 1)

The second control point sits above 1.0 on the Y axis, which is what makes it a spring rather than a standard ease-out. The element overshoots its final position slightly, then snaps back — a behaviour that approximates Framer Motion’s spring(stiffness: 100, damping: 15) without any runtime dependency.

Standard ease-out stops at exactly its destination. The spring overshoots by a few pixels and settles. That extra movement is what makes an entrance feel like something arrived rather than something was placed.

The same curve drives both the hero entrance animations and the scroll-reveal transitions. The only difference is duration: hero elements take 1 second; scroll-reveal elements use 0.9 seconds for the transform and 0.8 seconds for the fade.

The opacity transition uses a different curve — cubic-bezier(0.16, 1, 0.3, 1) — which is a smooth ease-out without overshoot. Opacity should not spring: an element that overshoots to opacity: 1.1 is invisible (capped at 1), so the overshoot would be wasted motion. The spring is for the spatial movement only.

Hero entrance

When a page loads, the hero content does not snap into place. Each element — badge, title, description, action buttons — slides up from 40 px below and fades in, one after another.

@keyframes hero-slide-up {
  from {
    opacity: 0;
    transform: translateY(40px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

The stagger is handled by a single parent class:

.animate-hero-stagger > * {
  animation: hero-slide-up 1s cubic-bezier(0.22, 1.6, 0.36, 1) both;
}
.animate-hero-stagger > *:nth-child(1) { animation-delay: 0ms;   }
.animate-hero-stagger > *:nth-child(2) { animation-delay: 90ms;  }
.animate-hero-stagger > *:nth-child(3) { animation-delay: 180ms; }
.animate-hero-stagger > *:nth-child(4) { animation-delay: 270ms; }
.animate-hero-stagger > *:nth-child(5) { animation-delay: 360ms; }
.animate-hero-stagger > *:nth-child(n+6) { animation-delay: 450ms; }

Add animate-hero-stagger to any container and its direct children cascade in automatically. No JavaScript, no component wrappers, no props. The cascade reads naturally: badge, then title, then description, then buttons — each 90ms behind the last.

The both fill mode matters here. Without it, each element would be visible at its resting position between when the page renders and when its animation starts. With both, the element stays at opacity: 0, translateY(40px) until its delay fires, then plays in cleanly.

The hero screenshot or feature image that follows the content column uses .animate-hero-slide-up directly — the same keyframe, the same spring, no stagger needed since it enters as a single unit.

The header drop-in

The header plays its own entrance alongside the hero, but from the opposite direction.

@keyframes header-drop-in {
  from {
    translate: -50% calc(-100% - 1.5rem);
    opacity: 0;
  }
  to {
    translate: -50% 0;
    opacity: 1;
  }
}

.animate-header-drop {
  animation: header-drop-in 0.8s cubic-bezier(0.22, 1.6, 0.36, 1) 150ms both;
}

As the hero content rises from below, the header lands from above. The 150ms delay means the hero starts moving first, and the header arrives just behind it — a counterpoint that reads as intentional choreography rather than two unrelated things animating at the same time.

The from state uses translate: -50% calc(-100% - 1.5rem). The -50% is needed because the floating header is horizontally centred with left: 50% — the hidden state mirrors this. The calc(-100% - 1.5rem) pushes it fully above the viewport, with the extra 1.5rem matching the gap between the header and the top of the page so there is no visible peek before the animation starts.

Scroll reveal

Everything below the hero that should animate into view uses the scroll-reveal system. It has three modes depending on what you need to reveal.

Single elements — [data-reveal]

Add data-reveal to any element and it starts invisible and transitions to visible when it crosses into the viewport.

<div data-reveal>
  This section fades and slides in as you scroll to it.
</div>

The default direction is up (the element starts 56px below its resting position). Three alternatives exist:

AttributeStarting position
data-revealtranslateY(56px) scale(0.94)
data-reveal="down"translateY(-56px) scale(0.94)
data-reveal="left"translateX(56px) scale(0.94)
data-reveal="right"translateX(-56px) scale(0.94)
data-reveal="scale"scale(0.88)

The slight scale (0.94 down to the fully visible 1.0) adds depth to the entrance — the element does not just slide in, it also grows slightly into place.

Delay can be stacked with data-reveal-delay:

<div data-reveal data-reveal-delay="1">First</div>
<div data-reveal data-reveal-delay="2">Second</div>
<div data-reveal data-reveal-delay="3">Third</div>

Delays are 100ms, 200ms, and 300ms respectively — useful for side-by-side elements where you want each one to read before the next appears.

Card grids — [data-reveal-children]

When you have a grid of cards, data-reveal-children cascades each direct child in sequence. The stagger and travel distance are tunable per grid:

<div data-reveal-children style="--reveal-stagger: 80ms; --reveal-distance: 40px">
  <div>Card 1</div>
  <div>Card 2</div>
  <div>Card 3</div>
</div>

The defaults are --reveal-stagger: 100ms and --reveal-distance: 48px. Override them inline or in a component’s scoped styles. The grid triggers as a whole when the container intersects — all children then cascade from child 1 to child 12 (capped at 12 steps; anything beyond uses the same delay as child 12).

Article bodies — [data-reveal-content]

Long-form content — blog posts, project pages — uses a third mode. Add data-reveal-content to the prose container and each direct child gets its own observer. As the reader scrolls, paragraphs, headings, code blocks, and images reveal themselves one at a time.

<div class="prose" data-reveal-content>
  <slot /> <!-- MDX content -->
</div>

The travel distance here is 40px (shorter than the 56px used for sections) and the duration is slightly faster — 0.7s for opacity, 0.9s for transform. Long-form content should feel light and progressive, not heavy. Shorter distances and faster fades keep the reading experience uninterrupted.

The JavaScript observer

All three modes are driven by a single IntersectionObserver registered in BaseLayout.astro:

const observer = new IntersectionObserver(function (entries) {
  entries.forEach(function (entry) {
    if (entry.isIntersecting) {
      entry.target.classList.add('is-visible');
      observer.unobserve(entry.target);
    }
  });
}, { threshold: 0.15, rootMargin: '0px 0px -10% 0px' });

The threshold: 0.15 means the element must be 15% visible before triggering. The negative rootMargin shrinks the detection zone slightly from the bottom, so elements trigger when the user has clearly scrolled them into view rather than at the first pixel.

Above-the-fold elements — those whose top is already in the viewport on page load — skip the observer entirely. Instead they fire sequentially via setTimeout, staggered 80ms apart, starting 250ms after the script runs. This gives the hero entrance animation time to complete before the fold content begins to reveal.

Elements that have already animated in (is-visible) are never re-observed. Once revealed, they stay revealed. The observer unobserves each target immediately after it triggers, keeping memory clean on long pages.

Scroll-based features

Two features update in real time as the user scrolls, both throttled with requestAnimationFrame:

Scroll progress bar — a 2px brand-coloured line that runs along the header edge (top or bottom, configurable) and fills from left to right as you scroll. Enabled on the homepage, the blog index, and individual posts.

Scroll progress ring — a circular SVG arc that draws clockwise around the back-to-top button as the page is scrolled. The arc radius and circumference are fixed (r="21", circumference ≈ 131.95), and the fill is driven by a single stroke-dashoffset value calculated from window.scrollY / scrollHeight.

Both use var(--color-brand-500) for their colour and update automatically whenever the visitor switches themes. The scroll listener is { passive: true } on both — it never blocks the main thread.

The typing effect

The hero typing effect on the About page cycles through words with a character-by-character animation. It is built as a setTimeout loop — one character added or removed per tick — with three non-obvious fixes:

  1. The element width is locked to the widest word before the first character types, using an off-screen measuring span that inherits the same computed font. This prevents layout shift as words of different lengths cycle through.
  2. The animation listens on astro:page-load so it restarts correctly after client-side navigation.
  3. overflow: hidden was removed because it clips descenders at large heading sizes.

Why no view transitions

Astro ships a ClientRouter that intercepts navigation and crossfades between pages without a full browser reload. Astro Rocket intentionally does not use it.

The reason is a compositing conflict: CSS keyframe animations that use fill-mode: both start their element in the animation’s initial state (opacity 0, translated 40px). When a page transition swaps the DOM mid-navigation, the hero elements are introduced already in that hidden state. The incoming animation fires correctly, but on mobile and some desktop configurations there was a visible frame or two where the old page had faded out and the new page’s hero had not yet started its entrance — a dark or blank flash that made the transition feel broken rather than seamless. A normal page load avoids this entirely: the browser renders the new page and the CSS starts from frame zero with no swap artifacts.

Reduced motion

Every animation respects prefers-reduced-motion:

@media (prefers-reduced-motion: reduce) {
  .animate-hero-slide-up,
  .animate-hero-stagger > *,
  .animate-header-drop {
    animation: none;
  }

  [data-reveal],
  [data-reveal-children] > *,
  [data-reveal-content] > * {
    opacity: 1;
    transform: none;
    transition: none;
  }
}

When the user has enabled reduced motion in their OS settings, all entrance animations are replaced with immediate renders. Elements appear at full opacity in their resting position. The scroll behaviour, theme switching, and interactive components continue to work — only the decorative motion is removed.

Why it works without a library

Three things make this system hold together at the quality level of a paid animation toolkit:

One spring curve used everywhere. cubic-bezier(0.22, 1.6, 0.36, 1) appears in the hero keyframes, the header drop-in, the scroll-reveal transitions, and the article-body reveals. The visual language is consistent because the timing function is consistent.

CSS does the work. The JavaScript only adds and removes the .is-visible class. All the visual behaviour — starting position, transition duration, easing, delay — lives in the stylesheet. This means the animations are composited on the GPU, never block the main thread, and cost nothing when not triggered.

Cascade precedence is explicit. The animation rules live outside every Tailwind @layer, making them unlayered CSS. Unlayered rules win over any @layer-scoped utility — so no Tailwind class can accidentally override a transition or animation property that the system depends on.

The full system is in src/styles/global.css and the observer script in src/layouts/BaseLayout.astro. See the Astro Rocket project page for the full picture of what ships in the starter.

Back to Blog
Share:

Related Posts

Hero Scroll Indicator — Desktop-Only, Hides on Scroll

Astro Rocket's hero has an animated scroll indicator: two bouncing chevrons that fade in after the hero animation and disappear the moment you start scrolling. Here's how every part of it works.

H Hans Martens
2 min read
astro-rocket features ux animation

Scroll Progress Ring — A Circular Indicator on the Back-to-Top Button

Astro Rocket's back-to-top button now has a circular SVG progress ring that fills as you scroll. It's brand-coloured, theme-aware, and runs entirely in CSS and a small inline script.

H Hans Martens
2 min read
astro-rocket features ux animation

Scroll Progress Bar — Reading Progress at a Glance

Astro Rocket now has a scroll progress bar: a thin brand-coloured line that fills as you scroll. Here's how it works, where it lives, and how to enable it on any page.

H Hans Martens
2 min read
astro-rocket features header ux

Follow along

Stay in the loop — new articles, thoughts, and updates.