랜딩페이지 전환율을 끌어올리는 섹션 9종
랜딩페이지 전환율이란 방문자가 이메일을 남기거나 예약·구매까지 가는 비율을 말하고, 이 글은 그 전환을 직접 받아내는 섹션 아홉 개를 통째로 주는 코드 모음입니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 실시간 시청자 카운터
- 02 리드 마그넷 다운로드 카드
- 03 웨비나 회차 예약 섹션
- 04 가격 앵커 비교 스택
- 05 이의 해소 FAQ 와 도달 CTA
- 06 보증 배지 섹션
- 07 이탈 의도 쿠폰 팝업
- 08 스크롤 도달 CTA 리본
- 09 최종 CTA 이중 선택
아홉 개를 늘어놓은 순서는 인기순이 아니라 방문자가 결제까지 가며 하나씩 넘어야 하는 고개를 그대로 따라갑니다. 먼저 지금 이 페이지를 같이 보는 사람이 있다는 걸 보여서 관심을 붙들고(01), 이메일 한 줄로 첫 액션을 받아내고(02), 회차를 골라 약속을 잡게 하고(03), 정가를 기준점으로 남긴 채 지갑을 열게 하고(04), 남은 의심을 한 줄씩 지우고(05), 환불·해지 조건으로 안심시키고(06), 그래도 나가려는 커서를 쿠폰으로 붙잡고(07), 읽는 흐름을 끊지 않은 채 리본으로 붙들고(08), 마지막에 두 갈래 중 하나를 고르게 합니다(09). 화면에 보이는 플랜 이름·파일 이름·쿠폰 코드 WELCOME20 은 전부 지어낸 자리표시자이고, 실제 인증 기관의 마크는 한 장도 넣지 않았습니다.
01실시간 시청자 카운터
큰 숫자가 1.2초마다 오르내리고 그 옆 막대 일곱 칸이 값을 따라 높이를 바꿉니다. 숫자를 옮기는 건 setInterval 안의 랜덤 워크이고, 막대는 폭을 건드리지 않고 transform: scaleY(var(--v)) 배율만 바뀝니다. 화면을 못 보는 사람에게 읽어 주는 문장은 다섯 번에 한 번만 갈아 끼웁니다.
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 + '명이 이 페이지를 보고 있어요';
}
setInterval(step, 1200);
02리드 마그넷 다운로드 카드
이메일과 동의 칸을 채우고 누르면 버튼이 잠기며 진행 막대가 900ms 동안 차오르고, 그 뒤 다운로드 칩이 튀어나와 실제로 눌러서 받을 수 있습니다. 한 번 받았다는 사실은 localStorage 에 남아 다음 방문에는 칩이 이미 떠 있는 상태로 열립니다.
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 = '이메일과 동의 칸을 확인해 주세요';
return;
}
btn.disabled = true;
btn.setAttribute('aria-busy', 'true');
form.classList.add('is-busy');
msg.textContent = '파일을 준비하고 있어요';
setTimeout(function () {
form.classList.remove('is-busy');
btn.disabled = false;
btn.removeAttribute('aria-busy');
try { localStorage.setItem('lead271', '1'); } catch (e2) { }
finish();
}, 900);
});
03웨비나 회차 예약 섹션
마감된 회차는 처음부터 눌리지 않고, 남은 회차를 고르면 마감까지 남은 시간이 1초마다 줄며 초 자리 숫자 띠가 한 칸씩 올라갑니다. 예약 버튼을 누르면 고른 자리에 확정 배지가 내려앉습니다. 세 회차는 라디오 묶음이라 클릭뿐 아니라 좌우 방향키로도 옮겨 다니고, 마감 회차는 방향키가 건너뜁니다.
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 });
}
slots.forEach(function (b, i) { b.addEventListener('click', function () { human(); pick(i); }); });
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);
});
04가격 앵커 비교 스택
가격 구성 보기를 누르면 정가·할인가·쿠폰가·절약 금액 네 줄이 80ms 간격으로 차례차례 내려앉고, 정가에는 취소선이 왼쪽에서 오른쪽으로 그어집니다. 세 가격이 한 패널에 쌓여 있어 원래 가격이 계속 기준점으로 남습니다.
.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); }
}
05이의 해소 FAQ 와 도달 CTA
망설이는 이유 세 줄을 하나씩 열어 답을 보면 그 줄에 「해소됨」 표시가 그려지고, 세 줄이 모두 풀리는 순간 아래 버튼이 커지며 글자가 「준비됐어요, 눌러주세요」로 갈립니다. 펼침은 높이 값을 재지 않고 grid-template-rows 를 0fr 에서 1fr 로 옮기는 방식이라 답 길이가 제각각이어도 그대로 맞습니다.
.stage.is-demo .ob__a {
transition: none;
animation-name: ob-open;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 160ms);
}
@keyframes ob-open {
0% { grid-template-rows: 0fr; }
16% { grid-template-rows: 1fr; }
66% { grid-template-rows: 1fr; }
100% { grid-template-rows: 0fr; }
}
@keyframes ob-label-in {
0%, 40% { opacity: 0; }
41%, 64% { opacity: 1; }
65%, 100% { opacity: 0; }
}
06보증 배지 섹션
환불 보증·안전한 결제·데이터 보호·언제든 해지 네 배지가 100ms 간격으로 도장 찍히듯 내려앉고, 첫 배지 안의 체크는 선이 그려지듯 나타납니다. 체크가 그려지는 건 길이 26짜리 점선 간격을 두고 stroke-dashoffset 을 26에서 0으로 당기는 것이라 SVG 한 장이면 끝납니다. 배지를 누르면 그 배지 설명만 열리고 나머지는 닫힙니다.
.gb__draw polyline { stroke-dasharray: 26; stroke-dashoffset: 0; }
.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; }
}
07이탈 의도 쿠폰 팝업
커서가 화면 위쪽 가장자리를 넘어가려 하면 쿠폰 카드가 올라옵니다. 판정은 문서의 mouseleave 이벤트에서 clientY 가 12px 미만인 경우 하나뿐이고, 한 번 뜬 사실은 sessionStorage 에 남아 같은 세션에서는 다시 열리지 않습니다.
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});
}
function closePop() {
pop.classList.remove('is-open');
msg.textContent = MICRO;
cta.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();
});
08스크롤 도달 CTA 리본
글을 읽어 내려가다 표시해 둔 단락이 60% 넘게 보이는 순간 아래 가장자리에서 리본이 올라옵니다. 감시는 IntersectionObserver 에 root 를 스크롤 상자로 지정해 맡겼고, 닫으면 그 자리에 「다시 보기」 알약이 남습니다.
var io = new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting && !closed) {
ribbon.classList.add('is-up');
msg.textContent = '시작 안내가 올라왔어요';
}
}, { root: vp, threshold: 0.6 });
io.observe(mark);
closeBtn.addEventListener('click', function () {
closed = true;
ribbon.classList.remove('is-up');
again.hidden = false;
again.focus({preventScroll:true});
msg.textContent = '리본을 닫아 뒀어요';
try { sessionStorage.setItem('ribbon271', 'closed'); } catch (err) {}
});
09최종 CTA 이중 선택
「무료로 시작하기」와 「데모 상담 잡기」 두 카드 중 고른 쪽이 앞으로 나오고, 그 아래에 다음에 뭘 하게 되는지를 적은 한 줄이 선택에 맞춰 바뀝니다. 겉은 카드지만 속은 진짜 radio 라 좌우 방향키를 따로 짜 넣지 않아도 듣고, Enter 를 누르면 미리보기 줄이 시작 버튼으로 바뀝니다.
function paint() {
a.setAttribute('aria-checked', String(a.checked));
b.setAttribute('aria-checked', String(b.checked));
pv.classList.toggle('is-b', b.checked);
pv.classList.toggle('is-armed', armed);
pvA.setAttribute('aria-hidden', String(armed || b.checked));
pvB.setAttribute('aria-hidden', String(armed || a.checked));
pvGo.setAttribute('aria-hidden', String(!armed));
msg.classList.toggle('is-b', b.checked);
msgA.setAttribute('aria-hidden', String(b.checked));
msgB.setAttribute('aria-hidden', String(a.checked));
}
paint();
group.addEventListener('change', function () { armed = false; paint(); });
group.addEventListener('dblclick', function () { armed = true; paint(); });
아홉 개가 어느 고개를 맡는지 한 줄로 정리하면 이렇습니다.
| 단계 | 섹션 | 방문자가 넘는 고개 |
|---|---|---|
| 관심 | 01 실시간 시청자 카운터 | 여기 사람이 있나 |
| 첫 액션 | 02 리드 마그넷 다운로드 카드 | 이메일을 줘도 되나 |
| 약속 | 03 웨비나 회차 예약 섹션 | 언제 시간을 내지 |
| 지갑 | 04 가격 앵커 비교 스택 | 이 값이 싼 건가 |
| 의심 | 05 이의 해소 FAQ 와 도달 CTA | 나한테도 맞나 |
| 안심 | 06 보증 배지 섹션 | 잘못되면 어쩌지 |
| 붙잡기 | 07 이탈 의도 쿠폰 팝업 | 지금 나갈까 |
| 붙들기 | 08 스크롤 도달 CTA 리본 | 어디서 누르지 |
| 결정 | 09 최종 CTA 이중 선택 | 둘 중 어느 쪽 |
움직임은 480×300 화면에서 2초 동안 24프레임으로 찍어 픽셀 단위로 쟀습니다. 누적면적은 첫 프레임과 한 번이라도 달라진 픽셀의 비율, 프레임당 최대는 한 프레임에서 가장 크게 변한 면적의 비율입니다.
| 항목 | 누적면적 % | 프레임당 최대 % | 강도 | 움직인 프레임 |
|---|---|---|---|---|
| 01 실시간 시청자 카운터 | 3.313 | 0.581 | 144.1 | 23/24 |
| 02 리드 마그넷 다운로드 카드 | 13.067 | 6.982 | 60.5 | 15/24 |
| 03 웨비나 회차 예약 섹션 | 3.188 | 1.799 | 74.8 | 14/24 |
| 04 가격 앵커 비교 스택 | 4.728 | 2.149 | 92.4 | 13/24 |
| 05 이의 해소 FAQ 와 도달 CTA | 40.843 | 15.855 | 118.4 | 22/24 |
| 06 보증 배지 섹션 | 8.107 | 3.490 | 42.6 | 17/24 |
| 07 이탈 의도 쿠폰 팝업 | 23.675 | 17.151 | 90.0 | 20/24 |
| 08 스크롤 도달 CTA 리본 | 21.055 | 9.894 | 125.5 | 20/24 |
| 09 최종 CTA 이중 선택 | 26.074 | 23.622 | 61.9 | 11/24 |
어디서 깨지는가 — 함정
세 번 다 같은 병이었습니다. 화면은 멀쩡히 도는데 그 화면을 대표하는 한 장이 거짓말을 하는 병입니다.
첫 번째는 최종 CTA 이중 선택(09)에서 나왔습니다. 고른 카드는 컷으로 바뀌는데 아래 안내 문장은 페이지가 열릴 때 role="status" 로 한 번 쓰이고 끝이라, 미리보기 루프에서 뽑힌 대표 프레임이 「데모 상담」이 앞으로 나온 화면인데 문장은 「무료 시작을 골랐어요」인 채였습니다. 문장 두 줄을 같은 자리에 겹쳐 두고 선택과 같은 시계, 즉 같은 steps(1, end) 컷 키프레임으로 갈리게 하자 어긋남이 사라졌습니다.
두 번째는 이의 해소 FAQ(05)의 닫히는 순서입니다. 세 줄을 animation-delay: calc(var(--i) * 160ms) 로 시차를 두고 펼쳤더니 닫힐 때도 같은 시차가 그대로 붙어, 첫 줄만 닫히고 나머지 둘은 열려 있는 구간이 생겼습니다. 그 구간에도 버튼 라벨은 「준비됐어요」인 채라 첫 줄이 아직 안 풀렸는데 버튼만 활성화된 프레임이 대표 이미지로 뽑혔습니다. 라벨이 바뀌어 있는 구간을 세 줄이 전부 열려 있는 41~64% 로 좁히고, 닫힘을 열림보다 훨씬 느리게(66%에서 100%까지) 펴서 해결했습니다. 느리게 편 덕에 프레임당 변한 면적도 같이 내려가 대표 이미지가 닫히는 장면으로 뽑히지 않습니다.
세 번째는 스크롤 도달 CTA 리본(08)입니다. 미리보기 루프에서 안내 문구는 관찰자가 작동하자마자 떴는데 리본은 아직 translateY(110%) 로 화면 아래에 있어, 리본 없이 문구만 뜬 화면이 나왔습니다. 문구를 리본과 같은 시각(41%)에 컷으로 켜고 읽어 내려가는 구간을 34~86% 로 길게 펴 프레임당 변화를 낮추자 대표 이미지가 리본이 올라온 순간으로 바뀌었습니다. 하나 더, 스크롤은 overflow-y: auto 상자가 아니라 그 안의 track 에만 transform 을 겁니다 — 상자 자체에 걸면 스크롤이 통째로 어긋납니다. 덧붙여 미리보기는 2초만 찍히므로 4초짜리 루프를 쓰면 뒷 절반이 영영 안 찍힙니다. 07·08·09 의 루프를 2초로 줄인 이유가 그것입니다.
접근성
zip 을 fuvny8y4 으로 열면 나오는 아홉 벌은 상태를 화면 밖으로도 내보내도록 이미 짜여 있습니다. 실시간 시청자 카운터(01)의 role="status" 문장은 다섯 번에 한 번만 갱신합니다 — 매 틱마다 읽어 주면 낭독기가 쉴 새 없이 떠들기 때문이고, 막대 자체는 transform: scaleY(var(--v)) 라 읽어 줄 것이 없습니다. 리드 마그넷 카드(02)는 입력칸마다 <label for> 를 붙이고 제출 중에는 버튼에 aria-busy 를 걸며, 결과 문장은 role="status" 로 알립니다. 웨비나 회차(03)의 카운트다운은 role="timer" 에 aria-live="off" 라 초마다 방해하지 않고, 마감 회차는 disabled, 고른 회차는 aria-checked 와 좌우 방향키로 옮깁니다. 가격 앵커(04)·이의 해소(05)·보증 배지(06)의 펼침은 전부 aria-expanded 와 grid-template-rows: 0fr → 1fr 한 쌍입니다. 이탈 쿠폰(07)은 role="dialog" aria-modal="true" 로 열리며 열리는 순간 focus({ preventScroll: true }) 로 초점을 받고 Escape 로 닫힙니다. 리본(08)의 닫기 버튼에는 aria-label 이 있고 닫은 사실은 sessionStorage 에 남으며, 최종 CTA(09)는 겉모습만 카드일 뿐 속이 진짜 radio 와 role="radiogroup" 이라 방향키가 저절로 듣습니다.
움직임을 줄여 달라는 설정에서는 아홉 개 모두 prefers-reduced-motion 블록에서 움직임만 끄고 상태는 남깁니다. 배지는 찍히는 대신 그냥 찍힌 채로, 체크는 그려지는 대신 그려진 채로, 리본은 올라오는 대신 올라온 채로 멈춥니다. 선언마다 !important 를 붙인 건 루프용 애니메이션이 나중에 선언돼 그냥 두면 이기기 때문입니다.
같은 랜딩 페이지에 신뢰 요소를 얹는 아홉 개는 믿음 가는 랜딩 페이지 섹션 템플릿 9종에 따로 모아 뒀고, 통째로 쓰는 화면 묶음은 템플릿 카테고리에, 이 코드를 누가 어떻게 검증해 올리는지는 소개에 적어 뒀습니다.
FAQ
이탈 쿠폰 팝업(07)은 모바일에서도 뜨나요?
뜨지 않습니다. 판정 조건이 mouseleave 의 clientY 값이라 커서가 없는 화면에서는 발생할 조건 자체가 없습니다. 모바일에서 같은 자리를 메우려면 뒤로 가기 버튼이나 체류 시간을 조건으로 바꿔야 하고, 그때도 카드를 여닫는 부분과 sessionStorage 기록은 그대로 재사용됩니다.
아홉 개를 한 페이지에 전부 넣어도 되나요?
권하지 않습니다. 붙잡는 장치가 겹치면 이탈 팝업(07)과 리본(08)이 같은 순간에 올라와 서로를 가리고, 방문자는 둘 다 닫아 버립니다. 고개 하나에 섹션 하나씩 맞춰, 길이가 긴 랜딩이면 다섯 개쯤 골라 순서대로 놓는 편이 낫습니다.
리드 마그넷 카드(02)가 실제로 파일을 보내 주나요?
데모가 내주는 건 data: 주소로 만든 짧은 샘플이고 발송 서버는 붙어 있지 않습니다. 검증·잠금·진행 막대·완료 표시까지가 이 섹션이 맡는 몫이라, 실제로 쓸 때는 setTimeout 자리에 서버 호출 한 줄을 넣으면 나머지 흐름은 손대지 않아도 됩니다.