GODRICH

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.

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

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)). It's the busiest of the nine by frame count, moving in 23 of 24 captured frames, yet it repaints only 3.313% of the canvas in total because nothing but the digits and the bar tips ever changes.

setIntervalscaleYaria-live
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

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. A bad address shakes the input instead of failing silently, which accounts for the 6.982% peak frame in a 13.067% total.

localStoragescaleXaria-live
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

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. 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.

aria-checkedtranslateYsetInterval
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

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. Because the rows are small text, the whole reveal costs 4.728% of the canvas and peaks at 2.149% in a single frame.

animation-delayscaleXaria-expanded
.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

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. It's the heaviest section of the nine by area, touching 40.843% of the canvas over 22 of 24 frames.

grid-template-rowsaria-expandedscale
.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

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. The row is the calmest of the nine at intensity 42.6, spread across 17 of 24 frames.

stroke-dashoffsetaria-expandedscale
.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

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. The card fills a large part of the frame as it arrives, which is the 17.151% peak in the measurements.

sessionStoragetranslateYaria-modal
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

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. Reading plus the ribbon together move 21.055% of the canvas at an intensity of 125.5, second only to the live counter.

IntersectionObservertranslateYsessionStorage
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

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. 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.622%, across only 11 moving frames.

aria-checkedscaletranslateX
.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.313% 0.581% 144.1 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.728% 2.149% 92.4 13/24
05 Objection FAQ 40.843% 15.855% 118.4 22/24
06 Guarantee badge row 8.107% 3.490% 42.6 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.622% 61.9 11/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 are the label swaps 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.