GODRICH

브레드크럼 UI 9종 — 경로가 길어도 안 깨지는 내비게이션

브레드크럼 UI는 지금 열린 페이지가 사이트 어디에 있는지 홈부터 차례로 경로로 보여 주는 내비게이션입니다. 경로가 길어도 안 깨지는 아홉 벌을 실제로 돌아가는 코드로 드립니다.

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

아홉 개는 예쁜 순서가 아니라 경로가 화면에서 겪는 일의 순서입니다. 처음 세 개는 모양입니다. 구분자를 바꿔 분위기를 잡고(01), 경로가 길어지면 가운데를 접고(02), 내가 지금 어느 칸인지 확실히 보여 줍니다(03). 다음 두 개는 이동입니다. 같은 층의 옆 카테고리를 미리 보여 주고(04), 폭이 줄면 앞 단계부터 점 세 개 뒤로 숨깁니다(05). 여섯 번째는 정보를 압축하고(06), 일곱 번째는 단계 사이를 오가는 움직임을 붙입니다(07). 마지막 두 개는 화면 밖 이야기입니다. 검색엔진이 읽는 경로를 따로 두고(08), 좁은 화면에서는 위로 한 칸만 남깁니다(09). 아홉 개 전부 navol·li 마크업 위에 만들었고, 마우스가 없는 사람을 위한 키보드 조작이 각각 실제로 들어 있습니다.

01구분자 세 가지

같은 경로가 슬래시, 꺾쇠, 가운뎃점 세 가지 구분자로 바뀌는 모음입니다. 슬래시와 가운뎃점은 마크업에 넣지 않고 li:not(:last-child)::aftercontent로 그리고 꺾쇠는 aria-hidden을 붙인 아이콘으로 두어, 읽기 프로그램에는 칸 이름만 들립니다. 알약을 누르면 그 구분자로 고정되고 좌우 화살표 키로도 옮길 수 있습니다.

::aftercontentsteps(1, end)
function pick(i) {
  root.classList.remove('is-demo');
  ind.style.setProperty('--i', i);
  pills.forEach(function (p, n) { p.setAttribute('aria-pressed', n === i ? 'true' : 'false'); });
  lists.forEach(function (l, n) {
    var on = n === i;
    l.classList.toggle('is-on', on);
    l.setAttribute('aria-hidden', on ? 'false' : 'true');
    [].forEach.call(l.querySelectorAll('a'), function (a) { a.tabIndex = on ? 0 : -1; });
  });
}

02중간 단계 접기 메뉴

경로가 네 단계를 넘어가면 가운데 단계들을 점 세 개 버튼 하나로 접습니다. 버튼을 누르면 숨은 단계가 목록으로 내려오고 Escape로 닫히며, 목록 안에서는 위아래 화살표로 움직입니다. 상태는 aria-expanded가 그대로 알려 줍니다.

aria-expandedtranslateYEscape
document.addEventListener('keydown', function (e) {
  if (e.key === 'Escape' && 열림()) { e.preventDefault(); 열기(false, true); return; }
  if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
  var i = links.indexOf(document.activeElement);
  if (i < 0 && document.activeElement !== btn) return;
  e.preventDefault();
  if (!열림()) { 열기(true, false); return; }
  var 걸음 = e.key === 'ArrowDown' ? 1 : links.length - 1;
  links[(i + 걸음 + links.length) % links.length].focus({ preventScroll: true });
});

03현재 페이지 강조

경로의 마지막 칸만 링크가 아닌 글자로 남고 굵기와 밑줄이 함께 붙습니다. 그 칸의 aria-current="page"가 읽기 프로그램에 지금 페이지가 여기라고 알려 주고, 밑줄은 text-underline-offset으로 글자에서 살짝 띄워 두었습니다. 앞쪽 칸을 누르면 그 칸이 새로운 현재 페이지가 되고 그 뒤의 칸은 함께 숨습니다.

aria-currentfont-weighttext-underline-offset
function 현재로(i) {
  root.classList.remove('is-demo');
  지금 = i;
  lis.forEach(function (li, n) {
    li.hidden = n > i;
    var c = crumbs[n];
    c.classList.toggle('ce__cur', n === i);
    if (n === i) c.setAttribute('aria-current', 'page');
    else c.removeAttribute('aria-current');
  });
  val.textContent = crumbs[i].textContent;
}

04형제 경로 미리보기

가운데 단계에 마우스를 올리거나 아래 화살표를 누르면 같은 층의 다른 카테고리가 목록으로 먼저 나옵니다. 위로 올라갔다 다시 내려오지 않고 옆으로 바로 건너뛰게 하는 장치이고, 목록 안에서도 위아래 화살표로 움직입니다. 칸에는 aria-haspopuparia-expanded가 붙어 있습니다.

