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 drawer that flies open and then stops dead instead of gliding to a close.
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.
One easing curve, everywhere
Almost every entrance in Astro Rocket rides a single easing curve:
cubic-bezier(0.16, 1, 0.3, 1)
It is a clean, strong ease-out — not a spring. Both control points sit high on the Y axis, so the element starts fast and decelerates to a smooth, complete stop. There is no overshoot and no bounce. That restraint is deliberate: a hero heading that springs past its mark and snaps back draws attention to the animation; one that glides to a stop draws attention to the content. Premium motion is calm.
The same curve drives the hero entrance, the header drop-in, and the scroll-reveal transitions. Consistency in the timing function is what makes the whole page feel like one designed system rather than a pile of separate effects.
There is exactly one variant. Two-column split sections opt into a slightly softer transform curve with data-reveal-ease="smooth":
[data-reveal][data-reveal-ease="smooth"] {
transition:
opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1),
transform 1.1s cubic-bezier(0.22, 1, 0.36, 1);
}
Notice the two durations. The opacity settles in 0.8s, but the transform keeps travelling for 1.1s — so an element finishes fading in and then keeps gliding the last few pixels into place. That small overlap is the difference between “appeared” and “arrived.”
Hero entrance
When a page loads, the hero content does not snap into place. Each element — badge, title, description, action buttons — slides up from 80 px below and fades in, one after another.
@keyframes hero-slide-up {
from {
opacity: 0.01;
transform: translateY(80px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
The from opacity is 0.01, not 0. A truly invisible element can be skipped by the browser as a paint candidate, which can confuse Largest Contentful Paint timing; starting a hair above zero keeps every faded element on the books while staying invisible to the eye.
The stagger is handled by a single parent class:
.animate-hero-stagger > * {
animation: hero-slide-up 1s cubic-bezier(0.16, 1, 0.3, 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 wrappers, no props. Each child enters 90 ms behind the last: badge, then title, then description, then buttons. The both fill mode is what holds each element in its hidden start state until its delay fires, so nothing flashes at its resting position before the cascade reaches it.
The LCP title never fades
The hero heading is usually the Largest Contentful Paint element, and an element painted below full opacity can be deferred or skipped as an LCP candidate. So the title opts out of the fade and rides a transform-only keyframe instead:
@media (prefers-reduced-motion: no-preference) {
.animate-hero-stagger > .hero-title-slot {
animation-name: hero-rise;
}
}
@keyframes hero-rise {
from { transform: translateY(80px); }
to { transform: translateY(0); }
}
This overrides only the animation-name. The title keeps the stagger’s duration, delay, easing, and fill mode — it still slides up in lockstep with everything else — but it is painted at full opacity from the very first frame. Wrapping the override in prefers-reduced-motion: no-preference means reduced-motion users fall straight through to the “no animation” rule further down.
The header drop-in
The floating 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.16, 1, 0.3, 1) 150ms both;
}
As the hero content rises from below, the header lands from above. The 150ms delay lets the hero start moving first, so the header arrives just behind it — a counterpoint that reads as choreography rather than two unrelated things firing at once. The -50% X translate mirrors the header’s centred resting position (left: 50%), and calc(-100% - 1.5rem) parks it just above the viewport so there is no peek before it drops.
Scroll reveal
Everything below the hero that should animate into view uses the scroll-reveal system. The base is a single attribute:
[data-reveal] {
opacity: 0;
transform: translateY(96px);
transition:
opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1),
transform 1.1s cubic-bezier(0.16, 1, 0.3, 1);
will-change: opacity, transform;
}
[data-reveal].is-visible {
opacity: 1;
transform: none;
}
Add data-reveal to any element and it starts invisible, 96 px low, and transitions to its resting position when a .is-visible class is added (more on what adds it below). The default direction is up; four alternatives change only the starting transform:
| Attribute | Starting position |
|---|---|
data-reveal | translateY(96px) |
data-reveal="down" | translateY(-96px) |
data-reveal="left" | translateX(96px) |
data-reveal="right" | translateX(-96px) |
data-reveal="scale" | scale(0.88) |
Side-by-side elements can be offset from each other with data-reveal-delay:
<div data-reveal>First</div>
<div data-reveal data-reveal-delay="1">Second</div>
<div data-reveal data-reveal-delay="2">Third</div>
The delays are 100ms, 200ms, 300ms, and 400ms for 1 through 4 — useful when you want each element in a row to read a beat before the next.
One motion axis on small screens
Two-column split sections stack into a single column below the lg breakpoint. A horizontal reveal there would slide content sideways while the hero above it rises — two motion axes at once, which feels uncoordinated on a phone. So horizontal reveals collapse to the default vertical rise on small screens:
@media (max-width: 1023.98px) {
[data-reveal="left"],
[data-reveal="right"] {
transform: translateY(96px);
}
}
Desktop keeps its side-by-side left/right entrances; everything narrower shares one upward axis with the hero.
Card grids — [data-reveal-children]
When you have a grid of cards, data-reveal-children cascades each direct child in sequence as soon as the container intersects. 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: 80px. Children cascade from the first up to the twelfth; anything beyond the twelfth shares the twelfth’s delay so a very long grid never trails off into a multi-second wait.
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 every direct child gets its own observer, so paragraphs, headings, code blocks, and images each rise as the reader scrolls to them:
<div class="prose" data-reveal-content>
<slot /> <!-- MDX content -->
</div>
The travel here is shorter (56px) and the timing a touch quicker (0.7s opacity, 0.9s transform). Reading should feel light and progressive, not like wading through a heavy entrance on every paragraph.
Revealing on load — data-reveal-eager
Most reveals wait for the scroll. But on a listing page, a centred hero can push the first card below the fold on mobile — which would leave it sitting invisible until the reader scrolls, even though it is “the first thing.” data-reveal-eager fixes that: a flagged element reveals on load, 250 ms in, with the normal animation.
The subtle part is its row-mates. On a multi-column grid, the eager card’s neighbours would otherwise ride the scroll observers on a different clock, so a side-by-side pair would enter visibly out of sync. The script groups each eager element with its same-row [data-reveal] siblings — detected by a shared offsetTop — and reveals the whole row in one tick. On a stacked single-column layout nothing shares a row, so mobile keeps its clean top-to-bottom order untouched.
Anchor jumps — .reveal-instant
In-page anchor links are a special case: if you click a button that jumps to #development, you do not want that section sliding in mid-jump. The destination (and everything the jump scrolls past) is tagged .reveal-instant, which snaps those elements straight to their final state:
.reveal-instant[data-reveal],
.reveal-instant [data-reveal],
.reveal-instant[data-reveal-children] > *,
.reveal-instant [data-reveal-children] > * {
opacity: 1;
transform: none;
transition: none;
}
Natural scrolling is untouched and still animates — only the jumped-to content is pre-revealed.
The card cascade
Blog cards and project cards carry an explicit, index-based stagger, so the cascade is written into the markup rather than left to where each card happens to land on screen. The BlogCard component takes two props for it:
<BlogCard
title={post.data.title}
revealDelay={index % 3}
eager={index === 0}
/>
revealDelay maps the map index onto data-reveal-delay. Listing pages pass a per-row modulo — index % 3 on a three-column grid — so every row rises left to right; the homepage passes the index directly for its short featured row. eager marks the first card so it leads in on load. Project cards do the same inline with data-reveal-delay={i % 2} on their two-column grid. Either way the timing is baked into the markup, so a grid cascades cleanly the moment it scrolls into view instead of popping in card by card on the scroll observer’s clock.
The JavaScript observer
All of this is CSS. The only thing JavaScript does is decide when to add .is-visible. A single script in BaseLayout.astro wires it up with three IntersectionObservers working together.
First, a first-pass observer sorts elements into “already on screen” and “still below the fold” — using the rootBounds the observer hands back, so it never forces a synchronous layout read:
const firstPass = new IntersectionObserver(function (entries) {
entries.sort(/* document order */);
entries.forEach(function (entry) {
firstPass.unobserve(entry.target);
if (entry.isIntersecting) {
const i = revealIndex++;
setTimeout(function () {
entry.target.classList.add('is-visible');
}, 250 + i * 80);
} else {
// hand off to a scroll-triggered observer
}
});
}, { threshold: 0, rootMargin: '0px 0px -25% 0px' });
Above-the-fold elements — those in the top 75% of the viewport on load — fire in document order, staggered 80 ms apart starting 250 ms in, so the fold reveals as one coordinated wave just after the hero entrance.
Everything below the fold is handed to one of two scroll-triggered observers. Short elements use threshold: 0.15, so they are clearly on screen before they play; unusually tall elements (taller than ~85% of the viewport) drop to threshold: 0, so they still fire even though they can never be 15% visible. Both trigger a little deeper into the viewport on normal pages and a little earlier on eager listing pages:
const belowFoldMargin = eager ? '0px 0px -5% 0px' : '0px 0px -15% 0px';
Eager elements skip the observers entirely — they and their grouped row-mates are revealed on a 250ms timer. And once an element has revealed, it is unobserved immediately and never watched again. Reveals are one-way; scrolling back up does not replay them, and long pages stay cheap.
Scroll-based features
Two features update in real time as you scroll, both throttled with requestAnimationFrame and registered with { passive: true } so they never block the main thread:
Scroll progress bar — a 2 px brand-coloured line on the header edge that fills left to right as you scroll.
Scroll progress ring — a circular SVG arc that draws clockwise around the back-to-top button, driven by a single stroke-dashoffset calculated from scroll position.
Both use var(--color-brand-500), so they re-colour instantly whenever the visitor switches themes.
The typing effect
The hero typing effect on the About page cycles through words one character at a time. It is a setTimeout loop with three non-obvious fixes: the element width is locked to the widest word up front (via an off-screen measuring span) so nothing reflows as words of different lengths cycle; it restarts on astro:page-load so it survives client navigation; and overflow: hidden was removed because it clipped descenders at large heading sizes.
Why no view transitions
Astro ships a ClientRouter that crossfades between pages without a full reload. Astro Rocket intentionally does not use it.
The reason is a compositing conflict. Keyframe animations with fill-mode: both hold their element in the animation’s initial state — opacity near zero, translated down — until the animation runs. When a view transition swaps the DOM mid-navigation, the incoming hero is introduced already in that hidden state, and on mobile there was a visible frame or two where the old page had faded out and the new hero had not yet started — a blank flash that made the transition feel broken. A normal page load avoids it entirely: the browser paints the new page and the CSS runs from frame zero, every time.
Reduced motion
Every entrance 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 reduced motion is requested, every entrance is replaced with an immediate render — elements appear at full opacity in their resting position. Scrolling, theme switching, and interactive components all keep working; only the decorative motion is removed.
Why it works without a library
Three things hold this together at the quality of a paid animation toolkit:
One easing curve used everywhere. cubic-bezier(0.16, 1, 0.3, 1) appears in the hero keyframes, the header drop-in, the scroll-reveal transitions, and the article-body reveals. The motion language is consistent because the timing function is consistent.
CSS does the work. JavaScript only toggles a class. Starting position, duration, easing, delay — all of it lives in the stylesheet, so the animations composite on the GPU, never block the main thread, and cost nothing until they are triggered.
Cascade precedence is explicit. The animation rules live outside every Tailwind @layer, which makes them unlayered CSS — and unlayered rules win over any @layer-scoped utility. No Tailwind class can accidentally override a transition or animation the system depends on.
The full system lives 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.