23. menu

메가메뉴(Mega Menu)는 대분류 하위에 속한 세부 메뉴 항목들을 다단(Multi-column) 형태로 한눈에 보여주는 내비게이션(Navigation) 구조다. 본 글에서는 웹 표준 시맨틱 태그(Semantic Tag)와 플렉스박스(Flexbox)를 활용하여 2단계 메가메뉴의 마크업과 스타일을 제작하고, 자바스크립트(JavaScript)를 통해 마우스 및 키보드 인터랙션과 상단 고정(Sticky) 기능을 구현하는 전 과정을 설명한다.

미리보기

메가메뉴 컴포넌트 제작 단계 — 시맨틱 태그 구조 설계부터 플렉스박스 스타일링과 스크립트 인터랙션 구현까지의 순서다.

HTML

HTML(에이치티엠엘) 코드는 전체 메뉴의 계층 구조를 정의한다. 최상위 내비게이션 영역인 <nav class="nav"> 내부에 1차 카테고리 목록(ul.menu > li.item)을 두고, 각 .item 내부에 2차 드롭다운(Dropdown) 영역인 div.mega를 중첩 배치한다. div.mega 안쪽은 div.rowdiv.col로 세분화하여 서브 타이틀(h3)과 하위 항목 링크 목록(ul > li > a)을 열 단위로 묶어낸다.

상위 항목 요소인 li.item 내부에 하위 div.mega를 자식 요소로 포함하는 구조는 CSS 가상 선택자와 자바스크립트 이벤트 감지 시 상위 요소와 하위 요소 간의 연결 상태를 유지하는 데 필수적이다. 하위 영역을 li 바깥으로 분리하면 마우스 이동 과정에서 포인터가 영역을 벗어나 메뉴가 의도치 않게 닫히는 문제가 발생할 수 있다.

<!DOCTYPE html>
  <html lang="ko">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>2단계 메가메뉴</title>
    <link rel="stylesheet" href="menu.css">
  </head>
  <body>
    <nav class="nav">
      <ul class="menu">
        <li class="item">
          <a href="#" class="link">카테고리1</a>
          <div class="mega">
            <div class="row">
              <div class="col">
                <h3>서브1-1</h3>
                <ul>
                  <li><a href="#">항목1</a></li>
                  <li><a href="#">항목2</a></li>
                  <li><a href="#">항목3</a></li>
                </ul>
              </div>
              <div class="col">
                <h3>서브1-2</h3>
                <ul>
                  <li><a href="#">항목1</a></li>
                  <li><a href="#">항목2</a></li>
                  <li><a href="#">항목3</a></li>
                </ul>
              </div>
              <div class="col">
                <h3>서브1-3</h3>
                <ul>
                  <li><a href="#">항목1</a></li>
                  <li><a href="#">항목2</a></li>
                  <li><a href="#">항목3</a></li>
                </ul>
              </div>
            </div>
          </div>
        </li>
        <li class="item">
          <a href="#" class="link">카테고리2</a>
          <div class="mega">
            <div class="row">
              <div class="col">
                <h3>서브2-1</h3>
                <ul>
                  <li><a href="#">항목1</a></li>
                  <li><a href="#">항목2</a></li>
                </ul>
              </div>
              <div class="col">
                <h3>서브2-2</h3>
                <ul>
                  <li><a href="#">항목1</a></li>
                  <li><a href="#">항목2</a></li>
                </ul>
              </div>
            </div>
          </div>
        </li>
        <li class="item">
          <a href="#" class="link">카테고리3</a>
          <div class="mega">
            <div class="row">
              <div class="col">
                <h3>서브3-1</h3>
                <ul>
                  <li><a href="#">항목1</a></li>
                  <li><a href="#">항목2</a></li>
                  <li><a href="#">항목3</a></li>
                  <li><a href="#">항목4</a></li>
                </ul>
              </div>
              <div class="col">
                <h3>서브3-2</h3>
                <ul>
                  <li><a href="#">항목1</a></li>
                  <li><a href="#">항목2</a></li>
                </ul>
              </div>
              <div class="col">
                <h3>서브3-3</h3>
                <ul>
                  <li><a href="#">항목1</a></li>
                  <li><a href="#">항목2</a></li>
                  <li><a href="#">항목3</a></li>
                </ul>
              </div>
              <div class="col">
                <h3>서브3-4</h3>
                <ul>
                  <li><a href="#">항목1</a></li>
                  <li><a href="#">항목2</a></li>
                </ul>
              </div>
            </div>
          </div>
        </li>
        <li class="item">
          <a href="#" class="link">카테고리4</a>
          <div class="mega">
            <div class="row">
              <div class="col">
                <h3>서브4-1</h3>
                <ul>
                  <li><a href="#">항목1</a></li>
                  <li><a href="#">항목2</a></li>
                  <li><a href="#">항목3</a></li>
                </ul>
              </div>
            </div>
          </div>
        </li>
      </ul>
    </nav>

    <section>
      <h1>2단계 메가메뉴 예제</h1>
      <p>상단 메뉴에 마우스를 올려보세요.</p>
    </section>

    <script src="menu.js"></script>
  </body>
  </html>