aria-haspopupArrowDownopacity
function open(first) {
  live();
  item.classList.add('is-open');
  btn.setAttribute('aria-expanded', 'true');
  if (first) opts[0].focus({ preventScroll: true });
}
function shut(back) {
  item.classList.remove('is-open');
  btn.setAttribute('aria-expanded', 'false');
  if (back) btn.focus({ preventScroll: true });
}
item.addEventListener('pointerenter', function () { open(false); });
item.addEventListener('pointerleave', function () { shut(false); });

05폭에 맞춰 자동 축약

상자 폭이 줄면 홈 다음 세 칸이 앞에서부터 하나씩 점 세 개 뒤로 숨어 경로가 절대 두 줄이 되지 않습니다. ResizeObserver가 폭을 지켜보다가 목록의 scrollWidthnav 폭보다 커지는 순간 그 칸에 hidden을 붙이고, 폭이 돌아오면 반대로 되돌립니다. 손잡이를 직접 끌어 같은 로직을 시험할 수 있습니다.

ResizeObserverscrollWidthhidden
function fit() {
  list.style.maxWidth = 'none';           // 재는 동안만 자연 폭으로
  숨길것.forEach(function (el) { el.hidden = false; });
  dots.hidden = true;
  for (var i = 0; i < 숨길것.length && list.scrollWidth > nav.clientWidth; i++) {
    dots.hidden = false;
    숨길것[i].hidden = true;
  }
  list.style.maxWidth = '';
}
new ResizeObserver(fit).observe(panel);

06아이콘과 글자 조합

첫 칸은 집 아이콘만 두고 나머지 칸은 아이콘과 글자를 함께 두어 단계마다 정보의 양이 달라집니다. 아이콘은 currentColor로 글자 색을 그대로 물려받아 칸이 어느 상태든 같은 색을 유지합니다. 아래 버튼으로 가운데 칸의 글자를 접었다 펼 수 있습니다.

currentColorinline-flexaria-label
toggle.addEventListener('click', function () {
  root.classList.remove('is-demo');
  var on = root.classList.toggle('is-compact');
  toggle.setAttribute('aria-pressed', on ? 'true' : 'false');
  toggle.textContent = on ? '아이콘과 글자' : '아이콘만 보기';
});

07단계 전환 애니메이션

위 단계를 누르면 그 뒤에 있던 칸이 한꺼번에 왼쪽으로 미끄러져 나가고, 다시 내려갈 때는 같은 칸이 오른쪽에서 밀려 들어옵니다. 칸이 빠져나가는 애니메이션이 끝나는 animationend 순간에 그 칸을 hidden으로 처리해 문서 크기가 흔들리지 않습니다.

translateXcubic-bezieranimationend
list.addEventListener('animationend', function (e) {
  var li = e.target.closest('.st__item');
  if (!li) return;
  if (li.classList.contains('is-out')) { li.hidden = true; li.classList.remove('is-out'); }
  else if (li.classList.contains('is-in')) { li.classList.remove('is-in'); }
});
list.addEventListener('click', function (e) {
  var a = e.target.closest('.st__link');
  if (!a || !a.hasAttribute('data-i')) return;
  e.preventDefault();
  stop();
  for (var k = items.length - 1; k > +a.getAttribute('data-i'); k--) {
    if (!items[k].hidden) leave(items[k]);
  }
});

08구조화 데이터 경로

화면의 경로와 같은 내용을 BreadcrumbList JSON-LD로 한 벌 더 둡니다. 문서 안에 진짜 script 태그가 있고, 버튼을 누르면 화면의 ol을 실제로 읽어 positionname, item을 만들어 다시 씁니다. 위쪽 카드는 그 경로가 검색 결과에서 어떤 줄이 되는지 보여 줍니다.

