GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Hero Section Design Examples: 9 Patterns

Hero section design examples: One sets a card reveal delay from its order value past a visibility threshold; one resizes an answer from its content length;

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

Every price, testimonial quote, stat, and logo shown across the nine sections is fictional — placeholders read BRAND A through BRAND D, and there is no real company, no real person's photo, and no live pricing or analytics API anywhere in the set. Listener-wise, only four of the nine answer something a person actually does: 03 (pricing toggle) and 05 (FAQ question) respond to a real click, 04 (testimonial carousel) pauses its auto-advance on real pointerenter/pointerleave, and 07 (footer link) redraws its underline from the same two real pointer events. The other five — 01, 02, 06, 08, and 09 — run once from a fixed CSS loop or a single requestAnimationFrame pass on load, with no click, drag, or pointer listener attached anywhere in their markup.

Anatomy of a landing section — what nine sections compute

Nine sections split into three ways of turning a scroll position, a state flip, or elapsed time into a number a browser can animate, and skipping this layer is what makes a landing page hero section feel like a slideshow instead of a page that responds to anything.

Why three sections keep a value increasing on their own, without waiting for a state change

01, 06, and 08 each recompute a value that keeps moving under a scroll or time input alone: 01's --sv custom property tracks a simulated scroll ratio and multiplies it by a different rate for the background layer and the heading layer, 06's --angle custom property keeps climbing once the band enters view so its conic-gradient border reads as a rotating ring, and 08's horizontal-offset value keeps growing so a duplicated logo set can loop without a visible seam.

Why three sections wait for a boolean or index value to flip before recomputing anything

02, 03, and 05 sit still until something switches: 02 waits for a card's visible-ratio value to cross a threshold before multiplying that card's order index by a fixed interval to time its reveal, 03 waits for the billing-cycle state to flip before crossfading two precomputed prices and recomputing the featured card's scale value, and 05 waits for a question's open-state value to flip before recomputing its answer's real content height.

Why three sections measure something directly instead of just reading a stored state

04, 07, and 09 pull a number from measurement or elapsed time rather than a flag: 04 grows an auto-advance value on a timer to interpolate the carousel's position and pauses that growth once a real pointer hovers, 07 measures a hovered link's actual rendered width with getBoundingClientRect() before growing its underline to that value, and 09 runs an elapsed-time ratio through an easing curve every frame to recompute the counter's displayed number up to its target.

Code per section

01Hero — an intro where background and heading shift at different scroll ratios

As the page's scroll value changes, the background image and heading move at different ratios, and the offset between the two layers creates depth.

Hero — an intro where background and heading shift at different scroll ratios
Open in a new tab

01 · Hero — an intro where background and heading shift at different scroll ratios — Measured area 17.827% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

--svtranslateY@property

What the numbers say

Across 23 tracked frames the hero changes on 20 of them, covering 17.827% cumulative area at a 98.9 average intensity, since both the background layer and the heading layer shift on every step of the loop. The --sv custom property is typed as <number> so calc() can multiply it directly, and the background moves at -18px per unit while the heading moves at a steeper -42px, which is what keeps the two layers reading as separate depths instead of one flat image.

@property --sv {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}

.stage {
  --sv: 0;
  position: relative;
  overflow: hidden;
  animation: scroll-sim $duration $easing infinite;
}

.hero__bg {
  position: absolute;
  inset: -10% -10% -30% -10%;
  border-radius: $r-card;
  background: linear-gradient(180deg, rgba($color, .28), rgba($color, 0));
  transform: translateY(calc(var(--sv) * -18px));
}

02Feature card grid — cards revealing in order from a visible-ratio value

Once a card's visible-ratio value crosses a threshold, it appears in turn starting at a time computed from its order value times a fixed interval.

Feature card grid — cards revealing in order from a visible-ratio value
Open in a new tab

02 · Feature card grid — cards revealing in order from a visible-ratio value — Measured area 57.951% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

--ianimation-delaytranslateY

What the numbers say

