라지 타이틀

Large Title

화면 맨 위에 크게 떠 있던 제목이 아래로 스크롤할수록 작아지며 상단 내비게이션 바 안으로 들어가는 패턴.

다른 이름: Collapsing titleCollapsing toolbar
···
html
<div class="stage"><div class="phone"><div class="notch"></div><div class="screen" id="screen">
<div class="navbar"><span id="smallTitle" class="smallTitle">Inbox</span></div>
<div class="scroller" id="scroller">
  <h1 id="bigTitle" class="bigTitle">Inbox</h1>
  <div class="item"></div><div class="item"></div><div class="item"></div>
  <div class="item"></div><div class="item"></div><div class="item"></div><div class="item"></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}

.navbar{position:absolute;top:0;left:0;right:0;height:11%;display:grid;place-items:center;
  background:var(--surface);border-bottom:1px solid var(--line);z-index:10}
.smallTitle{font-weight:700;font-size:clamp(7px,2.4vmin,10px);opacity:0}
.scroller{position:absolute;inset:11% 0 0;overflow-y:auto;padding:2% 7% 8%}
.bigTitle{margin:6% 0;font-weight:800;font-size:clamp(14px,7.4vmin,24px);transform-origin:left center}
.item{height:clamp(16px,10vmin,28px);border-radius:8px;background:var(--line);margin-bottom:6%}
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 scroller=document.getElementById('scroller'), big=document.getElementById('bigTitle'), small=document.getElementById('smallTitle');
const THRESH=38;
function apply(scrollTop){
  const p=Math.min(1,Math.max(0,scrollTop/THRESH));
  big.style.opacity=String(1-p); big.style.transform='scale('+(1-p*0.35)+')';
  small.style.opacity=String(p);
}
scroller.addEventListener('scroll',()=>apply(scroller.scrollTop));
async function autoSeq(){
  while(pfxAuto){
    const max=scroller.scrollHeight-scroller.clientHeight;
    const start=performance.now(), dur=1400;
    while(pfxAuto){
      const t=(performance.now()-start)/dur;
      if(t>=1) break;
      const st=(1-Math.cos(t*Math.PI))/2*max;
      scroller.scrollTop=st; apply(st);
      pfxAt(0.5, 0.15+((1-Math.cos(t*Math.PI))/2)*0.65);
      await new Promise(res=>requestAnimationFrame(res));
    }
    if(!pfxAuto)break;
    await pfxWait(500);
  }
}
autoSeq();

스크롤 컨테이너의 `scrollTop`을 매 프레임(또는 `scroll` 이벤트마다) 읽어서 0~threshold(보통 40~56px) 구간에 매핑한 진행률 `p`를 만든다. 그 `p`로 큰 제목의 `font-size`·`opacity`를 줄이고, 작은 제목(내비게이션 바 안의 타이틀)의 `opacity`를 반대로 키운다.

핵심은 두 타이틀을 **같은 텍스트, 다른 두 요소**로 따로 두는 것이다 — 큰 제목은 콘텐츠와 함께 스크롤되는 레이아웃 안에 있고, 작은 제목은 상단에 고정된 내비게이션 바 안에 있다. `font-size`를 직접 스크롤에 맞춰 보간하는 대신 두 요소를 크로스페이드하면 폰트 크기 변화로 인한 레이아웃 리플로우 비용을 피할 수 있다.

iOS의 `UINavigationBar` `prefersLargeTitles`가 정본 구현이고, Android는 `CollapsingToolbarLayout`(Material) 이 같은 역할을 한다. 두 플랫폼 모두 "탐색 중에도 지금 어느 화면인지"를 큰 글자로 먼저 보여주고, 다음 화면으로 이동할 목적(스크롤)이 시작되면 공간을 내주는 우선순위가 같다.

언제 쓰나

메일함, 설정처럼 최상위 탭 화면의 첫 진입 지점에 씁니다. 깊이 들어간 상세 화면에는 처음부터 작은 타이틀만 쓰는 것이 일반적입니다.