GODRICH

메가 메뉴 ui 9종 — 링크 나열이 아니라 사이트 지도

메가 메뉴 ui 는 상단 내비의 항목 하나에서 화면 폭만큼 넓은 패널이 열려 사이트 전체 구조를 한눈에 보여 주는 메뉴입니다. 여기 모은 아홉 개는 그 패널뿐 아니라 다단 전개·스크롤 축소·모바일 전환·검색·알림까지, 메뉴가 사이트에서 맡는 일 아홉 가지를 전부 돌아가는 코드로

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개는 인기순이 아니라 메뉴가 져야 하는 짐이 무거워지는 순서로 놓았습니다. 앞의 세 개는 데스크톱에서 정보를 펼치는 방법입니다. 행 하나에 매달아 통째로 열고(01), 계층을 옆으로 밀고(02), 같은 패널 안에서 갈아 끼웁니다(03). 다음 두 개는 스크롤을 만납니다. 지나가면 접히는 헤더(04)와 섹션을 따라다니는 서브 내비(05)입니다. 여섯째와 일곱째는 화면이 좁아질 때의 대답입니다. 햄버거가 전체 화면을 덮고(06), 서랍이 옆에서 밀려 들어옵니다(07). 마지막 두 개는 메뉴의 남은 두 역할인 찾기와 알림입니다. 아이콘이 입력창으로 펼쳐지고(08), 종이 읽지 않은 소식을 셉니다(09). 첫 칸에서 마지막 칸으로 갈수록 메뉴는 링크 목록에서 사이트의 조종석으로 바뀝니다. 접힘 축의 다른 이웃들은 햄버거 버튼 애니메이션탭 메뉴 전환에, 선택 상자 계열은 셀렉트 드롭다운에 따로 묶어 두었습니다.

01펼쳐지는 전체 폭 메가 패널

상단 행의 큰 메뉴에 마우스를 올리면 화면 폭만큼 넓은 패널이 내려오고, 안에는 카테고리 열 세 개와 강조 카드 한 장이 함께 보입니다. 열림은 translateY 여섯 픽셀과 한 번의 컷으로 끝나고, 그 뒤 열 제목들이 시차로 떠오르므로 2초 루프에서 열여덟 프레임이 움직입니다. 제품군이 세 개 이상인 쇼핑몰·SaaS 상단 내비에 맞습니다.

:hovergrid-template-columnsaria-expanded
trigger.setAttribute('aria-haspopup', 'true');
trigger.setAttribute('aria-expanded', 'false');

function open() { stopDemo(); root.classList.add('is-open'); trigger.setAttribute('aria-expanded', 'true'); }
function close() { stopDemo(); root.classList.remove('is-open'); trigger.setAttribute('aria-expanded', 'false'); }

trigger.addEventListener('mouseenter', open);
trigger.addEventListener('focus', open);
trigger.addEventListener('click', function () { root.classList.contains('is-open') ? close() : open(); });
root.addEventListener('mouseleave', close);
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });

02서브가 오른쪽으로 미끄러지는 다단 드롭다운

첫 단계 목록에서 화살표가 붙은 항목에 닿으면 두 번째 단계가 오른쪽에서 밀려 나오고, 그 안에서 다시 화살표가 붙은 항목이 세 번째 단계까지 이어 줍니다. 하위 전개는 CSS :hover:focus-within으로만 판정하므로 Tab 키로 들어가도 같은 순간에 열리고, JS 는 최상위 열림과 aria-expanded만 지킵니다. 카테고리가 2~3층인 문서·도움말 사이트 내비에 씁니다.

translateXaria-haspopup:focus-within
// 하위 단계는 각 항목(li) 기준 오른쪽 옆 — hover/:focus-within CSS 로 열린다
.mls__l2,
.mls__l3 {
  top: 0;
  left: 100%;
  opacity: 0;
  visibility: hidden;
  transform: translateX(12px);
  transition: opacity $dur-quick $easing, transform $dur-quick $easing, visibility 0s linear $dur-quick;
}
.mls__i--parent:hover > .mls__l2,
.mls__i--parent:focus-within > .mls__l2,
.mls__i--parent:hover > .mls__l3,
.mls__i--parent:focus-within > .mls__l3 {
  opacity: 1;
  visibility: visible;
  transform: translateX(0);
}