CSS

CSS(씨에스에스)는 메뉴의 레이아웃 배치와 펼침 애니메이션을 제어한다. 플렉스박스(Flexbox)는 1차원 공간에서 요소들을 가로 또는 세로 방향으로 유연하게 배치하고 정렬하는 CSS 레이아웃 모듈이다. 부모 요소에 플렉스 속성을 선언하면 자식 요소의 너비와 여백을 화면 크기에 맞추어 자동으로 분배한다. 이는 규격이 다른 물품들을 상자 폭에 맞추어 일정한 간격으로 나누어 담는 칸막이 상자와 같다. .menu.rowdisplay: flex를 지정하여 상위 항목과 하위 열(Column)들이 가로 방향으로 균등하게 정렬되도록 만든다.

메가 드롭다운 패널인 .megaposition: absolute; top: 100%; left: 0; width: 100vw;로 설정되어 1차 내비게이션 바로 아래에 뷰포트 전체 너비로 위치한다. 평상시에는 max-height: 0overflow: hidden으로 내부 콘텐츠를 숨기며, 상위 요소에 호버(:hover), 포커스(:focus-within), 또는 활성화 클래스(.on)가 적용되면 max-height: 500px로 확장되면서 transition: max-height 0.3s에 의해 부드럽게 펼쳐진다.

처음 스타일을 작성할 때 display: nonedisplay: block을 사용하면 CSS 전환 효과(Transition)가 동작하지 않아 부드러운 애니메이션이 구현되지 않는다. 따라서 높이 전환 효과를 줄 때는 max-height 속성과 overflow: hidden을 함께 조합하여 사용해야 한다. 또한 max-height를 실제 콘텐츠 높이에 비해 지나치게 큰 값(예: 3000px)으로 지정하면 닫히는 동작에서 체감 지연 시간이 발생하므로 적정 높이로 한정해야 한다.

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
  }

  body {
    font-family: sans-serif;
  }

  .nav {
    background: #333;
    position: relative;
    z-index: 999;
  }

  .menu {
    list-style: none;
    display: flex;
    justify-content: space-evenly;
    align-items: center;
    width: 100%;
    max-width: 1200px;
    margin: auto;
    text-align: center;
  }

  .item {
    width: calc(80% / 4);
  }

  .link {
    display: block;
    padding: 15px 20px;
    color: #fff;
    text-decoration: none;
  }

  .link:hover {
    background: #555;
  }

  .mega {
    position: absolute;
    top: 100%;
    left: 0;
    width: 100vw;
    background: #eee;
    border-bottom: 1px solid #ddd;
    max-height: 0;
    overflow: hidden;
    transition: max-height 0.3s;
    z-index: 1000;
  }

  .item:hover .mega,
  .item:focus-within .mega,
  .item.on .mega {
    max-height: 500px;
  }

  .row {
    display: flex;
    padding: 20px;
    gap: 30px;
    max-width: 1200px;
    margin: 0 auto;
  }

  .col {
    flex: 1;
  }

  .col h3 {
    margin-bottom: 10px;
    font-size: 14px;
  }

  .col ul {
    list-style: none;
  }

  .col ul li {
    margin-bottom: 5px;
  }

  .col ul li a {
    color: #333;
    text-decoration: none;
    font-size: 13px;
  }

  .col ul li a:hover {
    text-decoration: underline;
  }

  .cnt {
    padding: 50px;
  }

  section {
    padding: 2vw;
    height: 100vh;
  }

JS

