화면을 한 구간씩 넘기는 구성은 내용의 장면을 나누는 데 쓸 수 있다. 하지만 방문자가 무엇을 지나왔고 어디에 있는지 알 수 있어야 한다. 자료가 빠진 뒤 고정 번호를 계속 쓰거나 긴 내용을 화면 높이 안에 잘라 넣으면 이동과 읽기가 서로 어긋날 수 있다. 나는 구간을 보여주는 효과와 구간을 찾는 기능을 함께 확인한다.

이전 수정 작업에는 원래 전체 화면 이동과 슬라이더, 등장 동작을 보존하는 계약이 있었다. 현재 교체 엔진의 어댑터는 남은 구간을 원래 인덱스로 바꾸고 없는 구간을 다음 남은 대상으로 이어준다. 여기서는 그 원리를 설명하고 별도의 새 메뉴 예시를 구현했다. 원본 화면의 모든 동작을 재현했다고 말하지 않는다.

원래 동작을 유지하는 수정과 새 모듈은 구별한다

기존 화면의 구성을 유지하라는 작업에서 전체 스크롤 엔진을 임의로 바꾸면 색상이나 자료 교체보다 큰 변경이 된다. 확인한 인수인계에는 원래 레이아웃과 전체 화면 이동, 슬라이더와 등장 효과를 유지하도록 적혀 있다. 교체 템플릿에도 원본의 크기와 시점, 동작을 보존하는 범위가 있다.

아래 코드는 원본 엔진을 수정하는 패치가 아니라 이번 글을 위해 만든 작은 구현이다. 별도의 스크롤 영역에 구간과 메뉴를 연결한다. 기존 사이트에 적용할 때는 원래 동작 계약과 연결 API에 맞춰 어댑터를 사용해야 한다. 새 예시가 좋아 보인다는 이유로 보존해야 할 동작까지 바꾸지 않는다.

빠진 구간 뒤에도 이동 번호가 맞아야 한다

처음에는 여러 구간이 같은 순서로 존재하지만 자료가 빠지면 현재 화면의 두 번째와 원래 구성의 두 번째가 달라질 수 있다. 확인한 어댑터는 실제 남은 구간의 ID를 보고 원래 순서에 대응시키고, 요청한 원래 구간이 없으면 뒤에 남은 대상으로 이동한다. 이런 연결이 있어야 원래 효과가 다른 구간에 적용되지 않는다.

새 예시에서는 숫자보다 남은 ID로 직접 이동한다. 빠진 자료를 가리키는 메뉴 버튼은 숨기고, 현재 보이는 구간의 위치를 표시한다. 이 처리는 원형의 모든 효과를 복원하는 기능과는 다르다. ID를 연결하는 작은 기능과 기존 엔진의 타이밍·인덱스 보존을 각각 확인해야 한다.

현재 위치는 색상만으로 표시하지 않는다

메뉴가 화면 밖으로 사라지면 방문자는 다시 찾기 위해 여러 구간을 넘겨야 할 수 있다. 예시에서는 메뉴를 스크롤 영역 밖에 두고 현재 항목에 aria-current를 넣는다. 시각적으로도 배경과 밑줄을 바꾸도록 했다. 색상만 달라지는 장식보다 현재 위치의 의미를 남기는 방법이다.

현재 구간은 스크롤 영역 안의 읽는 기준선과 구간의 위치로 선택한다. 긴 구간을 읽는 중에는 그 구간의 ID가 유지되고, 빠진 구간의 ID는 계산에 들어오지 않는다. 기준선의 비율은 이 예시의 선택이며 모든 홈페이지에 맞는 값은 아니다. 실제 메뉴와 구간 길이에 따라 검토해야 한다.

긴 내용은 화면 높이를 넘어도 읽을 수 있어야 한다