03탭으로 갈아 끼우는 탭형 메가 메뉴

패널 안의 탭을 누르면 같은 자리에서 카드 묶음이 한 프레임에 갈아 끼워지고, 눌린 탭만 선택된 상태로 읽힙니다. 묶음 교체는 두 층을 섞지 않고 steps(1, end) 컷으로 자르고, 직후 새 카드들이 translateY로 시차 팝을 하므로 컷만 쓸 때보다 프레임이 절반 이상 늘어납니다. 같은 카테고리를 주제별로 몇 가지 각도로 보여 줘야 하는 스토어 내비에 맞습니다.

flex: 0 0 autosteps(1, end)aria-selected
function select(i) {
  tabs.forEach(function (t, k) {
    t.setAttribute('aria-selected', k === i ? 'true' : 'false');
    t.tabIndex = k === i ? 0 : -1;
    sets[k].hidden = k !== i;
    if (k === i && !root.classList.contains('is-demo')) {
      sets[k].classList.remove('is-pop');
      void sets[k].offsetWidth;
      sets[k].classList.add('is-pop');
    }
  });
}

04스크롤을 따라 접히는 축소 헤더

본문이 스크롤되어 지나가면 빈 막대 배경과 로고가 함께 작아지며 그만큼 본문이 위로 당겨지고, 되돌리면 원래 크기로 돌아옵니다. 높이를 바꾸는 대신 비어 있는 막대는 scaleY, 내용물은 scale, 본문은 translateY로 접으므로 축소 한 번에 리플로우가 0이고, 판정은 본문 맨 위의 감시점 하나가 화면에서 벗어났는지로 끝납니다. 목록·매거진처럼 스크롤이 긴 페이지의 상단에 씁니다.

scaleYIntersectionObservertransform-origin
// 축소는 전부 transform 이라 리플로우가 0이다.
var ns = document.querySelector('.ns');
var feed = ns.querySelector('.ns__feed');
var head = ns.querySelector('.ns__head');
new IntersectionObserver(function (entries) {
  head.classList.toggle('is-compact', !entries[0].isIntersecting);
}, { root: feed }).observe(ns.querySelector('.ns__sentinel'));

05현재 칸을 알려 주는 스티키 서브 내비

섹션이 지나가는 동안 카테고리 바는 자리에 붙어 있고, 화면에 걸린 섹션이 바뀌면 강조가 그 칸으로 옮겨 갑니다. 현재 섹션은 스크롤 상자를 루트로 삼은 IntersectionObserver가 화면 중앙과 가장 가까운 섹션으로 골라 칩의 aria-current를 갈아 씁니다. 설명서·랜딩처럼 한 페이지가 여러 섹션인 곳에 맞습니다.

position: stickyIntersectionObserveraria-current
var io = new IntersectionObserver(function (entries) {
  var w = win.getBoundingClientRect();
  var mid = w.top + w.height / 2;
  var best = null, gap = Infinity;
  entries.forEach(function (e) {
    if (!e.isIntersecting) return;
    var r = e.boundingClientRect;
    var d = Math.abs((r.top + r.bottom) / 2 - mid);
    if (d < gap) { gap = d; best = e.target; }
  });
  if (best) mark(best);
}, { root: win, threshold: 0.5 });

06햄버거가 X 로 접히는 풀스크린 오버레이

세 줄이 벌어지며 X자로 접히고 화면 전체를 덮는 판 위로 링크들이 하나씩 밀려 올라옵니다. 판이 열려 있는 동안 키보드 초점은 판 안에만 머뭅니다. 막대의 모프는 translateYrotate 조합이고 링크 등장은 항목마다 시차를 두므로, 모바일·포트폴리오의 주 메뉴로 썼을 때 첫인상이 문 하나가 아니라 안내판이 됩니다.

