바텀 시트

Bottom Sheet

화면 아래에서 올라와 일부만 덮는 패널. peek·half·full 같은 정해진 높이(디텐트) 사이를 드래그로 오갑니다.

다른 이름: Modal bottom sheet디텐트
···
html
<div class="stage"><div class="phone"><div class="notch"></div><div class="screen" id="screen">
<div class="bg">
  <div class="bar w80"></div><div class="bar w60"></div><div class="bar w70"></div>
  <div class="bar w50"></div><div class="bar w65"></div><div class="bar w55"></div>
</div>
<div class="dim" id="dim"></div>
<div class="sheet" id="sheet">
  <div class="handle" id="handle"></div>
  <div class="row">Share</div><div class="row">Save to Photos</div><div class="row">Copy Link</div>
</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}

.bg{position:absolute;inset:0;padding:14% 8% 0;display:grid;grid-auto-rows:clamp(3px,2.8vmin,7px);gap:7%}
.bar{border-radius:3px;background:var(--line)}
.w80{width:80%}.w60{width:60%}.w70{width:70%}.w50{width:50%}.w65{width:65%}.w55{width:55%}
.dim{position:absolute;inset:0;background:#000;opacity:0;pointer-events:none}
.sheet{position:absolute;left:0;right:0;bottom:0;height:84%;background:var(--surface);
  border-radius:14% 14% 0 0;box-shadow:0 -6px 16px rgba(0,0,0,.18);
  transform:translateY(calc(100% - var(--reveal,50px)));touch-action:none;padding-top:9px}
.handle{width:16%;height:4px;border-radius:2px;background:var(--line);margin:0 auto 8px}
.row{margin:0 7%;padding:7% 3%;border-top:1px solid var(--line);font-size:clamp(6.5px,2.5vmin,9.5px);font-weight:600}
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 sheet=document.getElementById('sheet'), handle=document.getElementById('handle'), dim=document.getElementById('dim');
function screenH(){ return screenEl.getBoundingClientRect().height; }
function detents(){ const h=screenH(); return {peek:h*0.16,half:h*0.5,full:h*0.82}; }
function setReveal(px){ sheet.style.setProperty('--reveal',px+'px'); dim.style.opacity=String(Math.min(.4,(px/screenH())*.5)); }
let dragging=false,startY=0,startReveal=0;
handle.addEventListener('pointerdown',e=>{
  pfxAuto=false;pfxHide();dragging=true;startY=e.clientY;
  startReveal=parseFloat(getComputedStyle(sheet).getPropertyValue('--reveal'))||detents().peek;
  sheet.style.transition='none';handle.setPointerCapture(e.pointerId);
});
addEventListener('pointermove',e=>{ if(!dragging)return; setReveal(Math.max(30,startReveal+(startY-e.clientY))); });
addEventListener('pointerup',()=>{
  if(!dragging)return; dragging=false;
  sheet.style.transition='transform .35s cubic-bezier(.2,.8,.2,1)';
  const cur=parseFloat(sheet.style.getPropertyValue('--reveal')), d=detents();
  setReveal(Object.values(d).reduce((a,b)=>Math.abs(b-cur)<Math.abs(a-cur)?b:a));
});
async function autoSeq(){
  const seq=['peek','half','full','half'];
  setReveal(detents().peek);
  while(pfxAuto){
    for(const key of seq){
      if(!pfxAuto)break;
      const d=detents(), fy=Math.max(0.05,1-d[key]/screenH());
      await pfxWalk(0.5,fy,0.5,fy,420);
      sheet.style.transition='transform .4s cubic-bezier(.2,.8,.2,1)';
      setReveal(d[key]);
      await pfxWait(650);
    }
  }
}
autoSeq();

시트 전체 높이는 고정해두고, `transform: translateY(calc(100% - var(--reveal)))`로 얼마나 드러날지(`--reveal`)만 바꾼다. 드래그 중에는 `--reveal`을 손가락 이동량만큼 그대로 갱신하고, 손을 떼는 순간 가장 가까운 디텐트(peek/half/full)로 스냅시킨다.

iOS는 이 패턴을 시트(sheet)라 부르고 `UISheetPresentationController`의 `detents`로 관리하며, Android는 "모달 바텀 시트"(Modal Bottom Sheet)라 부르고 `BottomSheetBehavior`의 `state`(collapsed/half-expanded/expanded)가 같은 역할을 한다. 이름은 달라도 "일부만 덮고, 손가락으로 높이를 바꾼다"는 개념은 동일하다.

뒤 화면을 완전히 가리지 않기 때문에 `peek` 상태에서도 맥락(지도, 리스트 등)이 계속 보여야 한다. 드러난 비율에 비례해 배경을 어둡게 하는 딤(dim) 처리를 더하면 지금 시트가 얼마나 화면을 차지하는지 직관적으로 전달된다.

언제 쓰나

지도 위 장소 상세, 필터, 공유 시트처럼 "본 화면을 유지한 채 보조 작업"을 할 때 씁니다. 전체 화면 전환이 필요하면 모달이나 새 화면이 낫습니다.