25. activeMenu

웹 페이지에서 사용자가 스크롤을 내릴 때 상단 내비게이션(Navigation, 메뉴 영역)을 화면 상단에 고정하고, 현재 뷰포트(Viewport, 화면 표시 영역)에 노출된 본문 구역(Section)에 맞춰 해당 메뉴 항목을 활성화하는 기능을 다룬다. 사용자의 스크롤 위치를 감지하여 상단 메뉴와 사이드 내비게이션에 활성 클래스를 부여하는 방식을 제이쿼리(jQuery)와 순수 자바스크립트(Vanilla JavaScript) 두 가지 코드로 각각 구현한다.

스크롤 연동 메뉴 활성화 흐름 — 스크롤 위치를 측정하여 상단 바를 고정하고 화면에 노출된 구역에 맞춰 메뉴를 활성화한다.

1. 메뉴활성화

1.1. 단계1-상단메뉴

1.1.1. 스티키

스티키(Sticky, 고정형 배치)는 스크롤이 일정 지점에 도달하기 전까지는 문서 흐름에 따라 위치하다가, 기준선을 넘어가면 화면 상단에 달라붙듯 고정되는 UI 패턴이다. 마치 책갈피가 페이지를 넘겨도 상단에 계속 남아 있는 것과 같다. 본 단계에서는 스크롤 위치를 실시간으로 측정하여 클래스를 부여함으로써 상단 바의 고정 상태와 메뉴의 활성화 상태를 변경한다.

예제

아래 링크는 상단 메뉴와 사이드 내비게이션의 스티키 고정 및 스크롤 동기화 동작을 확인할 수 있는 예제 페이지이다. 스크롤을 내릴 때 상단 바의 배경색과 고정 여부가 달라지는 동작과, 각 섹션을 지날 때마다 해당 메뉴 텍스트와 사이드 아이콘이 전환되는 현상을 확인한다.