BreadcrumbListapplication/ld+jsonitemListElement
function build() {
  var out = [], n = 0;
  for (var i = 0; i < all.length; i++) {
    if (all[i].hidden) continue;
    var a = all[i].querySelector('.jl__crumb');
    n++;
    out.push({ "@type": "ListItem", position: n, name: a.textContent.trim(),
               item: "https://godrichstory.com/" + a.getAttribute('data-seg') });
  }
  ld.textContent = JSON.stringify({ "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: out });
}

09모바일 뒤로 가기형

폭이 좁아지면 경로 전체가 바로 위 단계 하나만 남은 뒤로 가기 줄로 접힙니다. matchMedia로 실제 화면 폭을 상시 구독해 스스로 갈아 끼우고, 폭이 돌아오면 전체 경로가 다시 펼쳐집니다. 뒤로 가기 줄에는 aria-label로 어디로 돌아가는지 적어 두었습니다.

matchMediatranslateXaria-label
var mq = window.matchMedia('(max-width: 420px)');
function apply(narrow) {
  root.classList.remove('is-demo');
  root.classList.toggle('is-narrow', narrow);
  chips.wide.setAttribute('aria-pressed', String(!narrow));
  chips.narrow.setAttribute('aria-pressed', String(narrow));
}
function reflect() { apply(manual === null ? mq.matches : manual); }
if (mq.addEventListener) { mq.addEventListener('change', function () { manual = null; reflect(); }); }

어디서 깨지는가 — 함정

가장 오래 붙잡고 있던 함정은 05번이 폭을 재는 대목이었습니다. 목록에 max-width: 100%를 걸어 두면 마지막 칸이 알아서 줄바꿈 표시로 바뀌면서 scrollWidth가 실제보다 작게 잡혀, 폭이 좁아져도 칸이 절대 접히지 않았습니다. 재는 동안만 maxWidth를 풀어 자연 폭을 잰 뒤 다시 묶는 세 줄을 넣어서야 칸이 움직이기 시작했습니다. 렌더러의 시계로 확인해 보면 폭이 209px로 좁아진 순간 홈 다음 세 단계가 점 세 개 뒤로 숨었고, 311px로 돌아오자 노트북이 먼저 되돌아왔습니다. 칸을 숨기는 속성도 함정인데, hidden을 붙여도 flex 자식은 사라지지 않는 경우가 있어 [hidden] { display: none }을 직접 적어야 했습니다.

두 번째는 눈에 보이지 않는 목록의 높이였습니다. 02번과 04번의 펼쳐지는 목록은 절대 배치라 부모의 높이 계산에서 빠지는데, scrollHeight에는 그대로 잡혀 320px 화면에서 스크롤바가 생겼습니다. 뿌리에 목록 높이만큼 padding-bottom을 미리 확보해 두어야 두 화면에서 다 들어갑니다. 세 번째는 움직임을 담은 그림이었습니다. 06번은 칸이 떠오르는 동안 투명도 구간이 길어 대표 그림에 두 상태가 반투명하게 겹쳐 나왔고, 투명도가 오가는 구간을 프레임 두 개로 압축하니 강도가 46에서 91.9까지 올라갔습니다. 상태가 바뀌는 장면은 겹치지 않게 한 프레임에 교체하는 것이 정답이었습니다. 이렇게 잡은 아홉 벌 소스는 압축 비밀번호 vfj8frcg를 넣으면 열리는 zip 안에 바닐라와 React 판이 같은 값으로 들어 있습니다.

접근성

아홉 개 전부 navaria-label을 붙이고 항목은 ol·li로 적었으며, 구분자는 읽기 프로그램에 들리지 않도록 의사요소나 aria-hidden을 붙인 아이콘으로만 그렸습니다. 현재 페이지를 알리는 aria-current는 아홉 개 전부에 들어 있고 03·06·08번은 칸을 누를 때마다 그 자리를 옮기며, 접히는 목록을 여는 02·04번은 aria-expanded로 열림 상태를 알립니다. 키보드도 마우스와 같은 길을 갑니다. 01번은 알약과 좌우 화살표, 02·04번은 아래 화살표로 열어 목록 안을 위아래로 돌고 Escape로 닫으며, 05번은 손잡이에 좌우 화살표, 09번은 뒤로 가기 버튼이 Enter로 동작합니다. 포커스를 옮기는 코드는 전부 preventScroll 옵션과 함께 두어 데모가 문서를 끌어당기지 않게 했습니다. prefers-reduced-motion: reduce에서는 격자용 자동 재생과 전환 효과를 끄고 실제 조작과 상태 값은 그대로 남깁니다. aria-current의 쓰임은 MDN의 aria-current 문서에, 폭 관측은 MDN의 ResizeObserver 문서에 정리돼 있습니다.

경로 안내 부품을 더 찾는다면 내비게이션 카테고리에, 이 글처럼 JS로 움직이는 부품은 JS 카테고리에 모여 있습니다.

FAQ

브레드크럼이 검색 노출에 정말 도움이 되나요?

화면에 그리는 것만으로는 부족하고 08번처럼 BreadcrumbList 구조화 데이터를 함께 두어야 검색엔진이 경로를 이해합니다. 구글은 페이지를 다시 읽은 뒤 결과의 주소 자리에 경로를 보여 주기 시작하는데, 그 전에 구조화 데이터가 이미 있어야 합니다. 08번 데모처럼 화면의 목록에서 배열을 만들면 두 벌이 어긋나지 않습니다.

마지막 칸도 링크로 두면 안 되나요?

현재 페이지를 자기 자신에게 링크하면 같은 페이지로 도는 길이 생겨 권하지 않습니다. 마지막 칸은 링크가 아닌 글자로 두고 aria-current="page"를 붙이면 눈으로도 읽기 프로그램으로도 지금 위치가 분명해집니다. 03번이 정확히 그 모양입니다.

구분자를 마크업에 글자로 넣어도 되나요?

동작은 하지만 칸 사이마다 슬래시와 꺾쇠가 낱개로 읽혀 경로가 끊겨 들립니다. 구분자는 li:not(:last-child)::aftercontentaria-hidden을 붙인 그림으로 그리는 편이 낫고, 01번은 그 차이를 세 가지 구분자에서 한 번에 보여 줍니다.

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

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