GODRICH

9 CSS Hero Section Animations You Can Copy-Paste

A hero section is the big banner filling a landing page's first screen — headline, text, button.

Auto-plays · click a tile to jump to its section · all nine in one zip

These nine are ordered by what a visitor's eye actually meets on a fresh load, not by popularity. 01 through 03 own the very first screen — the headline, the background drifting behind it, and the CTA that has to keep holding attention. 04 and 05 build trust with a product mockup and a badge, 06 is the partner-logo strip most hero sections tuck just under the fold, and the last three (07-09) react to something happening — scrolling past, or a toast loading in.

01Headline stagger in

The three lines of a hero headline don't land together — each one fades and lifts into place a beat after the one before it, like someone typing out the pitch line by line. Every line has its own @keyframes, and only the entrance point (10%, 20%, 30%) shifts between them, so the whole stagger runs on nothing but opacity and translateY.

@keyframesopacity · translateYstagger delay
.hero__line { opacity: 0; transform: translateY($sp-6); }

.hero__line--1 { animation: line-in-1 $duration $easing infinite; }

@keyframes line-in-1 {
  0%   { opacity: 0; transform: translateY($sp-6); }
  10%  { opacity: 1; transform: translateY(0); }
  85%  { opacity: 1; transform: translateY(0); }
  100% { opacity: 0; transform: translateY($sp-6); }
}

02Gradient background drift

Behind the card sits a conic-gradient layer more than twice the card's own size, held in place only by overflow: hidden on the card that clips it. Just translate and rotate move it — never background-position — so the gradient never has to be recalculated as it drifts.

::beforeconic-gradienttranslate · rotate
.hero::before {
  content: '';
  position: absolute;
  inset: -60% -60%;
  background: conic-gradient(from 0deg, #ff477e, #7b2ff7, #4cc9f0, #ff477e);
  opacity: .55;
}
.stage.is-demo .hero::before { animation: drift $duration $easing infinite; }

@keyframes drift {
  0%   { transform: translate(-6%, -4%) rotate(0deg); }
  50%  { transform: translate(6%, 5%) rotate(180deg); }
  100% { transform: translate(-6%, -4%) rotate(360deg); }
}

03CTA pulse glow

The button breathes: scale and the box-shadow glow's blur radius and opacity grow and shrink together on the same loop. That loop is decoration only — the button still lifts on a genuine :hover and outlines on :focus-visible, each running its own, much quicker transition underneath.

box-shadowscale:hover · :focus-visible
.cta {
  box-shadow: 0 0 0 0 rgba(255, 224, 102, 0);
  transition: transform $dur-quick $ease-out;
}
.cta:hover { transform: translateY(-2px); }
.cta:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }

.stage.is-demo .cta { animation: pulse $duration $easing infinite; }

@keyframes pulse {
  0%   { transform: scale(1);     box-shadow: 0 0 0 0 rgba(255, 224, 102, .55); }
  50%  { transform: scale(1.045); box-shadow: 0 0 $sp-8 $sp-2 rgba(255, 224, 102, .55); }
  100% { transform: scale(1);     box-shadow: 0 0 0 0 rgba(255, 224, 102, 0); }
}

04Mockup float

The whole mockup card — browser-chrome dots and all — drifts up and tilts a fraction of a degree, then settles back, reading as floating instead of sitting flat on the page. Both values live entirely inside transform, so the float never forces a layout recalculation.

transform: translateYrotate@keyframes
.mockup { overflow: hidden; }

.stage.is-demo .mockup { animation: float $duration $easing infinite; }

@keyframes float {
  0%   { transform: translateY(0) rotate(0deg); }
  50%  { transform: translateY(-$sp-4) rotate(-1.2deg); }
  100% { transform: translateY(0) rotate(0deg); }
}

05Badge slide in

A NEW badge slides in from the side, holds still long enough to actually be read, then slides back out and repeats. That hold comes from a flat stretch in the keyframes — 30% to 65% sit at the same value — rather than any pause added to the animation itself.

transform: translateXopacitykeyframe hold
.badge {
  opacity: 0;
  transform: translateX($sp-10);
}
.stage.is-demo .badge { animation: slide-in $duration $easing infinite; }

@keyframes slide-in {
  0%   { opacity: 0; transform: translateX($sp-10); }
  30%  { opacity: 1; transform: translateX(0); }
  65%  { opacity: 1; transform: translateX(0); }
  100% { opacity: 0; transform: translateX($sp-10); }
}

06Marquee strip

Below the hero, a row of partner-logo pills scrolls sideways without a visible end, because the row is actually two identical sets of pills glued together and moved exactly half their combined width. Spacing between pills comes from margin-right, not flex gapgap would only widen the seam between the two sets, and the loop would visibly jump once a lap.

translateX(-50%)margin-right gapdoubled track
.marquee__track {
  display: flex;
  width: max-content;
  animation: pill-scroll $duration $easing infinite;
}
.marquee__pill { margin-right: $sp-4; flex-shrink: 0; }