[미리보기](https://qwerewqwerew.github.io/source/jq/9)

HTML

화면 상단의 주 메뉴인 nav.gnb, 화면 우측에 상시 표시될 ul.sideNav, 그리고 본문 영역을 구성하는 9개의 section.section 요소로 마크업을 구성한다. 각 내비게이션 링크(a)의 href 속성에는 이동 대상이 되는 섹션의 식별자(예: #section1)가 연결되어 있다.

  <!DOCTYPE html>
  <html lang="ko">
    <head>
      <meta charset="UTF-8" />
      <meta http-equiv="X-UA-Compatible" content="IE=edge" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      <title>Document</title>
      <meta property="og:title" content="발전하는 신입 퍼블리셔 망고의 포트폴리오입니다" />
      <meta property="og:url" content="http://www.mango.dothome.co.kr" />
      <meta property="og:type" content="website" />
      
      <meta property="og:site_name" content="망고의 포트폴리오" />
      <meta property="og:description" content="오늘보다는 내일 더 성장하는 것이 목표인 퍼블리셔의 포트폴리오입니다." />
      <meta name="twitter:card" content="summary" />
      <meta name="twitter:title" content="발전하는 신입 퍼블리셔 망고의 포트폴리오입니다" />
      <meta name="twitter:description" content="오늘보다는 내일 더 성장하는 것이 목표인 퍼블리셔의 포트폴리오입니다." />
      
      <link rel="stylesheet" href="css/reset.css" />
      <link rel="stylesheet" href="css/style.css" />
    </head>

    <body>
      <nav>
        <ul class="gnb">
          <li class="on"><a href="#section1">section1</a></li>
          <li><a href="#section2">section2</a></li>
          <li><a href="#section3">section3</a></li>
          <li><a href="#section4">section4</a></li>
          <li><a href="#section5">section5</a></li>
          <li><a href="#section6">section6</a></li>
          <li><a href="#section7">section7</a></li>
          <li><a href="#section8">section8</a></li>
          <li><a href="#section9">section9</a></li>
        </ul>
      </nav>
      <ul class="sideNav">
        <li class="on"><a href="#section1">section1</a></li>
        <li><a href="#section2">section2</a></li>
        <li><a href="#section3">section3</a></li>
        <li><a href="#section4">section4</a></li>
        <li><a href="#section5">section5</a></li>
        <li><a href="#section6">section6</a></li>
        <li><a href="#section7">section7</a></li>
        <li><a href="#section8">section8</a></li>
        <li><a href="#section9">section9</a></li>
      </ul>
      <main class="container">
        <section class="section" id="section1">section1</section>
        <section class="section" id="section2">section2</section>
        <section class="section" id="section3">
          <div class="ball">ball</div>
          section3
        </section>
        <section class="section" id="section4">section4</section>
        <section class="section" id="section5">section5</section>
        <section class="section" id="section6">section6</section>
        <section class="section" id="section7">section7</section>
        <section class="section" id="section8">section8</section>
        <section class="section" id="section9">section9</section>
      </main>
      <script src="https://code.jquery.com/jquery-1.12.0.js"></script>
      <script src="https://code.jquery.com/ui/1.12.0/jquery-ui.js"></script>
      <script src="jq.js"></script>
    </body>
  </html>

초기 로딩 시 첫 번째 항목(section1)의 li 태그에 미리 class="on"을 지정하여 첫 화면의 활성화 상태를 표현한다. 스크립트 파일(jq.js)은 제이쿼리 라이브러리 및 애니메이션 가속도(Easing)를 지원하는 제이쿼리 UI CDN 파일 아래에 위치해야 정상적으로 실행된다.

CSS

[reset다운로드](https://meyerweb.com/eric/tools/css/reset/)

내비게이션의 배치 형태와 활성화 상태 시의 시각 효과를 정의한다. nav의 기본 위치는 position: absolute로 설정되어 문서 상단에 머무르지만, 자바스크립트에 의해 nav.sticky 클래스가 추가되면 position: fixed로 전환되어 화면 상단에 영구 고정된다.

  @charset 'UTF-8';
  @import url('https://fonts.googleapis.com/css2?family=Black+And+White+Picture&family=Noto+Sans+KR:wght@100; 300; 400; 500; 700; 900&display=swap');
  html {
    overflow-x: hidden;
  }
  html,
  body,
  a,
  table {
    font-family: 'Black And White Picture', sans-serif;
    font-family: 'Noto Sans KR', sans-serif;
  }
  ul,
  li,
  ol {
    list-style: none;
  }
  a {
    text-decoration: none;
  }

  body::-webkit-scrollbar {
    width: 1em;
    background-color: red;
    background-image: repeating-conic-gradient(#474bff 0% 25%, #47d3ff 0% 50%);
    background-position: 0 0, 38px 38px;
    background-size: 46px 76px;
    background-color: #47d3ff;
  }
  body::-webkit-scrollbar-thumb {
  }
  body::-webkit-scrollbar-button {
    width: 3em;
    height: 3em;
    background-color: #282972;
    box-shadow: 1px 1px 3px #000;
  }
  body::-webkit-scrollbar-thumb {
    width: 10em;
    height: 10em;
    background-color: #47d3ff;
    background-image: linear-gradient(to right top, #d16ba5, #c777b9, #ba83ca, #aa8fd8, #9a9ae1, #8aa7ec, #79b3f4, #69bff8, #52cffe, #41dfff, #46eefa, #5ffbf1);
    border-radius: 3px 0 0 3px;
    box-shadow: 1px 1px 4px #000;
  }
  /* nav */
  nav {
    position: absolute;
    top: 0;
    z-index: 2;
    background: rgba(0, 0, 0, 0.4);
    padding: 1em 1.4em;
    width: 100%;
  }
  nav.sticky {
    background: rgba(255, 0, 100, 0.6);
    position: fixed;
    z-index: 222;
  }
  nav .gnb {
    display: flex;
    width: 100%;
    max-width: 1600px;
    margin: auto;
  }
  nav .gnb li {
    flex: 1 1 200px;
  }
  nav .gnb li a {
    color: #fff;
    display: inline-block;
    padding: 0.2rem;
    text-align: center;
    line-height: 2rem;
    height: 2rem;
    position: relative;
  }
  nav .gnb li a:before,
  nav .gnb li a:after {
    position: absolute;
    content: '';
    left: 0;
    width: 100%;
    height: 2px;
    background: #000;
    transform: scaleX(0);
    transition: all 0.5s;
  }
  nav .gnb li a:before {
    top: 0;
    transform-origin: left;
  }
  .gnb li a:after {
    bottom: 0;
    transform-origin: right;
  }
  nav .gnb li.on a:before,
  nav .gnb li a:hover:before,
  nav .gnb li.on a:after,
  nav .gnb li a:hover:after {
    transform: scaleX(1);
  }
  .container {
  }
  section {
    min-height: 100vh;
    line-height: 100vh;
    text-align: center;
    background-color: yellow;
    margin: 40vw auto;
  }
  section::selection {
    color: red;
    background-color: #00eeff;
  }

  /* sideNav  */
  .sideNav {
    position: fixed;
    top: 10%;
    right: 10%;
    z-index: 999;
  }
  .sideNav a {
    display: inline-block;
    padding: 1em;
    background: rgba(0, 0, 0, 0.5);
    color: #fff;
    margin-bottom: 1em;
  }
  .sideNav li.on a::before {
    content: '😺';
  }

  /* sectionIn */
  section {
    position: relative;
  }
  section .ball {
    position: absolute;
    background-color: skyblue;
    width: 450px;
    height: 450px;
    border-radius: 50%;
    left: -450px;
    transition: left 1s;
  }
  section.on .ball {
    left: 100px;
  }

상단 메뉴(.gnb li a)에는 가상 요소(:before, :after)를 사용하여 상하단 테두리 선을 transform: scaleX(0)으로 숨겨두었다가, 마우스 호버(:hover)나 활성화 클래스(li.on)가 붙었을 때 scaleX(1)로 확장하여 밑줄 애니메이션을 연출한다. 또한 사이드 메뉴는 li.on a::before 가상 요소로 이모지(‘😺’) 기호를 부착하며, 세 번째 섹션 내부의 원형 요소(.ball)는 섹션에 .on 클래스가 부여되면 left: -450px에서 left: 100px로 이동하도록 트랜지션이 적용된다.

JQ

제이쿼리 문법을 사용하여 메뉴 클릭 시 부드러운 스크롤 이동과 스크롤 위치에 따른 활성화 클래스 자동 변경을 제어한다. 스크롤 탑(ScrollTop, 화면 상단 스크롤 이동 거리)과 각 섹션의 오프셋 탑(OffsetTop, 문서 최상단부터 요소까지의 절대 수직 거리)을 계산하여 동작한다.

  let win = $(window),
    sections = $('.section'),
    gnb = $('.gnb li'),
    sideNav = $('.sideNav>li');

  const gnbOff = () => {
    gnb.each(() => {
      this.removeClass('on');
    });
  };

  gnb.click(function (e) {
    e.preventDefault();
    let tg = $(this);
    let index = tg.index();
    let section = sections.eq(index);
    let offset = section.offset().top;
    $('html,body').stop().animate({ scrollTop: offset }, 1000, 'easeOutCirc');
  });
  sideNav.click(function (e) {
    e.preventDefault();
    let tg = $(this);
    let index = tg.index();
    let section = sections.eq(index);
    let offset = section.offset().top;
    $('html,body').stop().animate({ scrollTop: offset }, 1000, 'easeOutCirc');
  });

  win.scroll(function () {
    let sct = win.scrollTop();
    sections.each(function (i) {
      if (sct >= sections.eq(i).offset().top - 300) {
        $('.gnb li').eq(i).addClass('on').siblings().removeClass('on');
        sideNav.eq(i).addClass('on').siblings().removeClass('on');
        sections.eq(i).addClass('on').siblings().removeClass('on');
      }
    });
    sct > 400 ? $('nav').addClass('sticky') : $('nav').removeClass('sticky');
  });

메뉴 항목을 클릭하면 e.preventDefault()a 태그의 기본 이동 동작을 막고, 클릭한 요소의 순번(index())에 해당하는 섹션의 offset().top 위치로 animate({ scrollTop: offset }, 1000, 'easeOutCirc')를 실행하여 부드럽게 스크롤한다. win.scroll() 이벤트에서는 현재 스크롤 위치가 각 섹션 상단 기준선(offset().top - 300)을 넘어서는 순간 .addClass('on').siblings().removeClass('on')을 수행하여 형제 요소들의 활성 클래스를 지우고 해당 인덱스 요소만 활성화한다. 여기서 - 300의 여백을 뺀 이유는 사용자의 시선이 섹션 영역에 도달하기 전 미리 메뉴 활성화를 전환하여 자연스러운 반응 속도를 제공하기 위함이다.

JS

외부 라이브러리 없이 브라우저 내장 API만을 활용한 순수 자바스크립트 구현 방식이다. 동일한 스크롤 감지 로직과 클래스 제어 기법을 표준 메서드로 처리한다.

let win = window,
  winSct,
  sections = document.querySelectorAll('.section'),
  gnb = document.querySelectorAll('.gnb li'),
  sideNav = document.querySelectorAll('.sideNav>li');

const gnbOff = () => {
  gnb.forEach((o) => {
    o.classList.remove('on');
  });
};
const navOff = () => {
  sideNav.forEach((o) => {
    o.classList.remove('on');
  });
};

gnb.forEach((el) => {
  el.addEventListener('click', function (e) {
    e.preventDefault();
    document.querySelector(el.firstChild.getAttribute('href')).scrollIntoView({ behavior: 'smooth' });
    gnbOff();
    el.classList.add('on');
  });
});
sideNav.forEach((el) => {
  el.addEventListener('click', function (e) {
    e.preventDefault();
    document.querySelector(el.firstChild.getAttribute('href')).scrollIntoView({ behavior: 'smooth' });
    navOff();
    el.classList.add('on');
  });
});

win.addEventListener('scroll', function (e) {
  e.preventDefault();
  winSct = this.scrollY;
  winSct >= 400 ? document.querySelector('nav').classList.add('sticky') : document.querySelector('nav').classList.remove('sticky');
  scrollOn(winSct);
});

const scrollOn = (sct) => {
  if (sct >= sections[0].offsetTop - 300) {
    gnbOff();
    navOff();
    document.querySelector('.gnb li:nth-child(1)').classList.add('on');
    document.querySelector('.sideNav li:nth-child(1)').classList.add('on');
    sections[0].classList.add('on');
  }
  if (sct >= sections[1].offsetTop - 300) {
    gnbOff();
    navOff();
    document.querySelector('.gnb li:nth-child(2)').classList.add('on');
    document.querySelector('.sideNav li:nth-child(2)').classList.add('on');
    sections[1].classList.add('on');
  }
  if (sct >= sections[2].offsetTop - 700) {
    gnbOff();
    navOff();
    document.querySelector('.gnb li:nth-child(3)').classList.add('on');
    document.querySelector('.sideNav li:nth-child(3)').classList.add('on');
    sections[2].classList.add('on');
  }
  if (sct >= sections[3].offsetTop - 300) {
    gnbOff();
    navOff();
    document.querySelector('.gnb li:nth-child(4)').classList.add('on');
    document.querySelector('.sideNav li:nth-child(4)').classList.add('on');
  }
  if (sct >= sections[4].offsetTop - 300) {
    gnbOff();
    navOff();
    document.querySelector('.gnb li:nth-child(5)').classList.add('on');
    document.querySelector('.sideNav li:nth-child(5)').classList.add('on');
  }
  if (sct >= sections[5].offsetTop - 300) {
    gnbOff();
    navOff();
    document.querySelector('.gnb li:nth-child(6)').classList.add('on');
    document.querySelector('.sideNav li:nth-child(6)').classList.add('on');
  }
  if (sct >= sections[6].offsetTop - 300) {
    gnbOff();
    navOff();
    document.querySelector('.gnb li:nth-child(7)').classList.add('on');
    document.querySelector('.sideNav li:nth-child(7)').classList.add('on');
  }
  if (sct >= sections[7].offsetTop - 300) {
    gnbOff();
    navOff();
    document.querySelector('.gnb li:nth-child(8)').classList.add('on');
    document.querySelector('.sideNav li:nth-child(8)').classList.add('on');
  }
  if (sct >= sections[8].offsetTop - 300) {
    gnbOff();
    navOff();
    document.querySelector('.gnb li:nth-child(9)').classList.add('on');
    document.querySelector('.sideNav li:nth-child(2)').classList.add('on');
  }
};

순수 자바스크립트에서는 scrollIntoView({ behavior: 'smooth' }) 메서드를 사용하여 대상 요소의 위치로 부드럽게 화면을 이동시킨다. 스크롤 시에는 window.scrollY 값을 읽어 scrollOn() 함수로 전달하고, 각 sectionoffsetTop 기준값을 확인하여 gnbOff()navOff()로 기존 활성 클래스를 전체 제거한 후 해당 순번의 메뉴에 classList.add('on')을 적용한다.

댓글 남기기