jQuery / 스크롤해도 상단에 고정되는 메뉴 만들기

jQuery로 상단 고정 메뉴를 만들려면 현재 문서의 스크롤 위치가 메뉴의 원래 위치에 도달했는지 확인한 뒤 position:fixed를 적용하는 클래스를 켭니다. 원래 위치보다 위로 돌아오면 클래스를 제거합니다. 고정할 때 메뉴가 문서 흐름에서 빠져 본문이 위로 튀지 않도록, 메뉴의 원래 자리에 같은 높이의 공간을 유지해야 합니다.

고정 기준과 원래 자리 정하기

CSS position:fixed는 일반 문서 흐름에서 빠져 보통 브라우저 화면을 기준으로 배치됩니다. 예제의 top:0은 화면 위쪽을 뜻합니다. 메뉴가 원래 놓인 위치는 문서 기준 좌표이므로 현재 스크롤과 비교할 때 두 좌표계를 맞춥니다.

고정된 메뉴 자체의 위치를 기준으로 다시 측정하면 이미 화면 위에 붙은 좌표를 원래 위치로 착각할 수 있습니다. 예제는 계속 문서에 남는 #menu-slot을 기준으로 삼습니다. slot의 화면 기준 top에 window.scrollY를 더해 문서 기준 임계 위치를 계산합니다.

메뉴가 고정되면 폭이 원래 부모의 폭을 자동으로 따르지 않을 수 있습니다. 따라서 slot의 현재 왼쪽 위치와 폭을 고정 메뉴에 지정하고, 실제 메뉴의 테두리 포함 높이를 slot에 남깁니다. 이 예제에서는 margin 없이 border-box로 배치해 폭 계산의 기준을 맞췄습니다.

스크롤과 창 크기 변경을 처리하는 전체 예제

전체 코드를 UTF-8의 fixed-menu-demo.html로 저장해 브라우저로 엽니다. 인터넷으로 공식 jQuery 파일 4.0.0을 읽습니다. 서버나 설치는 필요하지 않습니다. Windows Edge에서 jQuery 3.7.1·4.0.0으로 경계 전환, 본문 위치, 폭 변경과 앵커 이동을 확인했습니다.

