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

Landing Page Conversion: 9 Sections That Convert

Landing page conversion is the share of visitors who leave an email, book a slot, or buy, and this post hands you the nine sections that catch visitors before

From an exit-intent coupon popup to a CTA ribbon that rises while you read, every form check, countdown, and slot pick actually runs.

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

The order follows the doubts a visitor clears one after another, not how flashy each piece looks. Attention comes first with the live headcount; next comes the smallest possible action, where an email buys a file; then a commitment with a date attached to it; then the wallet; then the reasons people say no out loud; then the reassurance that their money is safe; then the grab at the door; then the hold that keeps a long page alive; and finally the fork where the visit ends in a decision instead of a back button. Every plan name, file name, seat count, price, and coupon code in these files is an invented placeholder, and no real certification or trust mark appears anywhere in the set. Landing page conversion optimization tends to stall on the same doubt twice, which is why the landing page parts below are ordered by that doubt rather than by looks.

Step Section The doubt it clears
Attention 01 Live viewer counter Is anyone else here?
First action 02 Lead magnet download card What do I get for my email?
Commitment 03 Webinar slot booking When exactly does this happen?
Wallet 04 Anchored price stack Is this price good or not?
Objection 05 Objection FAQ What happens if it doesn't work out?
Reassurance 06 Guarantee badge row Is my money safe?
The grab 07 Exit-intent coupon popup Is this the best offer I'll see?
The hold 08 Scroll-reached CTA ribbon Where was that button again?
Decision 09 Dual-path final CTA Do I buy or do I ask someone?

01Live viewer counter

What you see

The headcount drifts every 1.2 seconds, and the seven bars beside it rise and fall with it, so the page reads as occupied rather than abandoned. Under the hood, a setInterval random walk keeps the number between 96 and 168 and pushes each new level into a seven-slot queue that the bars read as scaleY(var(--v)).

Live viewer counter
Open in a new tab

01 · Live viewer counter — Measured area 3.017% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

setIntervalscaleYaria-live

Where to look in the code

It's the busiest of the nine by frame count, moving in 23 of 24 captured frames, yet it repaints only 3.017% of the canvas in total because nothing but the digits and the bar tips ever changes.

function step() {
  var d = Math.floor(Math.random() * 6) + 1;
  if (Math.random() < .5) d = -d;
  n = Math.max(96, Math.min(168, n + d));
  history.push(Math.max(.28, Math.min(1, (n - 88) / 84)));
  history.shift();
  paint();
  ticks++;
  if (ticks % 5 === 0) msg.textContent = n + ' people are viewing this page right now';
}
setInterval(step, 1200);

02Lead magnet download card

Where a landing page conversion rate is won on this card

Enter an email, check the consent box, and the card unlocks: the progress bar fills for 900 ms, then a file chip drops in that really downloads. The regex and the checkbox are read together, the button locks itself with aria-busy while the file is prepared, and a one-bit flag in localStorage is all that persists, so a returning visitor lands straight in the finished state.

Lead magnet download card
Open in a new tab

02 · Lead magnet download card — Measured area 13.067% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

localStoragescaleXaria-live

How to read the code

A bad address shakes the input instead of failing silently, which accounts for the 6.982% peak frame in a 13.067% total.

form.addEventListener('submit', function (e) {
  e.preventDefault();
  human();
  if (!EMAIL_RE.test(input.value) || !agree.checked) {
    input.classList.remove('is-shake');
    void input.offsetWidth;
    input.classList.add('is-shake');
    msg.textContent = 'Check your email address and the consent box';
    return;
  }
  btn.disabled = true;
  btn.setAttribute('aria-busy', 'true');
  form.classList.add('is-busy');
  msg.textContent = 'Preparing your file';
  setTimeout(function () {
    form.classList.remove('is-busy');
    btn.disabled = false;
    btn.removeAttribute('aria-busy');
    try { localStorage.setItem('lead271', '1'); } catch (e2) { /* a blocked store still finishes the screen */ }
    finish();
  }, 900);
});

03Webinar slot booking

What moves

Three sessions sit side by side, the full one refuses clicks from the start, and picking an open one runs the closing clock down a second at a time until the register button drops a booked badge into that slot. The group is a real radiogroup, so the left and right arrow keys move the selection and skip the disabled session without landing on it.

Webinar slot booking
Open in a new tab

03 · Webinar slot booking — Measured area 3.188% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-checkedtranslateYsetInterval

What to change

The digits ride a strip that moves by --d, which is why the whole section changes only 3.188% of the canvas across the loop.