@keyframes pill-scroll {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

07Text fades behind on scroll

In production, a real scroll-timeline would tie this headline's scale, opacity, and position directly to how far the page has scrolled. Browser support for that still varies enough that this demo swaps in a plain, looping @keyframes animation instead, so the capture looks the same no matter which browser opens it.

scale · opacitytranslateYtransform-origin: center top
.stage.is-demo .hero {
  animation: recede $duration $easing infinite;
  transform-origin: center top;
}

@keyframes recede {
  0%   { opacity: 1;   transform: scale(1)   translateY(0); }
  55%  { opacity: .15; transform: scale(.85) translateY(-$sp-10); }
  100% { opacity: 1;   transform: scale(1)   translateY(0); }
}

08Split text mask reveal

The headline looks like it's being wiped into view left to right, with an underline growing on the exact same beat underneath it. clip-path: inset() does the wipe by narrowing its right-hand value from 100% down to 0%, while the underline grows alongside it with a plain scaleX.

clip-path: inset()scaleX underlinesweep reveal
.headline__mask { clip-path: inset(0 100% 0 0); }
.headline__underline { transform: scaleX(0); transform-origin: left center; }

.stage.is-demo .headline__mask { animation: reveal $duration $easing infinite; }
.stage.is-demo .headline__underline { animation: underline-grow $duration $easing infinite; }

@keyframes reveal {
  0%   { clip-path: inset(0 100% 0 0); }
  40%  { clip-path: inset(0 0% 0 0); }
  85%  { clip-path: inset(0 0% 0 0); }
  100% { clip-path: inset(0 100% 0 0); }
}

09@starting-style load-in

A toast can go from display: none to fully visible with a genuine entrance transition — no @keyframes, no animation logic in JavaScript — just @starting-style paired with transition-behavior: allow-discrete set on the display property itself. The only JavaScript anywhere in this set of nine lives here, and it exists purely to replay that entrance every 350ms for the demo capture, not to make a real toast's first appearance work. In the actual file this loop sits inside a small function that waits 300ms before its first replay and then repeats.

@starting-styletransition-behavior: allow-discretedisplay transition
@supports (transition-behavior: allow-discrete) {
  .toast {
    transition: display $duration allow-discrete,
                opacity $duration $easing, transform $duration $easing;
  }
  .toast.is-hidden {
    display: none;
    opacity: 0;
    transform: translateY($sp-3) scale(.92);
  }
  @starting-style {
    .toast { opacity: 0; transform: translateY($sp-3) scale(.92); }
  }
}
function loop() {
  t.classList.add('is-hidden');
  requestAnimationFrame(function () {
    requestAnimationFrame(function () { t.classList.remove('is-hidden'); });
  });
}
setInterval(loop, 350);

Where does @starting-style break on a page that was never freshly loaded?

When item 09 was first built, the assumption was that @starting-style only fires for elements added to the page after it has already rendered. The real render disagreed: the toast played its entrance once on its own, before the replay script had run a single time, because @starting-style also applies to whatever was already sitting in the document at that very first style calculation — not only to later insertions. The replay loop that follows isn't what creates the entrance at all; it just re-triggers something CSS had already done by itself. Two other items in this set pay a related, quieter cost for stepping outside transform/opacity: 03's box-shadow glow and 08's clip-path wipe both force a repaint on every frame instead of a cheap composite — a trade each effect makes for a look transform alone can't produce. All nine files — the SCSS above and the React versions — sit behind one zip that opens with 5p7qvp7z, typed exactly as it appears in this sentence.

Variants worth trying

Variant Changed value Feel
Tighter stagger Item 01's entrance points (10% / 20% / 30%) tightened to (5% / 10% / 15%) Lines arrive almost together instead of one at a time
Bigger float Item 04's translateY(-$sp-4) (16px) raised to translateY(-32px) The mockup bobs like it's on water instead of breathing gently
Wider glow Item 03's box-shadow spread $sp-8 (32px) widened to 56px The CTA reads as a more urgent pulse instead of a calm one

Accessibility

With prefers-reduced-motion: reduce, all nine turn their animation off, and most simply land on an end state: 01 and 05 show every headline line and the badge already in place, 04 and 07 hold their resting transform, and 08 leaves the headline fully unmasked with the underline already grown. 02's gradient just stops turning wherever it happened to be mid-loop; 03 swaps its pulsing glow for one fixed, softer shadow rather than turning the glow off outright, so the button still reads as interactive without the motion. 06's marquee track stops at translateX(0), holding on the first set of pills — nothing is lost, since the row was only ever duplicated to loop, not to show two different sets of logos. 09 is the odd one out: reduced motion strips its transition, not its animation, so the demo's own replay script still toggles the toast every 350ms, just as an instant flash instead of a slide — the one item here that doesn't fully go still. More CSS animation sets that reuse this project's building blocks live in the marquee post item 06 borrows its scroll structure from, and the rest of what's published sits in the CSS category hub.

FAQ

Do I need JavaScript for hero section animations?

No — eight of the nine run on CSS alone. The only JavaScript in the set is a short replay loop behind item 09, and it exists only to repeat the entrance for this demo's capture, not to make a real toast's first appearance work.

Why does my scroll-linked hero effect play even when I'm not scrolling?

Item 07 shows what a real scroll-timeline effect would look like, but browser support for animation-timeline: view() and scroll() still varies enough that this demo substitutes a timed @keyframes loop instead. Wire the same scale, opacity, and translateY values to animation-timeline: view() directly once the target browsers support it — see MDN's scroll-driven animations guide.

Will box-shadow and clip-path animations run as smoothly as the rest?

Not quite — transform and opacity are the only two properties a browser can animate by compositing existing layers, so items 03 (box-shadow) and 08 (clip-path) force a repaint on every frame instead. Both still earn their keep here, since neither a glow nor a wipe reveal can be built from transform or opacity alone.

Enter the archive password

The password is inside this article. You will find it as you read.