장면을 한 화면처럼 보이게 하려는 의도와 본문을 잘라 숨기는 것은 다르다. 예시 CSS는 최소 높이를 정하되 실제 높이는 내용에 따라 늘어나게 했다. 작은 화면에서는 최소 높이를 풀고 스크롤 맞춤도 해제한다. 화면이 작아졌다는 이유로 내용이나 다음 길이 없어지지 않도록 하는 조건이다.

동작 줄이기 설정에서는 부드러운 이동을 사용하지 않고 맞춤 효과도 해제하도록 작성했다. 휠과 터치를 막아 자체 이동만 강제하지 않는다. 다만 이런 CSS와 이벤트 조건을 넣은 사실이 모든 기기의 실제 사용 검증은 아니다. 확대와 키보드, 긴 내용의 마지막까지 도달하는지는 실제 화면에서 확인해야 한다.

구간 안내 구현과 적용할 CSS

JavaScript는 남은 구간 위치를 읽고 메뉴의 현재 항목을 갱신한다. 버튼을 누르면 해당 ID의 구간으로 이동하고 제목에 초점을 둔다. 빠진 메뉴에 초점이 있으면 스크롤 영역으로 옮기도록 했다. 정리 함수는 붙인 이벤트와 관찰을 해제해 같은 화면에서 다시 사용할 때의 수명도 관리한다.

모든 키보드 입력을 가로채지 않는다. 버튼의 기본 활성화와 영역의 기본 스크롤을 이용하는 구조다. CSS는 아래 예시의 구간과 메뉴 클래스에 적용한다. 실제 홈페이지에서 고정 헤더와 중첩 스크롤, 원래 전체 화면 라이브러리가 함께 쓰인다면 대상과 오프셋을 그 환경에 맞춰 확인해야 한다.

코드와 구현 살펴보기

JavaScript · 남은 구간의 위치 안내

export function currentReadingSection(rects,viewport){
 if(!Array.isArray(rects)||!Number.isFinite(viewport?.top)||!Number.isFinite(viewport?.bottom)||viewport.bottom<=viewport.top)throw new TypeError('Finite viewport bounds required');
 const visible=rects.filter(x=>typeof x.id==='string'&&Number.isFinite(x.top)&&Number.isFinite(x.bottom)&&x.bottom>x.top&&x.bottom>viewport.top&&x.top<viewport.bottom);
 if(!visible.length)return null;
 const line=viewport.top+(viewport.bottom-viewport.top)*0.3;
 return visible.find(x=>x.top<=line&&x.bottom>line)?.id||visible.reduce((best,x)=>Math.abs(x.top-line)<Math.abs(best.top-line)?x:best).id;
}
export function attachSectionGuide(scroller,menu){
 const win=scroller.ownerDocument.defaultView;let frame=0,closed=false;
 const sections=()=>Array.from(scroller.querySelectorAll('[data-guide-section]'));
 const buttons=()=>Array.from(menu.querySelectorAll('button[data-section]'));
 function refresh(){
  if(closed)return;const items=sections(),ids=new Set(items.map(x=>x.id));
  const root=scroller.getBoundingClientRect();
  const current=currentReadingSection(items.map(x=>({id:x.id,...bounds(x)})),root);
  const active=scroller.ownerDocument.activeElement;
  for(const button of buttons()){
   const hidden=!ids.has(button.dataset.section);if(hidden&&button===active)scroller.focus({preventScroll:true});
   button.hidden=hidden;
   if(!hidden&&button.dataset.section===current)button.setAttribute('aria-current','location');else button.removeAttribute('aria-current');
  }
  menu.hidden=!ids.size;
 }
 function bounds(element){const r=element.getBoundingClientRect();return {top:r.top,bottom:r.bottom};}
 function queue(){if(!closed){win.cancelAnimationFrame(frame);frame=win.requestAnimationFrame(refresh);}}
 function navigate(event){
  const button=event.target.closest('button[data-section]');if(!button||!menu.contains(button))return;
  const target=sections().find(x=>x.id===button.dataset.section);if(!target)return;
  const top=target.getBoundingClientRect().top-scroller.getBoundingClientRect().top+scroller.scrollTop-scroller.clientTop;
  scroller.scrollTo({top:Math.max(0,top),behavior:win.matchMedia?.('(prefers-reduced-motion: reduce)').matches?'auto':'smooth'});
  const heading=target.querySelector('h2,h3');if(heading){heading.setAttribute('tabindex','-1');heading.focus({preventScroll:true});}
 }
 scroller.addEventListener('scroll',queue,{passive:true});menu.addEventListener('click',navigate);win.addEventListener('resize',queue);
 const observer=win.MutationObserver?new win.MutationObserver(queue):null;
 observer?.observe(scroller,{childList:true,subtree:true});refresh();
 return {refresh,destroy(){closed=true;win.cancelAnimationFrame(frame);observer?.disconnect();scroller.removeEventListener('scroll',queue);menu.removeEventListener('click',navigate);win.removeEventListener('resize',queue);}};
}