function pick(i) {
  picked = i;
  slots.forEach(function (b, j) {
    b.setAttribute('aria-checked', j === i ? 'true' : 'false');
    b.tabIndex = j === i ? 0 : -1;
  });
  slots[i].focus({ preventScroll: true });
}
document.getElementById('wb-slots').addEventListener('keydown', function (e) {
  if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
  e.preventDefault(); human();
  var n = picked, step = e.key === 'ArrowRight' ? 1 : slots.length - 1;
  do { n = (n + step) % slots.length; } while (slots[n].disabled && n !== picked);
  pick(n);
});

04Anchored price stack

What you see

A strikethrough sweeps across the list price, the discounted and coupon prices land one row below the other, and the amount saved pops in last. The four rows share one ap-rise keyframe and are staggered by animation-delay: calc(var(--i) * 80ms), so the reading order is built into the timing rather than into four hand-written animations.

Anchored price stack
Open in a new tab

04 · Anchored price stack — Measured area 4.901% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

animation-delayscaleXaria-expanded

Where to look in the code

Because the rows are small text, the whole reveal costs 4.901% of the canvas and peaks at 2.247% in a single frame.

.stage.is-demo .ap__row {
  animation-name: ap-rise;
  animation-duration: $duration;
  animation-timing-function: $easing;
  animation-iteration-count: infinite;
  animation-fill-mode: backwards;
  animation-delay: calc(var(--i) * 80ms);
}
@keyframes ap-rise {
  0%        { opacity: 0; transform: translateY(10px); }
  12%       { opacity: 1; transform: translateY(0); }
  74%       { opacity: 1; transform: translateY(0); }
  78%, 100% { opacity: 0; transform: translateY(10px); }
}
@keyframes ap-strike {
  0%        { transform: scaleX(0); }
  24%, 74%  { transform: scaleX(1); }
  78%, 100% { transform: scaleX(0); }
}

05Objection-handling FAQ that arms the CTA

What happens on screen

Open each of the three worries and a check draws itself on that row; the moment the third one is cleared, the button below grows and its label changes to say it's ready. The answers expand through grid-template-rows: 0fr → 1fr with aria-expanded on the question button, and the label swap is a steps(1, end) cut, so the two labels never cross-fade into a blur.

Objection-handling FAQ that arms the CTA
Open in a new tab

05 · Objection-handling FAQ that arms the CTA — Measured area 40.806% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

grid-template-rowsaria-expandedscale

How to read the code

It's the heaviest section of the nine by area, touching 40.806% of the canvas over 22 of 24 frames.

.stage.is-demo .ob__cta-t {
  animation-name: ob-label-out;
  animation-duration: $duration;
  animation-timing-function: steps(1, end);
  animation-iteration-count: infinite;
}
.stage.is-demo .ob__cta-t--ready {
  animation-name: ob-label-in;
  animation-duration: $duration;
  animation-timing-function: steps(1, end);
  animation-iteration-count: infinite;
}
@keyframes ob-label-out {
  0%, 40%   { opacity: 1; }
  41%, 64%  { opacity: 0; }
  65%, 100% { opacity: 1; }
}
@keyframes ob-label-in {
  0%, 40%   { opacity: 0; }
  41%, 64%  { opacity: 1; }
  65%, 100% { opacity: 0; }
}

06Guarantee badge row

What moves

Four badges stamp down in sequence, the check inside the shield draws itself along its own stroke, and pressing a badge unfolds one line of explanation while the other three close. The drawing is stroke-dashoffset running from 26 to 0, which is cheap to animate because the line is already there and only its dash offset moves.

Guarantee badge row
Open in a new tab

06 · Guarantee badge row — Measured area 8.107% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

stroke-dashoffsetaria-expandedscale

What to change

The row is the calmest of the nine at intensity 42.5, spread across 17 of 24 frames.

.stage.is-demo .gb__draw polyline {
  animation-name: gb-draw;
  animation-duration: $duration;
  animation-timing-function: $ease-out;
  animation-iteration-count: infinite;
  animation-fill-mode: backwards;
  animation-delay: 160ms;
}
@keyframes gb-draw {
  0%        { stroke-dashoffset: 26; }
  34%, 70%  { stroke-dashoffset: 0; }
  74%, 100% { stroke-dashoffset: 26; }
}

07Exit-intent coupon popup

What you see