헤더 높이가 180px이므로 처음 고정되는 기준은 문서 위에서 180px입니다. 아래쪽 버튼과 상태 출력은 실습을 반복하기 위한 도구입니다. 실제 사이트에는 메뉴 동작에 필요한 부분만 적용합니다.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>A menu fixed after its original position</title>
  <script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>
  <style>
    :root { --menu-height: 60px; }
    html { scroll-behavior: auto; }
    body { margin: 0; font: 16px/1.5 sans-serif; }
    header { height: 180px; display: grid; place-items: center; background: #e7f2f2; }
    .wrap { width: 100%; max-width: 800px; padding: 0 20px; margin: 0 auto; box-sizing: border-box; }
    #menu { box-sizing: border-box; width: 100%; display: flex; flex-wrap: wrap; gap: 12px; padding: 12px; border: 2px solid teal; background: #fff; }
    #menu a { padding: 4px; color: #075c55; }
    #menu.is-fixed { position: fixed; top: 0; z-index: 20; box-shadow: 0 3px 8px #0003; }
    main section { min-height: 650px; padding-top: 24px; }
    main h2 { margin: 0; scroll-margin-top: calc(var(--menu-height) + 16px); }
    #controls { position: fixed; right: 8px; bottom: 8px; z-index: 30; background: #fff; padding: 4px; }
    button { font: inherit; margin: 2px; }
    #result { position: fixed; left: 8px; bottom: 70px; background: #fff; white-space: pre-wrap; padding: 6px; z-index: 30; }
  </style>
</head>
<body>
  <header>Header before the menu</header>
  <div class="wrap"><div id="menu-slot">
    <nav id="menu" aria-label="Sections"><a href="#alpha">Alpha section</a><a href="#beta">Beta section</a><a href="#gamma">Gamma section</a></nav>
  </div></div>
  <main class="wrap">
    <section><h2 id="alpha">Alpha</h2><p>First section content.</p></section>
    <section><h2 id="beta">Beta</h2><p>Second section content.</p></section>
    <section><h2 id="gamma">Gamma</h2><p>Third section content.</p></section>
  </main>
  <div id="controls"><button type="button" id="down">Go to 300px</button><button type="button" id="reset">Back to 0px</button></div>
  <pre id="result" aria-live="polite"></pre>
  <script>
    $(function () {
      const $menu = $("#menu"), $slot = $("#menu-slot");
      let scheduled = false;
      function update() {
        scheduled = false;
        const rect = $slot[0].getBoundingClientRect();
        const threshold = rect.top + window.scrollY;
        const fixed = $(window).scrollTop() >= threshold;
        $menu.toggleClass("is-fixed", fixed).css({
          left: fixed ? rect.left + "px" : "",
          width: fixed ? rect.width + "px" : ""
        });
        const height = $menu.outerHeight();
        $slot.css("height", height + "px");
        document.documentElement.style.setProperty("--menu-height", height + "px");
        $("#result").text(`menu=${fixed ? "fixed" : "flow"}\nthreshold=${Math.round(threshold)}\nscrollY=${Math.round(window.scrollY)}`);
      }
      function schedule() {
        if (!scheduled) { scheduled = true; window.requestAnimationFrame(update); }
      }
      $(window).on("scroll.fixedMenuLesson resize.fixedMenuLesson load.fixedMenuLesson", schedule);
      $("#down").on("click", function () { window.scrollTo(0, 300); schedule(); });
      $("#reset").on("click", function () { window.scrollTo(0, 0); schedule(); });
      update();
    });
  </script>
</body>
</html>

처음 맨 위에서 열었을 때의 결과입니다. menu=flow는 원래 자리의 일반 배치라는 뜻입니다.

menu=flow
threshold=180
scrollY=0
  1. 천천히 아래로 내립니다. 스크롤 179px에서는 원래 자리에 있고 180px부터 화면 top=0에 고정됩니다.
  2. Go to 300px를 누르면 menu=fixed, threshold=180, scrollY=300입니다. 고정 전환 때 본문이 메뉴 높이만큼 갑자기 당겨지지 않는지 봅니다.
  3. 고정된 상태에서 창 폭을 넓혔다 줄입니다. 메뉴가 본문 컨테이너의 왼쪽·폭에 맞고, 링크가 줄바꿈되면 자리 높이도 함께 바뀌어야 합니다.
  4. 메뉴의 Beta section을 누릅니다. 실제 #beta 앵커로 이동하며 제목이 메뉴 아래에 보입니다. Tab으로 메뉴 링크를 선택해 키보드로도 이동할 수 있습니다.
  5. Back to 0px는 맨 위로 돌아갑니다. 메뉴는 일반 배치로 복원하고 고정용 left·width도 비웁니다. 이 버튼은 스크롤 비교용이며 주소의 기존 해시를 삭제하지 않습니다.

갱신 시점과 앵커 가림 처리

$(window).scrollTop()과 원래 자리의 임계 위치를 비교해 true·false를 구합니다. .toggleClass("is-fixed", fixed)는 그 결과대로 클래스를 명시적으로 켜거나 끕니다. 단순 .toggleClass("is-fixed")를 매 스크롤마다 호출하면 이동 이벤트 횟수에 따라 상태가 번갈아 바뀌므로 사용하지 않습니다.

scroll·resize·load에서는 requestAnimationFrame()으로 다음 화면 갱신 전에 update()를 예약합니다. scheduled 값은 이미 예약된 갱신을 중복 등록하지 않도록 합니다. 일정 밀리초 간격이나 특정 프레임 속도를 보장하는 장치는 아니며, 읽기·변경 작업 자체를 가볍게 유지해야 합니다.

update()는 메뉴 폭을 정한 뒤 실제 높이를 측정하므로 좁은 화면의 링크 줄바꿈도 자리 높이에 반영합니다. 처음 실행할 때도 update()를 한 번 호출해 스크롤 복원 상태를 처리합니다. 메뉴 위의 콘텐츠가 나중에 늘어나거나 웹폰트로 높이가 바뀌는 실제 화면에서는 해당 변경이 끝난 시점에 schedule()을 연결해야 합니다. 이 예제가 모든 DOM 변화를 자동 감시하는 것은 아닙니다.

메뉴 높이는 --menu-height에 기록하고 각 목적지 제목의 scroll-margin-top에 여유 16px를 더합니다. 이렇게 해야 기본 앵커로 이동한 제목이 고정 메뉴 아래에 가려지지 않습니다. 실제 사이트에 다른 고정 헤더가 있다면 그 높이도 포함한 top·목적지 여백을 함께 설계합니다. 링크는 실제 href를 유지하며 불필요하게 클릭 기본 동작을 막지 않습니다.

CSS sticky 선택과 실제 사이트 확인

단순히 원래 위치에서 스크롤하다 위에 붙는 메뉴라면 position:sticky와 top 값으로 JavaScript 없이 구현할 수도 있습니다. sticky는 문서 공간을 유지하고 스크롤 컨테이너와 포함 블록의 제한을 받습니다. fixed와 똑같이 동작한다고 생각하지 말고, 짧은 부모나 overflow가 있는 조상이 고정 범위를 제한하는지 확인합니다.

예제는 문서 전체를 기준으로 한 fixed 전환을 설명합니다. 내부 패널이 스크롤되는 화면에는 그대로 적용되지 않습니다. 또한 조상에 transform 등이 있으면 fixed의 기준이 바뀔 수 있으므로 메뉴의 상위 구조를 확인합니다. 여러 사이트의 기존 CSS를 상쇄하려고 전역 position·overflow를 무작정 바꾸지 않습니다.

고정 메뉴의 z-index가 높아도 모달·드롭다운·다른 쌓임 맥락을 모두 해결하지는 않습니다. 모바일에서 메뉴가 콘텐츠 대부분을 가리키지 않는지, 키보드 포커스가 보이는지, 확대 상태에서도 링크에 접근할 수 있는지 확인합니다. 실습용 고정 상태 출력과 버튼은 실제 콘텐츠를 덮지 않도록 제거하거나 적절히 배치합니다.

복원하려면 이벤트 네임스페이스를 해제하는 것만으로 끝내지 말고 고정 클래스·left·width와 slot 높이, 목적지용 사용자 정의 속성까지 원래 화면의 소유 범위 안에서 정리합니다. 앱에서 이 기능을 반복 초기화한다면 같은 이벤트를 중복 등록하지 않도록 수명 주기도 함께 관리합니다.