액션 시트

Action Sheet

버튼 하나를 누르면 화면 아래에서 관련 동작 목록과 취소 버튼이 카드로 슬라이드 업 하는 임시 메뉴.

다른 이름: Bottom action sheetiOS 액션 시트
···
html
<div class="stage"><div class="phone"><div class="notch"></div><div class="screen" id="screen">
<div class="ascreen"><div class="topbar2">Photo</div><button class="trigger" id="trigger">Share &#8599;</button></div>
<div class="scrim" id="scrim"></div>
<div class="asheet" id="asheet">
  <div class="asgroup"><div class="asitem">Save Image</div><div class="asitem">Copy</div><div class="asitem del">Delete</div></div>
  <div class="ascancel" id="ascancel">Cancel</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}

.ascreen{position:absolute;inset:0;padding:10% 8%}
.topbar2{font-weight:800;font-size:clamp(8px,3vmin,12px);margin-bottom:8%}
.trigger{padding:3% 6%;border:1px solid var(--line);border-radius:999px;background:var(--surface);
  font-size:clamp(6.5px,2.4vmin,9px);font-weight:700;color:var(--accent)}
.scrim{position:absolute;inset:0;background:#000;opacity:0;transition:opacity .25s;pointer-events:none;z-index:10}
.scrim.show{opacity:.35;pointer-events:auto}
.asheet{position:absolute;left:3%;right:3%;bottom:3%;display:grid;gap:5%;transform:translateY(140%);
  transition:transform .32s cubic-bezier(.2,.8,.2,1);z-index:20}
.asheet.show{transform:translateY(0)}
.asgroup{background:var(--surface);border-radius:12px;overflow:hidden;box-shadow:0 4px 14px rgba(0,0,0,.14)}
.asitem{padding:8% 0;text-align:center;font-size:clamp(6.5px,2.4vmin,9px);font-weight:700;border-bottom:1px solid var(--line)}
.asitem:last-child{border-bottom:none}
.asitem.del{color:#e5534b}
.ascancel{background:var(--surface);border-radius:12px;padding:8% 0;text-align:center;font-weight:800;
  font-size:clamp(6.5px,2.4vmin,9px);box-shadow:0 4px 14px rgba(0,0,0,.14)}
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 trigger=document.getElementById('trigger'), scrim=document.getElementById('scrim'), asheet=document.getElementById('asheet'), cancel=document.getElementById('ascancel');
function open(){ scrim.classList.add('show'); asheet.classList.add('show'); }
function close(){ scrim.classList.remove('show'); asheet.classList.remove('show'); }
trigger.addEventListener('pointerdown',()=>{ pfxAuto=false; pfxHide(); open(); });
scrim.addEventListener('pointerdown',close);
cancel.addEventListener('pointerdown',()=>{ pfxAuto=false; pfxHide(); close(); });
function fracOf(el){ const r=el.getBoundingClientRect(), sr=screenEl.getBoundingClientRect();
  return {x:(r.left+r.width/2-sr.left)/sr.width,y:(r.top+r.height/2-sr.top)/sr.height}; }
async function autoSeq(){
  while(pfxAuto){
    close();
    let f=fracOf(trigger);
    await pfxWalk(f.x,f.y,f.x,f.y,400); if(!pfxAuto)break;
    open();
    await pfxWait(1300); if(!pfxAuto)break;
    f=fracOf(cancel);
    await pfxWalk(0.5,0.15,f.x,f.y,350); if(!pfxAuto)break;
    close();
    await pfxWait(950);
  }
}
autoSeq();

바텀 시트와 코드 구조(오버레이 + 아래에서 올라오는 패널)는 비슷하지만 상태가 열림·닫힘 둘뿐이라 훨씬 단순하다 — 드래그로 높이를 조절하는 디텐트 개념 자체가 없고, 배경(scrim)을 탭하거나 항목을 고르면 곧장 닫힌다.

iOS 관례는 되돌릴 수 있는 일반 동작과 파괴적 동작(삭제 등, 빨간 글씨)을 같은 카드 안에 두되 "취소"만 시각적으로 살짝 떨어뜨려 별도 카드로 둔다 — 실수로 다른 항목과 헷갈려 취소를 잘못 누를 확률을 줄이는 배치다. Android Material은 대체로 이 간격 규칙 없이 바텀 시트 메뉴 하나로 통일해서 쓴다.

옵션이 5개를 넘어가면 한 화면 안에서 스크롤이 필요해지고, 그럴 땐 액션 시트보다 전체 화면 메뉴나 별도 화면으로 옮기는 편이 낫다.

언제 쓰나

트리거 하나에 딸린 2~5개의 명확한 선택지(공유 대상, 정렬 기준 등)에 씁니다. 값을 직접 입력받아야 하면 다이얼로그를, 새 화면으로 이동해야 하면 내비게이션을 씁니다.