Push the cursor up past the top edge of the page and a coupon card rises; the copy button marks itself copied, Escape closes the card, and the same session never sees it twice. The trigger is a document-level mouseleave with a 12-pixel clientY guard, so a cursor leaving sideways toward a scrollbar doesn't fire it, and the dialog takes focus with focus({ preventScroll: true }) the moment it opens.

Exit-intent coupon popup
Open in a new tab

07 · Exit-intent coupon popup — Measured area 23.675% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

sessionStoragetranslateYaria-modal

Where to look in the code

The card fills a large part of the frame as it arrives, which is the 17.151% peak in the measurements.

function seen() { try { return sessionStorage.getItem('exit271') === '1'; } catch (err) { return false; } }
function openPop() {
  stage.classList.remove('is-demo');
  pop.classList.add('is-open');
  try { sessionStorage.setItem('exit271', '1'); } catch (err) {}
  closeBtn.focus({preventScroll:true});
}
document.addEventListener('mouseleave', function (e) {
  if (e.clientY < 12 && !pop.classList.contains('is-open')) {
    if (seen()) { msg.textContent = MICRO; return; }
    openPop();
  }
});
document.addEventListener('keydown', function (e) {
  if (e.key === 'Escape' && pop.classList.contains('is-open')) closePop();
});

08Scroll-reached CTA ribbon

What happens on screen

Read down the panel, and the moment the decisive paragraph is 60% visible, a ribbon rises from the bottom edge; close it and a show-again pill stays behind in the same corner. An IntersectionObserver rooted on the scroll box watches one marked paragraph, and the dismissal is written to sessionStorage, so a reload opens with the ribbon already put away.

Scroll-reached CTA ribbon
Open in a new tab

08 · Scroll-reached CTA ribbon — Measured area 21.055% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

IntersectionObservertranslateYsessionStorage

How to read the code

Reading plus the ribbon together move 21.055% of the canvas at an intensity of 125.5, second only to the live counter.

var mark = document.querySelector('[data-mark]');
var closed = false;
try { closed = sessionStorage.getItem('ribbon271') === 'closed'; } catch (err) {}
if (closed) { again.hidden = false; msg.textContent = 'The banner is dismissed'; }

var io = new IntersectionObserver(function (entries) {
  if (entries[0].isIntersecting && !closed) {
    ribbon.classList.add('is-up');
    msg.textContent = 'The start banner slid up';
  }
}, { root: vp, threshold: 0.6 });
io.observe(mark);

09Dual-path final CTA

What moves

Two cards — start free and book a demo — take a click or an arrow key; the chosen one steps forward and the next-step line under it swaps to match. They're genuine radio inputs inside a role="radiogroup", so arrow-key navigation and the exclusive selection come from the browser rather than from a keydown handler.

Dual-path final CTA
Open in a new tab

09 · Dual-path final CTA — Measured area 26.074% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-checkedscaletranslateX

What to change

In the preview loop, the selection flips as a cut at 42% and 84%, which is why it registers the largest single-frame change of the nine, 23.621%, across only 12 moving frames.

.is-demo .dc__cell:first-child .dc__ring { animation: dc-ring-a $duration steps(1, end) infinite; }
@keyframes dc-ring-a {
  0%, 41%   { opacity: 1; }
  42%, 83%  { opacity: 0; }
  84%, 100% { opacity: 1; }
}
.is-demo .dc__cell:last-child .dc__ring { animation: dc-ring-b $duration steps(1, end) infinite; }
@keyframes dc-ring-b {
  0%, 41%   { opacity: 0; }
  42%, 83%  { opacity: 1; }
  84%, 100% { opacity: 0; }
}
.is-demo .dc__pv-t--a { animation: dc-ring-a $duration steps(1, end) infinite; }
.is-demo .dc__pv-t--b { animation: dc-ring-b $duration steps(1, end) infinite; }
.is-demo .dc__msg-t--a { animation: dc-ring-a $duration steps(1, end) infinite; }
.is-demo .dc__msg-t--b { animation: dc-ring-b $duration steps(1, end) infinite; }

Here is what the render pass actually measured for all nine, at 480×300 over a two-second loop at 12 fps.

Section Area changed Peak frame Intensity Moving frames
01 Live viewer counter 3.017% 0.456% 139.4 23/24
02 Lead magnet download card 13.067% 6.982% 60.5 15/24
03 Webinar slot booking 3.188% 1.799% 74.8 14/24
04 Anchored price stack 4.901% 2.247% 93.9 13/24
05 Objection FAQ 40.806% 15.829% 117.4 22/24
06 Guarantee badge row 8.107% 3.490% 42.5 17/24
07 Exit-intent coupon popup 23.675% 17.151% 90.0 20/24
08 Scroll-reached CTA ribbon 21.055% 9.894% 125.5 20/24
09 Dual-path final CTA 26.074% 23.621% 60.9 12/24