rotatetranslateYaria-modal
ov.addEventListener('keydown', function (e) {
  if (e.key !== 'Tab' || links.length === 0) return;
  var first = links[0], last = links[links.length - 1];
  if (e.shiftKey && document.activeElement === first) {
    e.preventDefault(); last.focus({ preventScroll: true });
  } else if (!e.shiftKey && document.activeElement === last) {
    e.preventDefault(); first.focus({ preventScroll: true });
  }
});

07오른쪽에서 밀려오는 사이드 드로어

버튼을 누르면 오른쪽 가장자리에서 패널이 밀려 들어오고 남은 화면은 어두워지며 손댈 수 없게 되는데, 닫히면 초점은 눌렀던 버튼으로 돌아옵니다. 잠금은 inert 속성 하나로 합니다. 본문에 inert를 붙이면 뒤쪽 링크가 시각적으로 남아 있어도 탭과 스크린리더가 건너뛰고 드로어가 닫히면 반대로 돌아가며, 이 서랍 짝은 장바구니·계정 메뉴처럼 옆에서 열리는 화면에 맞습니다.

translateXinertaria-hidden
function setOpen(on) {
  root.classList.toggle('is-open', on);
  page.inert = on;                                    // 열린 동안 본문은 포커스를 잃는다
  page.setAttribute('aria-hidden', on ? 'true' : 'false');
  drawer.inert = !on;
  drawer.setAttribute('aria-hidden', on ? 'false' : 'true');
  openBtn.setAttribute('aria-expanded', on ? 'true' : 'false');
  (on ? closeBtn : openBtn).focus({ preventScroll: true });
}

08아이콘에서 입력창으로 펼쳐지는 검색

돋보기를 누르면 알약 모양 입력창이 아이콘 자리에서 펼쳐지고, 몇 글자 넣으면 건너뛰기 링크가 그 아래로 좁게 따라옵니다. 펼침은 폭을 늘리는 대신 항상 전체 폭인 입력창을 clip-path: inset으로 오른쪽부터 덮었다가 치우는 방식이라 레이아웃이 다시 계산되지 않습니다. 상단 여백이 아까운 문서·블로그 헤더에 맞습니다.

clip-pathinsetaria-expanded
$clip-shut: inset(0 calc(100% - 40px) 0 0 round 999px);   // 아이콘 칸 40px 만 보임
$clip-open: inset(0 0 0 0 round 999px);                    // 전부 보임

.search {
  clip-path: $clip-shut;
  transition: clip-path $dur-base $easing;
}
.frame.is-open .search { clip-path: $clip-open; }

09읽음 표시가 살아 있는 알림 배지 메뉴

종에 매달린 숫자가 안 읽은 알림 수를 세고, 패널에서 항목을 읽으면 점이 지워지며 숫자도 그 즉시 따라 줄어듭니다. 배지 숫자는 숫자 세 개를 겹쳐 두고 steps(1, end)로 한 프레임에 갈아 끼우므로 반투명하게 겹치는 순간이 없고, 화면 밖 aria-live 영역이 수를 소리내어 갱신합니다. 앱 상단의 알림 센터에 씁니다.

steps(1, end)aria-livetranslateY
function sync() {
  var n = unread().length;
  root.classList.toggle('is-clear', n === 0);
  badge.setAttribute('data-n', String(n));
  live.textContent = live.dataset.fmt.replace('{n}', String(n));
}

어디서 깨지는가 — 함정

이번에 손이 멈춘 곳은 세 군데였습니다.