긴 구간을 포함한 실행 예시

아래 예시에는 첫 구간, 긴 상세 구간, 마지막 구간이 있다. 합성 자료 가운데 한 항목을 빠진 상태로 두었다. 메뉴 버튼과 스크롤 영역은 별도로 만들었고 현재 구간을 표시하는 함수를 실제 HTML 블록에 넣었다. 원본 템플릿의 업체나 이미지를 옮기지 않았다.

발행 뒤 공개 HTML의 실행 프레임과 코드를 확인한다. 새 프레임의 실제 스크롤과 초점 이동은 기존 Studio Chrome을 보존하는 현재 조건에서 미검증이다. 예시 구현과 공개 응답, 브라우저에서 본 결과를 같은 검수로 합치지 않는다.

코드와 구현 살펴보기

CSS · 내용이 늘어나는 구간과 메뉴

.guide-scroller { max-block-size: 65svh; overflow-y: auto; scroll-snap-type: y proximity; scroll-behavior: auto; border: 1px solid #b9c8be; }
.guide-section { min-block-size: 60svh; block-size: auto; padding: clamp(20px, 4vw, 48px); scroll-snap-align: start; scroll-margin-block-start: 12px; }
.guide-section + .guide-section { border-block-start: 1px solid #b9c8be; }
.guide-menu { display: flex; flex-wrap: wrap; gap: 8px; margin-block: 16px; }
.guide-menu button { padding: 10px 14px; font: inherit; color: #263b30; background: #fff; border: 1px solid #8aa494; cursor: pointer; }
.guide-menu button[aria-current] { background: #263b30; color: #fff; text-decoration: underline; }
.guide-scroller:focus-visible, .guide-menu button:focus-visible { outline: 3px solid #326c4d; outline-offset: 3px; }
@media (max-width: 520px) { .guide-section { min-block-size: auto; } .guide-scroller { scroll-snap-type: none; } }
@media (prefers-reduced-motion: reduce) { .guide-scroller { scroll-snap-type: none; } }

확인한 코드 범위와 새 적용의 검수

이번에 합성 위치 데이터와 가짜 DOM 이벤트로 9개 검사를 실행했다. 첫 구간과 이후 구간, 긴 구간의 위치 유지, 간격이 있는 구간 선택, 없는 구간, 잘못된 화면 범위, 빠진 버튼과 초점 대체, ID 이동 오프셋과 제목 초점, 동작 줄이기, 이벤트 정리를 확인했다. 이 결과는 함수와 연결 코드의 범위다.

기존 템플릿 파일은 바꾸지 않았다. 실제 전체 화면 애니메이션과 모든 브라우저의 CSS 지원, 키보드·터치 체감은 검사하지 않았다. 적용 전에는 메뉴가 원하는 구간을 가리키는지, 마지막 내용이 읽히는지, 확대와 작은 화면에서도 이동을 유지하는지 확인해야 한다. 장면을 만드는 효과 옆에 사용자가 길을 찾는 기능을 남기는 것이 이 모듈의 목적이다.