Where it breaks — three traps the preview loop exposed

The first one made the poster image lie. In the dual-path CTA (09), the selection flips as a cut, but the role="status" line under the cards was written once at load and never again, so the frame the preview loop picked as representative showed the demo card selected while the sentence still said the free start had been chosen. The fix was to put both sentences on the same clock as the selection itself, layered one over the other and switched by the same steps(1, end) keyframes, so the picture and the text can never disagree by a frame.

The second was a closing order that came apart. The three objection rows (05) were staggered open with animation-delay: calc(var(--i) * 160ms), which also staggered them shut, leaving a stretch where the first row had already closed while the CTA label still said it was ready to press. A frame from that stretch was picked as the poster: the button armed, the first objection unanswered. Narrowing the armed label to 41–64%, the only window where all three rows are open, and then drawing the close out far longer than the open (66% to 100%) fixed both problems at once, and the slower close also lowered the per-frame area enough that the closing motion stopped winning the poster vote.

The third was a message with nothing behind it. In the scroll ribbon (08), the status line appeared the instant the IntersectionObserver fired, while the ribbon itself was still sitting at translateY(110%) below the edge, so the loop produced a frame announcing a banner that wasn't on screen. Cutting the message in at 41%, the same moment the ribbon arrives, and stretching the reading pass out to 34–86% to lower the per-frame change moved the poster to the frame where the ribbon is actually up. One more rule fell out of that section: the transform belongs on the track inside the overflow-y: auto box, never on the box itself, because moving the box moves the scroll along with it. There was a fourth, cheaper lesson too. The capture is only two seconds long, so a four-second loop simply never shows its second half, and the loops in 07, 08, and 09 all had to be cut back to two seconds before they measured honestly.

Accessibility

Every one of the nine keeps its state when motion is switched off, and in the exact files you get after opening the zip with fuvny8y4, that promise takes the form of a @media (prefers-reduced-motion: reduce) block with !important on each declaration, so the animation stops while the selected, expanded, or booked state stays visible. The live counter (01) refreshes its role="status" sentence only once every five ticks, because announcing every tick turns a screen reader into a metronome, and the bars themselves are aria-hidden decoration driven by transform: scaleY(var(--v)). The download card (02) ties its input to a real label element through for, marks the button aria-busy while the file is prepared, and reports the result through role="status". The webinar section (03) runs its clock inside role="timer" with aria-live="off", so the countdown never interrupts, marks the full session disabled, and carries the selection in aria-checked with arrow-key movement. The price stack (04), the objection list (05), and the badge row (06) all expand through aria-expanded paired with grid-template-rows: 0fr → 1fr. The coupon popup (07) is a role="dialog" with aria-modal="true" that takes focus with focus({ preventScroll: true }) and closes on Escape. The ribbon (08) gives its close button an aria-label and remembers the dismissal in sessionStorage. The show-again pill takes its name from its own visible text. The final CTA (09) uses native radio inputs in a role="radiogroup", so arrow keys work without a line of key handling behind them. The query itself is documented on MDN's prefers-reduced-motion page.

If the trust half of a landing page is what you need next, the nine trust-building sections cover logos, comparison tables, and press proof, the template collection has the rest of the set, and the about page explains how every demo here is rendered and measured before it ships.

FAQ

Does the exit-intent popup (07) work on phones?

No, and neither does any other exit-intent trigger. On a touch screen there's no cursor to leave through the top edge, so mouseleave never fires. If you want an equivalent hold on mobile, drive the same popup from a scroll-up gesture or a time-on-page threshold and keep the sessionStorage guard so it still appears at most once per session.

Do the counter (01) and the seat counts (03) show real numbers?

No. The counter is a bounded random walk between 96 and 168, and the seat counts are fixed text, because a demo has no server to ask. Both are written so the display is the only thing you replace: point the counter at a real figure from your analytics endpoint and swap the seat strings for your booking data, and the animation, the ARIA wiring, and the reduced-motion block all keep working unchanged.

Why do these landing page conversion tips swap labels with cuts instead of fades?

Because a cross-fade produces frames where both labels are half visible, and one of those frames becomes the thumbnail. steps(1, end) means the old text is gone and the new text is there in the same frame, which is also more honest for anyone reading it: a button either says it's ready or it doesn't, and there's no in-between state to misread.

Enter the archive password

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