첫째는 02 의 폭이었습니다. 320px 폰에서 L1·L2·L3 의 폭을 각각 112·96·84px로 줄여도 본문 폭이 329px로 측정되었습니다. 원인은 슬라이드 자체였습니다. 하위 단계는 left: 100%로 부모 항목 옆에 붙는데, 슬라이드 중인 부모의 transform: translateX(12px)가 절대 위치한 자식에도 얹히므로, L2 와 L3 이 동시에 밀리는 순간 최대 24px 만큼 더 나갑니다. 미디어쿼리에서 폭을 108·92·80px로 낮춰 12.8 + 280 + 24 = 316.8px 을 맞췄습니다. 무대 안에서는 멀쩡해도 폰에서는 처음 펼친 순간에만 넘치는, 눈으로 한 번 봐야 잡히는 값입니다. 절대 위치로 띄운 목록은 부모의 높이 계산에서도 빠지므로, 폭과 함께 세로 여유도 뿌리에 예약해 두는 편이 안전합니다.

둘째는 대표 장면이었습니다. 격자 미리보기의 poster 는 앞 프레임과 가장 크게 달라진 장을 뽑는데, 여닫는 패널에서는 닫히는 순간의 변화가 열리는 순간보다 크게 나오기 쉽습니다. 열림 때는 없던 열 제목과 카드가 닫힘 때는 이미 차 있어서입니다. 처음에는 닫힘을 서서히 페이드했지만 소용이 없었습니다. 이 편의 측정은 한 픽셀에서 최대 채널 차이가 12 를 넘으면 그 픽셀을 움직였다고 세는데, 크림색 패널이 잉크 무대 위에서는 알파가 절반으로 내려가도 차이가 그 문턱을 넘기 때문에, 면적이 절반으로 줄지 않았습니다. 결국 닫힘을 opacity 가 아니라 clip-path: inset의 위프로 바꿔 한 프레임이 감당하는 면적을 쪼갰습니다. 위프의 방향은 위에서 아래로 접는 쪽으로 정했는데, 패널이 상단 행에 매달려 있어 매단 쪽부터 차례로 내려앉는 모양새가 자연스러웠기 때문입니다. 01·03·09 의 poster 가 이 방식으로 패널이 열린 장을 다시 차지했습니다. 아홉 장의 poster 는 마지막까지 반드시 눈으로 확인했습니다. 수치가 다 갖춰져 있어도 뽑힌 한 장이 주인공을 비추고 있는지는 기계가 잴 수 없는 자리입니다.

셋째는 그 위프가 09 에서 처음에 움직이지 않았던 일입니다. 패널에 걸린 애니메이션 중 하나가 steps(1, end) 타이밍을 쓰고 있었는데, 같은 키프레임에 넣은 clip-path까지 계단으로 잘려 위프가 한 번의 컷이 되어 버렸습니다. clip-path 를 이징이 걸린 다른 애니메이션으로 옮겨서야 프레임마다 조금씩 접혔습니다. 계단은 값을 가르는 도구이지, 값을 스캔하는 도구가 아닙니다. 같은 이유로 시차를 주는 요소의 지연은 animation: 단축이 지워 버리므로 낱개 속성으로 쓰는 습관이 이번에도 답이 되었습니다. 아홉 개 폴더를 여는 압축 비밀번호는 kes5pp8b 이고, 위에서 말한 수치는 전부 run 폴더의 측정.json 에 그대로 남아 있습니다.

접근성

아홉 개 모두 prefers-reduced-motion: reduce에서 자동 루프를 끄고 각자의 정적 상태를 남깁니다. 01·02·03 은 패널이 열린 채로 서고, 04 는 큰 헤더로 돌아가며, 05 는 카테고리 바가 제목 아래 제자리에 멈춥니다. 06 은 오버레이가 열리고 막대가 X 로 접힌 한 장을 보여 주고, 07 은 드로어가 열린 장을, 08 은 입력창이 펼쳐진 장을, 09 는 안 읽은 세 줄이 그대로 남습니다. 문은 애니메이션이지만 문의 상태는 값이므로, 움직임을 꺼도 정보는 줄지 않습니다. 열림 판정은 전부 aria-expanded에 적히고 aria-haspopup은 스크립트가 처음에 한 번 붙입니다. 포커스를 되돌리는 다섯 데모는 모두 focus({ preventScroll: true })를 쓰는데, 이 데모들이 홈 목록에 iframe 으로 박혀 있어 무가드 포커스가 부모 페이지를 끌고 내려가기 때문입니다.

