키보드 어보이던스

Keyboard Avoidance

소프트 키보드가 올라올 때 지금 입력 중인 필드가 가려지지 않도록 화면 내용을 밀어 올리거나 줄이는 처리.

다른 이름: KeyboardAvoidingView키보드 회피
···
html
<div class="stage"><div class="phone"><div class="notch"></div><div class="screen" id="screen">
<div class="kbcontent" id="kbcontent">
  <div class="msg left"></div><div class="msg right"></div><div class="msg left"></div>
</div>
<div class="inputrow" id="inputrow">
  <div class="inputfake" id="inputfake">Message...</div><div class="sendbtn">&#8593;</div>
</div>
<div class="keyboard" id="keyboard"></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}

.kbcontent{position:absolute;top:0;left:0;right:0;bottom:16%;overflow:hidden;padding:6% 7%;
  display:grid;align-content:end;gap:5%;transition:bottom .3s cubic-bezier(.2,.8,.2,1)}
.msg{max-width:64%;height:clamp(10px,7vmin,18px);border-radius:10px;background:var(--line)}
.msg.right{justify-self:end;background:color-mix(in srgb,var(--accent) 30%,var(--line))}
.inputrow{position:absolute;left:0;right:0;bottom:3%;height:11%;display:flex;align-items:center;gap:4%;
  padding:0 6%;transition:bottom .3s cubic-bezier(.2,.8,.2,1);z-index:10}
.inputfake{flex:1;height:78%;border-radius:999px;border:1px solid var(--line);display:flex;align-items:center;
  padding:0 6%;font-size:clamp(6px,2.2vmin,8.5px);color:var(--muted)}
.sendbtn{width:16%;aspect-ratio:1;border-radius:50%;background:var(--accent);color:#fff;
  display:grid;place-items:center;font-size:clamp(7px,2.6vmin,10px);flex:none}
.keyboard{position:absolute;left:0;right:0;bottom:0;height:42%;background:var(--line);
  border-radius:8% 8% 0 0;transform:translateY(100%);transition:transform .3s cubic-bezier(.2,.8,.2,1);z-index:5}
.keyboard.show{transform:translateY(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 inputfake=document.getElementById('inputfake'), inputrow=document.getElementById('inputrow'),
  keyboard=document.getElementById('keyboard'), kbcontent=document.getElementById('kbcontent');
function setKb(show){
  keyboard.classList.toggle('show',show);
  inputrow.style.bottom= show?'45%':'3%';
  kbcontent.style.bottom= show?'58%':'16%';
}
inputfake.addEventListener('pointerdown',()=>{ pfxAuto=false; pfxHide(); setKb(true); });
kbcontent.addEventListener('pointerdown',()=>{ pfxAuto=false; pfxHide(); setKb(false); });
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){
    setKb(false);
    let f=fracOf(inputfake);
    await pfxWalk(f.x,f.y,f.x,f.y,400); if(!pfxAuto)break;
    setKb(true);
    await pfxWait(1100); if(!pfxAuto)break;
    await pfxWalk(f.x,f.y,0.5,0.3,300); if(!pfxAuto)break;
    setKb(false);
    await pfxWait(700);
  }
}
autoSeq();

웹에서는 키보드가 열리면 `visualViewport`의 `height`가 줄어드는 `resize` 이벤트로 감지할 수 있다. 네이티브는 두 방식으로 나뉜다 — Android의 `windowSoftInputMode="adjustResize"`처럼 레이아웃 자체를 남은 높이에 맞춰 다시 그리거나(콘텐츠 영역이 줄어든다), `adjustPan`이나 React Native `KeyboardAvoidingView`의 `behavior="padding"`처럼 포커스된 입력 필드가 있는 자리로 화면을 밀어 올린다(레이아웃 자체는 그대로).

어느 쪽이든 원칙은 같다 — 키보드가 콘텐츠를 그냥 덮어버리게 두지 않고, 지금 타이핑 중인 자리가 항상 키보드 위에 떠 있게 만든다. 리스트 화면이라면 입력창 하나만 밀어 올리는 게 아니라 스크롤 가능 영역의 높이 자체를 줄여야, 키보드를 닫을 때까지 나머지 메시지를 스크롤해서 볼 수 있다.

흔한 사고 지점은 화면에 고정된 다른 요소들이다 — 하단에 고정해둔 CTA나 탭 바가 리사이즈 이벤트를 듣지 않으면 키보드가 그 위를 그대로 덮어버려, 정작 필요한 순간에 눌리지 않는 버튼이 된다.

언제 쓰나

로그인, 채팅 작성창, 댓글처럼 화면 아래쪽에 텍스트 입력이 있는 모든 화면에 씁니다. 화면 세로 길이가 가장 짧은 기종에서 먼저 검증하는 것이 안전합니다.