자바스크립트(JS)는 사용자의 마우스 움직임과 키보드 조작에 반응하는 인터랙션을 제어한다. 이벤트 리스너(Event Listener)는 특정 동작(Event)이 일어나는 순간을 감지하여 지정된 동작을 실행하는 자바스크립트 함수다. 지정된 요소에서 이벤트가 발생하면 연결된 콜백 함수가 호출되어 클래스를 추가하거나 스타일을 변경한다. 이는 방문자가 초인종을 누르면 즉시 문을 열어주는 감시 장치와 같다.

.item 요소에 mouseenter 이벤트를 연결하여 마우스가 진입하면 기존에 열려 있던 다른 메뉴의 .on 클래스를 제거하고 현재 항목에만 .on 클래스를 추가한다. 내비게이션 전체 영역인 .nav에서 마우스가 완전히 벗어나면 mouseleave 이벤트가 발생하여 모든 .item에서 .on 클래스를 제거해 메뉴를 닫는다. 또한 keydown 이벤트를 통해 사용자가 Escape(이에스씨) 키를 누르면 열려 있는 모든 서브메뉴를 즉시 닫아 키보드 탐색 환경에서의 웹 접근성을 보장한다.

마우스 진입 이벤트 등록 시 mouseover 대신 mouseenter를 사용하는 이유는 mouseover가 자식 요소로 포인터가 이동할 때마다 이벤트 버블링(Bubbling)을 일으켜 불필요한 함수 호출을 반복하기 때문이다. mouseenter는 해당 요소의 경계선에 진입할 때만 정확히 1회 실행되므로 안정적인 메뉴 제어가 가능하다.

const nav = document.querySelector('.nav');
const items = document.querySelectorAll('.item');

// 마우스 오버로 메뉴 열기
items.forEach(item => {
  item.addEventListener('mouseenter', () => {
    // 다른 메뉴 닫기
    items.forEach(i => i.classList.remove('on'));
    // 현재 메뉴 열기
    item.classList.add('on');
  });
});

// nav 영역 벗어나면 모두 닫기
nav.addEventListener('mouseleave', () => {
  items.forEach(item => item.classList.remove('on'));
});

// ESC 키로 닫기
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') {
    items.forEach(item => item.classList.remove('on'));
  }
});

스티키

스티키 내비게이션(Sticky Navigation)은 사용자가 화면을 스크롤하더라도 메뉴 영역이 화면 밖으로 사라지지 않고 상단에 계속 고정되어 유지되는 내비게이션 기능이다. 스크롤 위치가 원래 메뉴 위치를 지나치면 고정 위치 속성이 활성화되어 뷰포트 최상단에 붙는다. 이는 스크롤을 내려도 항상 시야에 보이도록 화면 윗부분에 꽂아 둔 고정 핀과 같다.

다음 CSS 코드는 내비게이션 요소에 .sticky 클래스가 추가되었을 때 적용할 고정 스타일을 정의한다. position: fixedtop: 0; left: 0; width: 100%;를 부여하여 스크롤 위치와 상관없이 뷰포트 맨 위에 밀착 고정한다.

  .nav.sticky {
  position: fixed;     /* 고정 상태 */
  top: 0;
  left: 0;
  width: 100%;
}

다음 자바스크립트 코드는 스크롤 위치에 따라 .sticky 클래스를 추가하거나 제거한다. 페이지가 로드될 때 nav.offsetTop으로 내비게이션의 초기 수직 위치를 기록한 뒤, 윈도우 스크롤 이벤트(window.scroll)가 발생할 때마다 현재 스크롤 위치(window.scrollY)가 초기 위치를 초과했는지를 검사하여 클래스를 동적으로 제어한다.

// nav의 시작 위치 저장
  const navTop = nav.offsetTop;

  // 스크롤 이벤트
  window.addEventListener('scroll', () => {
    if (window.scrollY > navTop) {
      nav.classList.add('sticky');    // nav 높이 넘으면 고정
    } else {
      nav.classList.remove('sticky'); // 위로 올라가면 해제
    }
  });

주의할 점으로 요소의 위치 속성이 fixed로 바뀌는 순간 해당 요소는 일반 문서 흐름(Document Flow)에서 완전히 제외된다. 이로 인해 내비게이션 바 바로 아래에 위치하던 본문 영역(section)이 메뉴의 원래 높이만큼 갑자기 위로 튀어 올라오는 레이아웃 밀림(Layout Shift) 현상이 일어날 수 있다. 이를 방지하려면 .sticky 클래스가 부여될 때 내비게이션 높이만큼 본문 상단에 padding-top을 함께 추가해 주는 처리가 필요하다.

댓글 남기기