Sticky Bottom CTA

스티키 바텀 CTA

Pin the one primary action — "Add to Cart" — to the bottom of the screen so it stays put no matter how long the content above it scrolls.

Also known as: 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();

Take the button out of document flow and pin it to the viewport instead. Natively, it sits outside the scroll container, laid directly on the screen; on the web that's `position: fixed` (or `sticky` relative to an app-internal scroll container). No matter how far the content scrolls, the button's screen coordinates never move.

The most common trap is on the content side — since the CTA is a separate layer floating over the content, forgetting to add `padding-bottom` equal to the CTA's height at the end of the scroll area means the last paragraph or button ends up permanently hidden behind it, unreachable.

The other is the safe area. On a device with a home indicator, pinning the CTA flush to the very bottom puts it right in the gesture zone, so it needs inset padding like `padding-bottom: max(16px, env(safe-area-inset-bottom))` to float just above it.

When to use

Use it on screens with unpredictable scroll length but one action that must always stay reachable — product detail, checkout, an application form. Avoid stacking more than one sticky CTA.