대비 표의 숫자는 WCAG 상대휘도 식에 넣어 이 편의 조합마다 손으로 구했습니다.

쓰이는 곳 글자 바탕 대비
01·03·08 본문 잉크 / 흰 패널 #17141a #ffffff 18.24:1
01 강조 카드 흰 글자 / 파랑 #ffffff #2f6df6 4.53:1
02·07·09 잉크 / 크림 패널 #17141a #fff7e6 17.11:1
04 잉크 / 노랑 무대 #17141a #ffd23f 12.63:1
05 하이라이트 칩 크림 글자 / 잉크 칩 #fff7e6 #17141a 17.11:1
06 큰 링크 크림 / 오버레이 #fff7e6 #241f2d 15.05:1
마우스 키보드 상태가 적히는 곳
01 트리거에 올리거나 누른다 포커스로 열리고 Esc 로 닫힌다 트리거 aria-expanded
02 항목에 올린다 Tab 으로 하위 진입, Esc 로 전체 닫기 각 상위 버튼 aria-expanded
03 탭을 누른다 좌우 화살표로 탭 이동, Esc 닫기 aria-selected, 패널 hidden
04 본문을 스크롤한다 스크롤 상자 자체가 포커스를 가진다 헤더의 .is-compact 클래스
05 섹션을 지나간다 칩 앵커로 섹션 이동 aria-current
06 버튼을 누른다 Tab 순환이 판 안에 갇히고 Esc 닫힌다 aria-modal, 버튼 aria-expanded
07 버튼·오버레이·닫기를 누른다 본문은 inert, Tab 은 드로어 안, Esc 닫기 드로어·본문 aria-hidden
08 돋보기를 누르고 입력한다 Esc 로 접히고 초점은 버튼으로 버튼 aria-expanded
09 종과 줄·전체 읽기를 누른다 첫 안 읽은 줄로 초점, Esc 닫기 aria-live 영역의 수 문장

판정 기준은 MDN 의 aria-haspopup 문서IntersectionObserver 문서를 따랐습니다. 같은 축의 다른 이웃으로 떠 있는 독 내비게이션스티키 헤더, 사이드 드로어 전환가 있습니다.

FAQ

메가 메뉴는 언제 쓰지 말아야 하나요

항목이 일곱 개 아래로 줄지 않을 때입니다. 패널의 이점은 한 번에 보여 주는 지도인데, 항목이 적으면 지도가 목록보다 느려집니다. 이럴 때는 02 의 다단 드롭다운이나 폼 안이라면 셀렉트 드롭다운이 더 빠른 답입니다. 편집거리가 아니라 읽는 사람의 지도 크기로 정합니다.

호버로 열지 클릭으로 열지는 무엇으로 정하나요

패널이 열릴 때 화면의 절반 이상을 덮으면 클릭, 그보다 작으면 호버입니다. 호버는 지나가는 길에 열리는 만큼 남의 조작을 훔치기 쉽고, 01 처럼 닫힘을 마우스가 바깥으로 나가는 순간에 묶어야 애매한 상태가 남지 않습니다. 01 도 호버와 동시에 포커스·클릭을 열림 조건에 넣어 터치와 키보드를 함께 지원합니다.

모바일에서는 이 패턴들을 어떻게 바꾸나요

펼침은 접힘으로 갈아입습니다. 데스크톱의 메가 패널은 모바일에서 06 의 풀스크린 오버레이나 07 의 드로어로 바뀌는 게 보통이고, 08 의 검색은 아이콘에서 시작하는 구조 그대로 좁은 폭과 잘 맞습니다. 바꿀 때 남길 것은 동작이 아니라 aria-expanded·inert·포커스 복귀 같은 상태 약속입니다. 화면이 좁아져도 약속이 같으면 같은 메뉴입니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.