Floating Search

플로팅 서치

A rounded search bar floating over content that shrinks to an icon when you scroll down, and expands back when you scroll up.

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

It collapses and expands based on scroll direction, not position — if this frame's `scrollTop` is greater than the last frame's (scrolling down), shrink the bar to an icon; if it's less, or near the top, expand it back. Unlike a pattern that hides the whole header, the one core action — search — never fully disappears, it just shrinks to an icon.

Unlike a sticky header baked into the layout flow, this bar floats as a separate layer above the content and never pushes it down. Google Maps and the Google app's search bar are the textbook reference for this pattern.

Tapping the collapsed icon should instantly expand it and focus the input — if searching only worked after scrolling all the way back to the top, collapsing it in the first place would defeat the purpose.

When to use

Use it on content-filling scroll screens — maps, feeds — where search should stay within reach without permanently claiming header height.