스티키 바텀 CTA

Sticky Bottom CTA

콘텐츠가 아무리 길어도 "장바구니 담기" 같은 핵심 버튼만은 화면 하단에 항상 떠 있도록 고정하는 패턴.

다른 이름: Pinned CTA고정 하단 버튼
···
html
<div class="stage"><div class="phone"><div class="notch"></div><div class="screen" id="screen">
<div class="pdcontent" id="pdcontent">
  <div class="hero4"></div>
  <div class="bar w80"></div><div class="bar w60"></div><div class="bar w90"></div><div class="bar w70"></div>
  <div class="bar w85"></div><div class="bar w55"></div><div class="bar w75"></div>
</div>
<div class="ctawrap"><button class="ctabtn">Add to Cart — $24</button></div></div><div class="home"></div></div></div>
css
.stage{position:absolute;inset:0;display:grid;place-items:center;overflow:hidden}
.phone{position:relative;height:90vmin;max-height:640px;aspect-ratio:9/19.5;background:#08080c;
  border-radius:20%/8.6%;box-shadow:0 0 0 2px rgba(255,255,255,.08) inset,0 12px 30px rgba(0,0,0,.35)}
.phone .notch{position:absolute;left:50%;top:0;transform:translateX(-50%);width:38%;height:5.6%;
  background:#08080c;border-radius:0 0 46% 46%;z-index:80}
.phone .screen{position:absolute;top:7.6%;left:3%;right:3%;bottom:3%;border-radius:11%/4.6%;overflow:hidden;
  background:var(--surface);color:var(--fg)}
.phone .home{position:absolute;left:50%;bottom:1.1%;transform:translateX(-50%);width:32%;height:3px;
  border-radius:2px;background:rgba(255,255,255,.45);z-index:80}
.pfx{position:absolute;left:0;top:0;width:16px;height:16px;margin:-8px 0 0 -8px;border-radius:50%;
  background:color-mix(in srgb,var(--accent) 55%,transparent);
  box-shadow:0 0 0 6px color-mix(in srgb,var(--accent) 15%,transparent);pointer-events:none;z-index:999;
  transition:opacity .2s}
.pfx.hold{width:30px;height:30px;margin:-15px 0 0 -15px;
  box-shadow:0 0 0 9px color-mix(in srgb,var(--accent) 20%,transparent);
  transition:width .5s linear,height .5s linear,margin .5s linear,box-shadow .5s linear,opacity .2s}

.pdcontent{position:absolute;inset:0 0 17%;overflow-y:auto;padding:6% 7%}
.hero4{height:clamp(30px,20vmin,52px);border-radius:10%;background:var(--accent);opacity:.22;margin-bottom:8%}
.bar{height:clamp(3px,2.2vmin,6px);border-radius:3px;background:var(--line);margin-bottom:7%}
.w80{width:80%}.w60{width:60%}.w90{width:90%}.w70{width:70%}.w85{width:85%}.w55{width:55%}.w75{width:75%}
.ctawrap{position:absolute;left:0;right:0;bottom:0;height:17%;background:linear-gradient(to top,var(--surface) 62%,transparent);
  display:grid;align-items:end;padding:0 6% 6%;z-index:10}
.ctabtn{border:none;border-radius:999px;background:var(--accent);color:#fff;font-weight:800;
  font-size:clamp(6.5px,2.4vmin,9.5px);padding:4.5% 0}
js
const pfx=document.createElement('div');pfx.className='pfx';document.body.appendChild(pfx);
const screenEl=document.getElementById('screen');
let pfxAuto=true;
function pfxHide(){pfx.style.opacity='0';}
addEventListener('pointerdown',()=>{pfxAuto=false;pfxHide();},{capture:true});
function pfxAt(fx,fy){
  const r=screenEl.getBoundingClientRect();
  const x=r.left+r.width*fx, y=r.top+r.height*fy;
  pfx.style.transform='translate('+x+'px,'+y+'px)';
  return {x,y};
}
function pfxWalk(x0,y0,x1,y1,ms){
  return new Promise(res=>{
    const start=performance.now();
    function step(now){
      if(!pfxAuto) return res();
      const p=Math.min(1,(now-start)/ms), e=1-Math.pow(1-p,3);
      pfxAt(x0+(x1-x0)*e, y0+(y1-y0)*e);
      if(p<1) requestAnimationFrame(step); else res();
    }
    requestAnimationFrame(step);
  });
}
function pfxWait(ms){
  return new Promise(res=>{
    const start=performance.now();
    function step(now){ if(!pfxAuto) return res(); if(now-start<ms) requestAnimationFrame(step); else res(); }
    requestAnimationFrame(step);
  });
}

const content=document.getElementById('pdcontent');
async function autoSeq(){
  while(pfxAuto){
    const max=content.scrollHeight-content.clientHeight;
    let start=performance.now();
    while(pfxAuto){
      const t=(performance.now()-start)/1300; if(t>=1)break;
      const e=(1-Math.cos(t*Math.PI))/2, st=e*max;
      content.scrollTop=st; pfxAt(0.5,0.15+e*0.55);
      await new Promise(r=>requestAnimationFrame(r));
    }
    if(!pfxAuto)break; await pfxWait(500); if(!pfxAuto)break;
    start=performance.now();
    while(pfxAuto){
      const t=(performance.now()-start)/1100; if(t>=1)break;
      const st=max*(1-t);
      content.scrollTop=st; pfxAt(0.5,0.7-t*0.55);
      await new Promise(r=>requestAnimationFrame(r));
    }
    if(!pfxAuto)break; content.scrollTop=0;
    await pfxWait(500);
  }
}
autoSeq();

버튼을 문서 흐름 밖으로 빼서 화면(뷰포트) 기준으로 고정한다. 네이티브에서는 스크롤 컨테이너 밖, 화면 레이아웃에 직접 얹고, 웹에서는 `position: fixed`(또는 앱 내부 스크롤 컨테이너 기준 `sticky`)를 쓴다. 스크롤이 아무리 내려가도 버튼의 화면 좌표는 바뀌지 않는다.

여기서 가장 흔히 빠지는 함정은 콘텐츠 쪽이다 — CTA가 콘텐츠 위에 떠 있는 별도 레이어이기 때문에, 스크롤 영역 맨 아래에 CTA 높이만큼 `padding-bottom`을 더해주지 않으면 마지막 문단이나 버튼이 CTA 뒤에 가려 영영 눌리지 않는다.

또 하나는 세이프 에어리어다. 홈 인디케이터가 있는 기기에서 CTA를 화면 맨 밑에 딱 붙이면 제스처 영역과 겹치므로, `padding-bottom: max(16px, env(safe-area-inset-bottom))`처럼 인셋을 더해 버튼을 살짝 띄워야 한다.

언제 쓰나

상품 상세, 결제, 지원서처럼 스크롤 길이는 예측할 수 없지만 핵심 행동 하나는 항상 손에 닿아야 하는 화면에 씁니다. 스티키 CTA를 여러 개 겹쳐 쌓지 않습니다.