플로팅 서치

Floating Search

콘텐츠 위에 떠 있는 둥근 검색창이 아래로 스크롤하면 작은 아이콘으로 접히고, 위로 스크롤하면 다시 펼쳐지는 패턴.

다른 이름: Docked search bar떠 있는 검색바
···
html
<div class="stage"><div class="phone"><div class="notch"></div><div class="screen" id="screen">
<div class="fscontent" id="fscontent">
  <div class="fsrow"></div><div class="fsrow"></div><div class="fsrow"></div><div class="fsrow"></div>
  <div class="fsrow"></div><div class="fsrow"></div><div class="fsrow"></div><div class="fsrow"></div>
</div>
<div class="fsbar" id="fsbar"><span class="fsicon"></span><span class="fslabel">Search places</span></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}

.fscontent{position:absolute;inset:0;overflow-y:auto;padding:22% 6% 6%}
.fsrow{height:clamp(18px,11vmin,30px);border-radius:10px;background:var(--line);margin-bottom:6%}
.fsbar{position:absolute;left:6%;top:6%;right:6%;height:11%;border-radius:999px;background:var(--surface);
  box-shadow:0 6px 16px rgba(0,0,0,.16);display:flex;align-items:center;gap:8%;padding:0 6%;
  transition:width .3s,right .3s,padding .3s;overflow:hidden;z-index:10}
.fsbar.collapsed{width:11%;right:auto;padding:0;justify-content:center}
.fsicon{width:clamp(6px,3vmin,9px);height:clamp(6px,3vmin,9px);border-radius:50%;border:1.6px solid var(--muted);flex:none}
.fslabel{font-size:clamp(6px,2.2vmin,8.5px);color:var(--muted);font-weight:600;white-space:nowrap;transition:opacity .2s}
.fsbar.collapsed .fslabel{opacity: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('fscontent'), bar=document.getElementById('fsbar');
let lastTop=0;
function onScroll(top){
  if(top<=4) bar.classList.remove('collapsed');
  else if(top>lastTop+2) bar.classList.add('collapsed');
  else if(top<lastTop-2) bar.classList.remove('collapsed');
  lastTop=top;
}
content.addEventListener('scroll',()=>onScroll(content.scrollTop));
async function autoSeq(){
  while(pfxAuto){
    const max=content.scrollHeight-content.clientHeight;
    let start=performance.now();
    while(pfxAuto){ const t=(performance.now()-start)/1100; if(t>=1)break; const st=t*max; content.scrollTop=st; onScroll(st); pfxAt(0.5,0.15+t*0.7); 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)/900; if(t>=1)break; const st=max*(1-t); content.scrollTop=st; onScroll(st); pfxAt(0.5,0.85-t*0.7); await new Promise(r=>requestAnimationFrame(r)); }
    if(!pfxAuto)break; content.scrollTop=0; onScroll(0);
    await pfxWait(600);
  }
}
autoSeq();

스크롤 위치가 아니라 스크롤 방향으로 접고 펼친다 — 이전 프레임과 지금 프레임의 `scrollTop` 차이가 양수(아래로)면 검색창을 아이콘 크기로 접고, 음수(위로)거나 맨 위 근처면 다시 펼친다. 헤더를 통째로 숨기는 스크롤 패턴과 달리, 검색이라는 핵심 기능은 아이콘 형태로라도 화면에서 완전히 사라지지 않는다.

레이아웃 흐름에 끼어 있는 스티키 헤더와 달리 이 검색창은 콘텐츠 위에 떠 있는 별도 레이어라 콘텐츠를 밀어내지 않는다. 구글 지도·구글 앱의 검색창이 이 패턴의 표준 사례로 꼽힌다.

접힌 아이콘 상태에서도 탭하면 즉시 펼쳐지고 포커스가 입력창으로 가야 한다 — 다시 스크롤을 맨 위로 올려야만 검색할 수 있게 만들면 접었다 펴는 의미가 없다.

언제 쓰나

지도·피드처럼 콘텐츠가 화면 전체를 채우는 스크롤 화면에서, 검색을 늘 손닿는 곳에 두되 헤더 높이를 항상 차지하게 하고 싶지 않을 때 씁니다.