Across 23 tracked frames the grid changes on 12 of them, covering 57.951% cumulative area — the highest of the nine — at a 37.3 average intensity, since every card in the grid shifts together rather than one small element moving alone. Each card carries its own order value in an inline --i custom property, and animation-delay: calc(var(--i) * #{$stagger-step}) turns that value into a 120ms-per-card offset, the same constant this post's first variant stretches to 280ms.

.is-demo .card {
  animation: card-in $duration $easing both;
  animation-delay: calc(var(--i) * #{$stagger-step});
}

@keyframes card-in {
  0% { opacity: 0; transform: translateY(14px); }
  100% { opacity: 1; transform: translateY(0); }
}

03Pricing cards — a list where only the recommended card gets a larger scale value

The recommended plan's scale value is computed larger than its neighbors so it appears to float, and once the billing cycle changes, the shown price recalculates to that value.

Pricing cards — a list where only the recommended card gets a larger scale value
Open in a new tab

03 · Pricing cards — a list where only the recommended card gets a larger scale value — Measured area 5.337% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

classList.togglenth-child(2)steps(1, end)

What the numbers say

Across 23 tracked frames the pricing list changes on 9 of them, covering 5.337% cumulative area at a 56.8 average intensity, since only the featured card's scale value and the crossfading price pair actually move. Clicking either billing button — a real click listener, not an autoplay loop — toggles the is-yearly class that both swaps which precomputed price is visible and recomputes the featured card's scale value, which this post's zip ships at 1.08 rather than a wider 1.16.

// Clicking a billing-cycle button sets the pricing state to yearly or monthly, and the shown price recalculates from that state.
// Computed layer: billing-cycle state -> recomputed shown price. Real click listener attached.
(function () {
  var root = document.getElementById('pricing');
  var mBtn = document.getElementById('btn-monthly');
  var yBtn = document.getElementById('btn-yearly');
  if (!root || !mBtn || !yBtn) return;

  function setYearly(yearly) {
    root.classList.remove('is-demo');
    root.classList.toggle('is-yearly', yearly);
    mBtn.classList.toggle('is-active', !yearly);
    yBtn.classList.toggle('is-active', yearly);
    mBtn.setAttribute('aria-pressed', String(!yearly));
    yBtn.setAttribute('aria-pressed', String(yearly));
  }

  mBtn.addEventListener('click', function () { setYearly(false); });
  yBtn.addEventListener('click', function () { setYearly(true); });
})();

04Testimonial carousel — a card track whose auto-advance value grows on a timer

The testimonial track's auto-advance value increases on a timer, interpolating the position to the next card, and pauses that increase once the pointer hovers over it.

Testimonial carousel — a card track whose auto-advance value grows on a timer
Open in a new tab

04 · Testimonial carousel — a card track whose auto-advance value grows on a timer — Measured area 2.61% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

setIntervaltranslateXclearInterval

What the numbers say

Across 23 tracked frames the carousel changes on 8 of them, covering 2.61% cumulative area at a 124.2 average intensity, since only one card-width slice of the track moves at a time rather than the whole section. window.setInterval(advance, 2600) grows the index value every 2.6 seconds and render() turns that index into a translateX position, and a real pointerenter listener stops that timer so the index only keeps growing while nobody is reading the current card.

function render() {
  track.style.transform = 'translateX(' + (index * -100) + '%)';
}

function advance() {
  index = (index + 1) % count;
  render();
}

function stop() {
  if (timer !== null) { window.clearInterval(timer); timer = null; }
}

function start() {
  stop();
  timer = window.setInterval(advance, 2600);
}

function goLive() {
  if (live) return;
  live = true;
  track.classList.remove('is-demo');
  render();
  start();
}

05FAQ accordion — a list recomputing answer height from content length

Opening a question recomputes its answer's content-height value and expands to that value, while opening another one collapses the previous answer back to a height of zero.

FAQ accordion — a list recomputing answer height from content length
Open in a new tab

05 · FAQ accordion — a list recomputing answer height from content length — Measured area 39.718% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

scrollHeight--openclassList.remove

What the numbers say

Across 23 tracked frames the accordion changes on 17 of them, covering 39.718% cumulative area — the second-highest of the nine — at a 160.1 average intensity, since a full answer block expands and collapses rather than a thin element sliding. A real click listener calls openItem(), which reads the answer's actual scrollHeight and writes it into max-height so a short answer never leaves empty space and a long one never gets clipped.

function openItem(item) {
  var q = item.querySelector('.faq__q');
  var a = item.querySelector('.faq__a');
  if (!q || !a) return;
  q.setAttribute('aria-expanded', 'true');
  a.style.maxHeight = a.scrollHeight + 'px';
}

items.forEach(function (item) {
  var q = item.querySelector('.faq__q');
  if (!q) return;
  q.addEventListener('click', function () {
    faq.classList.remove('is-demo');
    var wasOpen = q.getAttribute('aria-expanded') === 'true';
    items.forEach(closeItem);
    if (!wasOpen) openItem(item);
  });
});

06CTA band — a strip whose border-angle value keeps increasing once it enters view

Once the band enters the viewport, the gradient-angle value tracing the button's border keeps increasing at a steady rate, making the border appear to rotate.

CTA band — a strip whose border-angle value keeps increasing once it enters view
Open in a new tab

06 · CTA band — a strip whose border-angle value keeps increasing once it enters view — Measured area 1.011% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

--angleconic-gradient@property

What the numbers say

Across 23 tracked frames the band changes on all 23 — tied for the most of the nine — but covers only 1.011% cumulative area at a 19.1 average intensity, the lowest of the nine, because only a thin ring outline redraws on every frame rather than a wide surface. @property --angle is typed as <angle> so @keyframes spin { to { --angle: 360deg; } } can animate it directly, and the conic-gradient tracing .band__ring is what turns that single increasing value into the appearance of a rotating border.

.band__ring {
  position: absolute;
  inset: 0;
  border-radius: $r-pill;
  background: conic-gradient(from var(--angle), $color, transparent 32%, $color);
  animation: spin $duration $easing infinite;
}

.band__cta {
  position: relative;
  border: 0;
  border-radius: calc(#{$r-pill} - 4px);
  padding: $sp-3 $sp-8;
  font-family: inherit;
  font-weight: 600;
  font-size: 13px;
  line-height: 1;
  color: $stage-yellow;
  background: $color;
  cursor: pointer;
}

@keyframes spin {
  to { --angle: 360deg; }
}

07Footer — a link list recomputing underline width from zero to the text width

Once the pointer sits over a link, that item's underline-width value is recomputed from zero up to the text's width and draws in from the left.

Footer — a link list recomputing underline width from zero to the text width
Open in a new tab

07 · Footer — a link list recomputing underline width from zero to the text width — Measured area 0.281% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

getBoundingClientRectquerySelectorAlladdEventListener

What the numbers say

Across 23 tracked frames the footer changes on only 7 of them — the fewest of the nine — covering the smallest cumulative area of the nine at 0.281%, yet its 232.0 average intensity is the highest of the nine, because the handful of pixels that do move are concentrated in one thin underline rather than spread across a surface. A real pointerenter listener measures the hovered link's actual rendered width and writes it into a custom property that the underline's width reads, so a short link's underline never overshoots and a long link's never falls short.

// When the pointer enters a link, its text's real width value is remeasured, and the underline-width value grows from zero up to that value.
// Computed layer: pointer entry -> text-width value (getBoundingClientRect) -> underline-width interpolation. Real pointer listener attached (the demo playback itself is a fixed sequence).
(function () {
  var panel = document.getElementById('stage-panel');
  if (!panel) return;
  var links = panel.querySelectorAll('.link');

  links.forEach(function (link) {
    link.addEventListener('pointerenter', function () {
      panel.classList.remove('is-demo');
      var w = link.getBoundingClientRect().width;
      link.style.setProperty('--tw', w + 'px');
    });
    link.addEventListener('pointerleave', function () {
      link.style.setProperty('--tw', '0px');
    });
  });
})();

08Logo bar — a row whose horizontal offset value keeps growing without a seam

The logo row's horizontal offset value keeps increasing over time, and as a logo exits the right edge the same logo reappears on the left so the row never breaks.

Logo bar — a row whose horizontal offset value keeps growing without a seam
Open in a new tab

08 · Logo bar — a row whose horizontal offset value keeps growing without a seam — Measured area 2.711% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

@keyframeswill-changetranslateX

What the numbers say

Across 23 tracked frames the marquee changes on all 23 — tied with 06 for the most of the nine — covering 2.711% cumulative area at a 132.9 average intensity, since a full row of logos is in motion on every frame rather than a single element. The logo set is duplicated once and marked aria-hidden on the copy, and the track moves exactly half the duplicated track's own width on a loop, so the seam between the last logo and the first one lands invisibly instead of snapping.

.marquee__track {
  display: flex;
  width: max-content;
  will-change: transform;
}

.marquee__track.is-demo {
  animation: scroll $duration $easing infinite;
}

.logo {
  margin-right: $sp-8;
  color: $color;
  font-weight: 700;
  font-size: 15px;
  letter-spacing: 0.03em;
  white-space: nowrap;
}

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

09Stat counter — a number recalculated every frame up to a target value

Once the section enters the viewport, the number recalculates every frame from zero up to its target value, and a new digit appears when the value crosses a digit boundary.

Stat counter — a number recalculated every frame up to a target value
Open in a new tab

09 · Stat counter — a number recalculated every frame up to a target value — Measured area 3.044% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFrameMath.powtoLocaleString

What the numbers say

Across 23 tracked frames the counter changes on 13 of them, covering 3.044% cumulative area at a 134.4 average intensity, since only the digit glyphs themselves redraw rather than any surrounding layout. Each animation-frame step divides elapsed time by a fixed duration constant — 1700ms in this zip, 3200ms in this post's third variant — runs that ratio through an ease-out curve, and multiplies the result by the target value before writing it back to the page, so a larger target simply computes a larger number at the same eased pace rather than running longer.

function run(el) {
  var target = Number(el.getAttribute('data-target')) || 0;
  var suffix = el.getAttribute('data-suffix') || '';
  if (reduceMotion) {
    el.textContent = format(target) + suffix;
    return;
  }
  var start = null;
  function step(ts) {
    if (start === null) start = ts;
    var elapsed = ts - start;
    var t = Math.min(1, elapsed / COUNT_DURATION_MS);
    var value = target * easeOutCubic(t);
    el.textContent = format(value) + suffix;
    if (t < 1) {
      window.requestAnimationFrame(step);
    }
  }
  window.requestAnimationFrame(step);
}

Where values reshape the section — scroll ratio, order value, target number

Three of the nine values above don't just decorate a section — get them wrong and the section reads as broken or misleading, not just less lively.

Why hero's scroll ratio has to drive two different rates, not one

01's background and heading both read the same scroll-ratio value, but the background multiplies it by -18px while the heading multiplies it by a steeper -42px — if both layers used the same rate the two would move together as one flat image, and the parallax depth this pattern is named for would disappear entirely.

Why the stagger's order value needs a visibility gate before it fires

02 doesn't just multiply every card's order value by a fixed interval on page load — a card only starts counting from its own delay once its visible-ratio value has already crossed a threshold, so a card far down the page doesn't finish revealing itself before a visitor has scrolled anywhere near it.

Why the counter's target number has to be recomputed every frame, not tweened once

09 can't just animate a CSS property from 0 to its target, because the displayed value has to pass through an easing curve and get re-formatted on every single frame — recomputing the whole number from the elapsed-time ratio each time is what lets a new digit appear cleanly the moment the value crosses a digit boundary, instead of a generic tween briefly showing an impossible in-between digit count.

Where it breaks — long text, low-end devices, dark mode

Where this set differs from a nine-section team-page set built around the same section-by-section layout, a nine-section pricing-page set built the same way, and a nine-section case-study-page set is the computed axis each one turns into motion — those sets recompute values tied to a team roster's layout, a plan's own discount rate, and a case study's own content blocks, while this set's nine sections recompute a scroll ratio, an order value, a measured width, and elapsed time instead. The full nine-section implementation behind this set, both the vanilla version and a React port, sits behind 67e9abq3 in this post's zip.

What happens to hero section design ideas when the heading text runs long

01's heading is a short fixed string in the demo, but most hero section design ideas eventually pair the same moving background with real marketing copy — because the heading's steeper rate is independent of its content length, a longer heading still moves the same distance as a short one, though a production build should still cap line count so the moving text can't overlap the section beneath it on a narrow viewport.

What a low-end device does to several looping sections on the same page

06's conic-gradient ring, 08's marquee, and 01's background layer all animate continuously rather than once, so a low-end device rendering several of these sections on the same page can drop frames on the ring and the marquee before it drops any on the parallax layer, since a conic-gradient repaint costs more per frame than a transform-only animation — keeping the ring's spin duration no shorter than 2s and marking the marquee track for compositing both help keep the frame budget realistic.

What dark mode does to a set built like most landing page sections examples

Unlike landing page sections examples that hardcode a single light palette, every color in this set reads from a named token rather than a literal hex value, so a dark-mode variant only has to override the token layer — the CTA band's own foreground color is the one value worth rechecking by hand, since a color that passes contrast against a light band background can fail once the band's background token flips dark.

Three variants and accessibility

Treat the numbers below as a hero section design template only once every placeholder brand, price, and headline has been swapped for real content — the computed axis stays the same, only the constant changes.

Variant What changes What it trades off
Feature-card stagger interval Item 02's stagger constant from 120ms to 280ms At 120ms the cards land close enough together that the grid reads as one motion; at 280ms each card visibly takes its own turn, but a grid with many cards takes noticeably longer to finish revealing
Pricing featured-card scale Item 03's scale constant from 1.08 to 1.16 At 1.08 the featured card barely stands out from its neighbors; at 1.16 it clearly floats above them, but at tight card spacing the larger scale can visually overlap the card beside it
Stat counter duration Item 09's duration constant from 1700ms to 3200ms At 1700ms a large target number still finishes counting before most visitors look away; at 3200ms the count reads more deliberately, but a very large target risks still counting after a visitor has scrolled past

This zip ships the 120ms, 1.08, and 1700ms values.

Why reduced motion and screen readers still land on the same computed values

All nine check for a reduced-motion preference and swap their computed transition or animation for an instant jump to the same end value, so a reduced-motion reader still sees the hero settled at its resting position, the featured pricing card at its full scale, and the counter already at its target number — just without the interpolation in between. Pressed and expanded states stay in sync on the pricing toggle and the FAQ questions, and the hero background, the CTA band's ring, and the logo marquee's duplicated half are all marked as decorative so a screen reader doesn't announce a duplicate or purely visual layer as content.

FAQ

Do any of these nine sections load a real company name, price, or photo?

No — every brand shown is a placeholder like BRAND A through BRAND D, every price and stat is a fixed fictional value written into the demo's own markup or data attributes, and there is no real person's photo or live API call anywhere in the set.

Which of the nine respond to a real click or pointer instead of just autoplaying?

Four do: 03's billing toggle and 05's question header respond to a real click, 04's carousel pauses on real pointerenter/pointerleave, and 07's footer link redraws its underline on the same two pointer events; the other five — 01, 02, 06, 08, and 09 — run once from a fixed CSS loop or a single requestAnimationFrame pass with no click, drag, or pointer listener attached anywhere in their markup.

Is a section set built this way safe to reuse directly on a real product page?

Yes, once every placeholder brand, price, and headline is replaced with real content — the computed layer itself, the scroll ratio, the order value, the measured width, and the eased counter, doesn't reference any placeholder, so swapping the on-screen text and colors is enough to move from demo to production.

Sources: MDN — CSS custom properties · MDN — IntersectionObserver · MDN — Element.getBoundingClientRect · MDN — prefers-reduced-motion

Enter the archive password

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