08Scroll-reached CTA ribbon
The moment reading reaches the decisive paragraph a ribbon rises from the bottom edge; closing it leaves a show-again pill in its place so it can come back any time.
Best for
IntersectionObservertranslateYsessionStorage

<main class="stage is-demo" id="stage">
<div class="sr">
<div class="sr__vp" id="sr-vp">
<div class="sr__track" id="sr-track">
<p class="sr__h">How to pick a weekly plan</p>
<p class="sr__p">Start with the smallest plan first.</p>
<p class="sr__p">It takes about two weeks to see how much you actually use.</p>
<p class="sr__p">Upgrading then is soon enough, and you can downgrade in the same place.</p>
<p class="sr__p sr__p--mark" data-mark>You can start here for $0 a month<span class="sr__hl" aria-hidden="true"></span></p>
<p class="sr__p">More details follow in the guide below.</p>
</div>
</div>
<div class="sr__ribbon" id="sr-ribbon">
<span class="sr__ribbon-t">Start from $0/mo</span>
<span class="sr__arrow" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>
</span>
<button type="button" class="sr__close" id="sr-close" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="12" height="12" aria-hidden="true"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z"/></g></svg>
</button>
</div>
<button type="button" class="sr__again" id="sr-again" hidden>Show again</button>
</div>
<p class="sr__msg" role="status" id="sr-msg"></p>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개 — 이 데모의 지속시간·이징·포인트색
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.stage * { box-sizing: border-box; }
// ── 정적 기본 화면: 스크롤 0 · 리본 숨김 ──
.sr { position: relative; overflow: hidden; width: min(360px, 94vw); background: $subject-ink; color: $subject-cream; border-radius: $r-card; box-shadow: $shadow-raised; }
// 스크롤 viewport 와 track 을 나눈다 — transform 은 track 에만 건다
.sr__vp { height: 132px; overflow-y: auto; padding: $sp-3 $sp-4 $sp-6; }
.sr__track { --sc: -140px; }
.sr__h { margin: 0 0 $sp-2; font-weight: 800; font-size: 14px; line-height: 1.3; font-family: inherit; }
.sr__p { margin: 0 0 $sp-3; font-size: 11px; line-height: 1.7; color: rgba(255, 247, 230, .76); font-family: inherit; }
.sr__p--mark { position: relative; display: inline-block; padding: 0 $sp-1; color: $subject-ink; background: $stage-yellow; border-radius: $r-xs; font-weight: 700; }
.sr__hl { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; border-radius: $r-xs; background: $color; transform: scaleX(0); transform-origin: left center; } // 규칙8: 좌변서 그어짐
// ── 리본: 바닥에서 올라온다. 기본은 숨김(translateY) ──
.sr__ribbon { position: absolute; left: 0; right: 0; bottom: 0; display: flex; align-items: center; gap: $sp-2; padding: $sp-2 $sp-3; background: $color; color: $stage-paper; transform: translateY(110%); transition: transform $dur-base $easing; }
.sr__ribbon.is-up { transform: translateY(0); }
.sr__ribbon-t { flex: 1 1 auto; font-weight: 800; font-size: 12px; line-height: 1.2; font-family: inherit; }
.sr__arrow { flex: 0 0 auto; display: grid; place-items: center; }
.sr__close { flex: 0 0 auto; width: 22px; height: 22px; display: grid; place-items: center; border: 0; border-radius: 50%; background: rgba(255, 255, 255, .22); color: $stage-paper; cursor: pointer; }
.sr__again { position: absolute; right: $sp-3; bottom: $sp-3; border: 0; border-radius: $r-pill; padding: $sp-1 $sp-3; background: $subject-cream; color: $subject-ink; font-weight: 700; font-size: 11px; line-height: 1.4; font-family: inherit; cursor: pointer; }
.sr__again[hidden] { display: none; }
.sr__close:focus-visible, .sr__again:focus-visible, .sr__vp:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.sr__msg { margin: $sp-2 0 0; min-height: 13px; text-align: center; font-size: 11px; line-height: 1.3; color: rgba(23, 20, 26, .6); font-family: inherit; }
// ── 측정용 자동 루프: track 만 움직인다(스크롤 상자 자체엔 transform 금지) ──
// 읽어 내려가는 구간은 길게 펴서 프레임당 변한 면적을 낮춘다 — 그래야 대표 이미지가 리본이 올라오는 순간으로 뽑힌다
.is-demo .sr__track { animation: sr-scroll $duration $easing infinite; }
@keyframes sr-scroll {
0% { transform: translateY(0); }
34%, 86% { transform: translateY(var(--sc)); }
96%, 100% { transform: translateY(0); }
}
// 리본은 한 번 올라와 그대로 머문다 — 퇴장은 재현하지 않는다
.is-demo .sr__ribbon { animation: sr-rise $duration $easing 1 both; }
@keyframes sr-rise {
0%, 38% { transform: translateY(110%); }
41%, 100% { transform: translateY(0); }
}
// 안내 문구는 리본이 실제로 올라온 뒤에만 보인다 — 리본 없이 문구만 뜬 장면이 대표 이미지가 되면 거짓이다
.is-demo .sr__msg { animation: sr-msg $duration steps(1, end) 1 both; }
@keyframes sr-msg {
0%, 40% { opacity: 0; }
41%, 100% { opacity: 1; }
}
.is-demo .sr__hl { animation: sr-hl $duration $ease-out infinite; }
@keyframes sr-hl {
0%, 30% { transform: scaleX(0); }
52%, 100% { transform: scaleX(1); }
}
.is-demo .sr__arrow { animation: sr-nudge $duration $easing infinite; }
@keyframes sr-nudge {
0%, 56%, 100% { transform: translateX(0); }
64% { transform: translateX(5px); }
72% { transform: translateX(0); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.sr__ribbon, .is-demo .sr__ribbon, .is-demo .sr__track, .is-demo .sr__hl, .is-demo .sr__arrow, .is-demo .sr__msg {
animation: none !important; transition: none !important; transform: none !important;
}
.sr__ribbon { transform: translateY(0) !important; }
.is-demo .sr__msg { opacity: 1 !important; }
.is-demo .sr__hl { transform: scaleX(1) !important; }
}
// ── 반응형(320×200) ──────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.sr { width: min(288px, 92vw); }
.sr__vp { height: 92px; padding: $sp-2 $sp-3 $sp-5; }
.sr__track { --sc: -96px; }
.sr__h { margin: 0 0 $sp-1; font-size: 11px; }
.sr__p { margin: 0 0 $sp-2; font-size: 9px; line-height: 1.6; }
.sr__ribbon { gap: $sp-1; padding: $sp-1 $sp-2; }
.sr__ribbon-t { font-size: 10px; }
.sr__again { right: $sp-2; bottom: $sp-2; padding: 0 $sp-2; font-size: 9px; }
.sr__msg { margin: $sp-1 0 0; min-height: 0; font-size: 9px; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage * {
box-sizing: border-box;
}
.sr {
position: relative;
overflow: hidden;
width: min(360px, 94vw);
background: #17141a;
color: #fff7e6;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.sr__vp {
height: 132px;
overflow-y: auto;
padding: 12px 16px 24px;
}
.sr__track {
--sc: -140px;
}
.sr__h {
margin: 0 0 8px;
font-weight: 800;
font-size: 14px;
line-height: 1.3;
font-family: inherit;
}
.sr__p {
margin: 0 0 12px;
font-size: 11px;
line-height: 1.7;
color: rgba(255, 247, 230, 0.76);
font-family: inherit;
}
.sr__p--mark {
position: relative;
display: inline-block;
padding: 0 4px;
color: #17141a;
background: #ffd23f;
border-radius: 4px;
font-weight: 700;
}
.sr__hl {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
border-radius: 4px;
background: #2f6df6;
transform: scaleX(0);
transform-origin: left center;
}
.sr__ribbon {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #2f6df6;
color: #ffffff;
transform: translateY(110%);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sr__ribbon.is-up {
transform: translateY(0);
}
.sr__ribbon-t {
flex: 1 1 auto;
font-weight: 800;
font-size: 12px;
line-height: 1.2;
font-family: inherit;
}
.sr__arrow {
flex: 0 0 auto;
display: grid;
place-items: center;
}
.sr__close {
flex: 0 0 auto;
width: 22px;
height: 22px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.22);
color: #ffffff;
cursor: pointer;
}
.sr__again {
position: absolute;
right: 12px;
bottom: 12px;
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
cursor: pointer;
}
.sr__again[hidden] {
display: none;
}
.sr__close:focus-visible, .sr__again:focus-visible, .sr__vp:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.sr__msg {
margin: 8px 0 0;
min-height: 13px;
text-align: center;
font-size: 11px;
line-height: 1.3;
color: rgba(23, 20, 26, 0.6);
font-family: inherit;
}
.is-demo .sr__track {
animation: sr-scroll 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes sr-scroll {
0% {
transform: translateY(0);
}
34%, 86% {
transform: translateY(var(--sc));
}
96%, 100% {
transform: translateY(0);
}
}
.is-demo .sr__ribbon {
animation: sr-rise 2s cubic-bezier(0.2, 0.8, 0.2, 1) 1 both;
}
@keyframes sr-rise {
0%, 38% {
transform: translateY(110%);
}
41%, 100% {
transform: translateY(0);
}
}
.is-demo .sr__msg {
animation: sr-msg 2s steps(1, end) 1 both;
}
@keyframes sr-msg {
0%, 40% {
opacity: 0;
}
41%, 100% {
opacity: 1;
}
}
.is-demo .sr__hl {
animation: sr-hl 2s ease-out infinite;
}
@keyframes sr-hl {
0%, 30% {
transform: scaleX(0);
}
52%, 100% {
transform: scaleX(1);
}
}
.is-demo .sr__arrow {
animation: sr-nudge 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes sr-nudge {
0%, 56%, 100% {
transform: translateX(0);
}
64% {
transform: translateX(5px);
}
72% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.sr__ribbon, .is-demo .sr__ribbon, .is-demo .sr__track, .is-demo .sr__hl, .is-demo .sr__arrow, .is-demo .sr__msg {
animation: none !important;
transition: none !important;
transform: none !important;
}
.sr__ribbon {
transform: translateY(0) !important;
}
.is-demo .sr__msg {
opacity: 1 !important;
}
.is-demo .sr__hl {
transform: scaleX(1) !important;
}
}
@media (max-width: 340px), (max-height: 220px) {
.sr {
width: min(288px, 92vw);
}
.sr__vp {
height: 92px;
padding: 8px 12px 20px;
}
.sr__track {
--sc: -96px;
}
.sr__h {
margin: 0 0 4px;
font-size: 11px;
}
.sr__p {
margin: 0 0 8px;
font-size: 9px;
line-height: 1.6;
}
.sr__ribbon {
gap: 4px;
padding: 4px 8px;
}
.sr__ribbon-t {
font-size: 10px;
}
.sr__again {
right: 8px;
bottom: 8px;
padding: 0 8px;
font-size: 9px;
}
.sr__msg {
margin: 4px 0 0;
min-height: 0;
font-size: 9px;
}
}// 실제 동작: IntersectionObserver 가 스크롤 상자 안 표시 단락(data-mark)을 감시해 60% 이상 보이면 리본이 올라온다. 닫으면 "Show again" 알약이 뜨고, 닫은 사실은 sessionStorage(ribbon271)에 남아 새로 고쳐도 닫힌 채로 시작한다.
var stage = document.getElementById('stage');
var vp = document.getElementById('sr-vp');
var ribbon = document.getElementById('sr-ribbon');
var closeBtn = document.getElementById('sr-close');
var again = document.getElementById('sr-again');
var msg = document.getElementById('sr-msg');
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);
closeBtn.addEventListener('click', function () {
closed = true;
ribbon.classList.remove('is-up');
again.hidden = false;
again.focus({preventScroll:true});
msg.textContent = 'The banner is dismissed';
try { sessionStorage.setItem('ribbon271', 'closed'); } catch (err) {}
});
again.addEventListener('click', function () {
closed = false;
ribbon.classList.add('is-up');
again.hidden = true;
msg.textContent = 'The start banner is back';
try { sessionStorage.removeItem('ribbon271'); } catch (err) {}
});
function human() { stage.classList.remove('is-demo'); }
document.addEventListener('pointerdown', human, { once: true });
document.addEventListener('keydown', human, { once: true });import { useEffect, useRef, useState } from "react";
import styles from "./ScrollCtaRibbon.module.scss";
// 이벤트·ref 타입은 이 파일 안에서 최소한으로 선언한다
type Box<T> = { current: T | null };
type Entry = { isIntersecting: boolean };
export type ScrollCtaRibbonProps = { duration?: number; easing?: string; color?: string };
type Vars = { "--duration": string; "--easing": string; "--color": string };
export default function ScrollCtaRibbon({
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ScrollCtaRibbonProps) {
const [demo, setDemo] = useState(true);
const [up, setUp] = useState(false);
const [closed, setClosed] = useState(false);
const [msg, setMsg] = useState("");
const vpRef: Box<HTMLDivElement> = useRef<HTMLDivElement>(null);
const markRef: Box<HTMLParagraphElement> = useRef<HTMLParagraphElement>(null);
const againRef: Box<HTMLButtonElement> = useRef<HTMLButtonElement>(null);
const vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as Vars;
useEffect(() => {
let wasClosed = false;
try { wasClosed = sessionStorage.getItem("ribbon271") === "closed"; } catch (err) {}
if (wasClosed) { setClosed(true); setMsg("리본을 닫아 뒀어요"); }
}, []);
useEffect(() => {
const vp = vpRef.current;
const mark = markRef.current;
if (!vp || !mark) return;
const io = new IntersectionObserver((entries: Entry[]) => {
if (entries[0].isIntersecting && !closed) {
setUp(true);
setMsg("시작 안내가 올라왔어요");
}
}, { root: vp, threshold: 0.6 });
io.observe(mark);
return () => io.disconnect();
}, [closed]);
useEffect(() => {
const off = () => setDemo(false);
window.addEventListener("pointerdown", off, { once: true });
window.addEventListener("keydown", off, { once: true });
return () => {
window.removeEventListener("pointerdown", off);
window.removeEventListener("keydown", off);
};
}, []);
function close() {
setClosed(true);
setUp(false);
setMsg("리본을 닫아 뒀어요");
try { sessionStorage.setItem("ribbon271", "closed"); } catch (err) {}
if (againRef.current) againRef.current.focus({ preventScroll: true });
}
function again() {
setClosed(false);
setUp(true);
setMsg("시작 안내를 다시 불렀어요");
try { sessionStorage.removeItem("ribbon271"); } catch (err) {}
}
return (
<main className={`${styles.stage} ${demo ? styles.demo : ""}`} style={vars}>
<div className={styles.sr}>
<div className={styles.vp} ref={vpRef}>
<div className={styles.track}>
<p className={styles.h}>주간 요금제를 고르는 법</p>
<p className={styles.p}>처음에는 가장 작은 요금제로 시작하는 편이 좋아요.</p>
<p className={styles.p}>쓰는 양은 두 주쯤 지나야 눈에 보이거든요.</p>
<p className={styles.p}>그때 올려도 늦지 않고, 내리는 것도 같은 자리에서 돼요.</p>
<p className={`${styles.p} ${styles.mark}`} ref={markRef} data-mark>
여기서 월 ₩0으로 시작할 수 있어요
<span className={styles.hl} aria-hidden="true" />
</p>
<p className={styles.p}>더 궁금한 점은 아래 안내에서 이어집니다.</p>
</div>
</div>
<div className={`${styles.ribbon} ${up ? styles.up : ""}`}>
<span className={styles.ribbonT}>월 ₩0부터 시작하기</span>
<span className={styles.arrow} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2" /><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z" /></g></svg>
</span>
<button type="button" className={styles.close} aria-label="닫기" onClick={close}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="12" height="12" aria-hidden="true"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2" /><path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z" /></g></svg>
</button>
</div>
<button type="button" className={styles.again} ref={againRef} hidden={!closed} onClick={again}>다시 보기</button>
</div>
<p className={styles.msg} role="status">{msg}</p>
</main>
);
}08 Scroll-reached CTA ribbon — Measured area 21.055% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · Landing Page Conversion: 9 Sections That Convert
The post this demo lives in
Landing Page Conversion: 9 Sections That ConvertLanding page conversion is the share of visitors who leave an email, book a slot, or buy, and this post hands you the ni See all demos in this post
If you like this effect - similar demos
Landing sections01 Split product heroHero Section Design: 9 Patterns, No JS Libraries
Landing sections06 Video card — a card that derives playback from viewport ratio and scrub position from scroll9 Case Study Page Design Sections (Copy-Paste)
Landing sections01 Hero — an intro where background and heading shift at different scroll ratiosHero Section Design Examples: 9 Patterns