GODRICH

알림 목록 ui 9가지 — 사이드바에 박히는 활동 피드와 이력 위젯

알림 목록 ui는 떴다 사라지는 토스트가 아니라 화면 한구석에 자리를 잡고 계속 쌓이는 목록을 말합니다. 사이드바에 박히는 이 아홉 개 위젯은 읽음 표시, 고치기 전 값, 로그 수준, 오늘의 건수까지 전부 실제 상태를 바꾸며 돌아갑니다.

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

순서는 위젯을 만드는 사람이 아니라 목록이 겪는 순서입니다. 먼저 팀의 일이 위에서부터 쌓이고(01), 고친 값의 앞뒤가 남고(02), 읽은 것과 안 읽은 것이 갈라집니다(03). 밖에서 언급이 굴러 들어오고(04), 댓글이 답글 뭉치로 접히며(05), 서류가 제출에서 승인까지 단계를 지나갑니다(06). 여기까지가 쌓이는 일입니다. 다음은 고르는 일입니다. 로그에서 수준을 고르고(07), 막 생긴 서비스의 빈 피드가 첫 활동으로 깨어납니다(08). 마지막으로 쌓인 것을 한눈에 접어 요약 카운터가 오늘을 셉니다(09).

01새 활동이 위에서 등장하는 팀 활동 스트림

누가 무엇을 언제 했는지가 아바타와 문장과 시간 한 줄로 쌓이는 목록입니다. 목록 전체에 role="log"aria-live 를 붙여 두었고, 활동 추가를 누르면 새 줄이 접혔던 자리에서 펼쳐지며 위에 얹히고 나머지가 한 칸씩 내려갑니다.

role="log"aria-livetranslateY
document.getElementById('ts-add').addEventListener('click', function () {
  ts.classList.remove('is-demo');
  var row = document.getElementById('ts-tpl').content.firstElementChild.cloneNode(true);
  row.classList.add('is-shut');
  tsList.insertBefore(row, tsList.firstChild);
  void row.offsetWidth; // 두 단계로 나눠야 grid 전환이 애니메이션으로 잡힌다
  row.classList.remove('is-shut');
  tsCountUp();
});

02고친 값을 되돌리는 변경 이력 diff

수정 전 값은 del 에 줄이 그어지고 수정 후 값은 ins 에 밑줄이 그어져 한눈에 앞뒤가 보입니다. 항목마다 붙은 되돌리기 단추를 누르면 그 항목만 이전 값으로 돌아가고 남은 수정 수가 갱신됩니다.

delinssteps(1, end)
b.addEventListener('click', function () {
  dh.classList.remove('is-demo');
  var row = b.closest('.dh__row');
  var back = row.classList.toggle('is-back');
  b.setAttribute('aria-pressed', back ? 'true' : 'false');
  dhLeft += back ? -1 : 1;
  dhCount();
});

03읽음과 안 읽음이 갈리는 알림 목록

안 읽은 항목은 흰 카드에 파란 점이 맥박치고, 열면 점이 사라지며 읽은 색으로 한 프레임에 바뀝니다. 모두 읽음을 누르면 남은 항목 전부가 읽음이 되고 남은 수가 갱신되어 읽힙니다.

scalesteps(1, end)aria-live
function ncSync() {
  var left = ncRows.filter(function (r) {
    return !r.classList.contains('is-read');
  }).length;
  ncBadge.textContent = ncBadge.getAttribute('data-t')
    .replace('{n}', left);
}
document.getElementById('nc-all').addEventListener('click', function () {
  nc.classList.remove('is-demo');
  ncRows.forEach(function (r) { r.classList.add('is-read'); });
  ncSync();
});

04옆에서 굴러 들어오는 소셜 언급 카드

누군가 언급할 때마다 카드가 오른쪽에서 굴러 들어와 살짝 틀어졌다 바로 섭니다. 새 언급은 등장만 하고 사라지지 않으며, 목록이 role="log" 라 언급이 차례로 읽힙니다.

translateXrotaterole="log"
document.getElementById('mn-more').addEventListener('click', function () {
  mn.classList.remove('is-demo');
  var row = document.getElementById('mn-tpl').content.firstElementChild.cloneNode(true);
  mnList.insertBefore(row, mnList.firstChild);
  row.addEventListener('animationend', function () {
    row.classList.remove('is-in');
  }, { once: true });
});

05답글이 접히는 댓글 스레드 미니

원댓글 아래 답글 뭉치가 grid-template-rows0fr1fr 사이에서 접혔다 펼쳐지고, 토글에는 aria-expanded 가 붙습니다. 댓글 달기를 누르면 새 답글이 아래에서 떠오릅니다.

grid-template-rowsaria-expandedtranslateY
function ctOpen(on) {
  ctRe.classList.toggle('is-open', on);
  ctTg.setAttribute('aria-expanded', on ? 'true' : 'false');
  ctTg.classList.toggle('is-open', on);
}
ctTg.addEventListener('click', function () {
  ct.classList.remove('is-demo');
  ctOpen(!ctRe.classList.contains('is-open'));
});

06단계마다 불이 들어오는 상태 배지 타임라인

제출·검토·승인 세 단계가 세로로 이어지고 이음선이 현재 단계까지 차오릅니다. 다음 단계를 누르면 지금 단계만 aria-current 를 받고 단계 이름이 안내 문구에도 반영됩니다.

scaleYsteps(1, end)aria-current
function stGo(i) {
  stAt = (i + stRows.length) % stRows.length;
  stRows.forEach(function (r, n) {
    r.classList.toggle('is-now', n === stAt);
    r.classList.toggle('is-done', n < stAt);
    if (n === stAt) { r.setAttribute('aria-current', 'step'); }
    else { r.removeAttribute('aria-current'); }
  });
  stPhase.setAttribute('data-at', String(stAt));
}

07수준 칩으로 거르는 시스템 로그

정보·경고·오류 칩이 aria-pressed 로 눌린 상태를 기억하고, 눌린 수준의 줄만 남으며 나머지 줄은 hidden 으로 화면에서 빠집니다. 칩을 전부 끄면 걸러짐이 없으므로 줄 전부를 다시 보여 줍니다.

aria-pressedhiddensteps(1, end)
function lgFilter() {
  var on = lgChips.filter(function (c) {
    return c.getAttribute('aria-pressed') === 'true';
  }).map(function (c) { return c.getAttribute('data-k'); });
  var showAll = on.length === 0 || on.length === lgChips.length;
  lgRows.forEach(function (r) {
    r.hidden = !(showAll || on.indexOf(r.getAttribute('data-k')) >= 0);
  });
}

08빈 피드가 첫 활동으로 바뀌는 순간

막 생긴 서비스의 피드는 비어 있는 시간이 더 깁니다. 빈 상태 안내는 컷 한 번으로 물러나고 첫 활동이 아래에서 떠오르며, 그 자리에 aria-live 를 두어 첫 활동이 낭독기에 전해집니다.

steps(1, end)translateYaria-live
function efSync(on) {
  ef.classList.toggle('has-act', on);
  efPhase.textContent = on
    ? efPhase.getAttribute('data-t1').replace('{n}', efCount)
    : efPhase.getAttribute('data-t2');
}
document.getElementById('ef-add').addEventListener('click', function () {
  ef.classList.remove('is-demo');
  var row = document.getElementById('ef-tpl').content.firstElementChild.cloneNode(true);
  efList.insertBefore(row, efList.firstChild);
  efCount++;
  efSync(true);
});

09오늘의 수와 시간대 막대를 세는 요약 카운터

오늘의 건수가 커지면 숫자가 갱신되어 읽히고, 시간대별 막대는 같은 눈금의 grid 위에서 밑뿌리를 두고 자라납니다. 새로 집계를 누르면 숫자가 실제로 올라가고 마지막 막대가 실제로 자랍니다.

aria-livescaleYtransform-origin
function tick(ts) {
  if (!t0) { t0 = ts; }
  var p = Math.min(1, (ts - t0) / 400);
  var v = Math.round(from + (to - from) * p);
  scN.textContent = String(v);
  if (p < 1) { requestAnimationFrame(tick); }
}
requestAnimationFrame(tick);
w.style.setProperty('--f', '.85'); // 막대 자체가 실제로 자란다

어디서 깨지는가 — 함정

가장 자주 부딪히는 곳은 한 요소에 같은 속성을 움직이는 애니메이션을 두 개 거는 것입니다. 03 의 안 읽은 점은 사라지는 컷과 맥박이 둘 다 transform 을 썼는데, 두 애니메이션이 같은 속성을 만지면 뒤에 적은 쪽이 이겨 컷이 아예 일어나지 않았습니다. 그래서 컷은 opacity 로 옮기고 맥박만 transform 에 남겼습니다. 09 의 막대도 자라는 애니메이션과 접는 애니메이션을 나란히 걸었다가 접는 쪽이 계속 이겨 막대가 영원히 자라지 않았고, 결국 한 바퀴 안에서 자라고 접는 한 애니메이션으로 합쳤습니다.

두 번째는 문장을 조각으로 쪼개어 두는 것입니다. 활동 문장을 이름과 조사와 목적어로 잘라 강조하면 한국어에서는 예쁘지만 영어판과 일본어판에서 어순이 뒤섞입니다. 그래서 문장은 이름 노드 하나와 나머지 한 노드로만 나누고, 목적어 강조는 문장 안 색 대신 줄 전체의 배경과 왼쪽 막대로 옮겼습니다. 언어가 바뀌어도 문장이 살아 있는 이유입니다.

세 번째는 배치용 transform 을 되돌리는 실수입니다. 06 의 단계 점은 레일 한가운데 놓으려고 translate(-50%, -50%) 를 쓰는데, 움직임을 끄는 블록에서 transform 을 통째로 초기화하면 점이 레일 구석으로 튀어 버립니다. 점은 애니메이션만 끄고 자리 잡은 transform 은 그대로 두는 것이 이 부품의 규칙입니다.

덧붙여 이음선처럼 가는 요소는 넓이가 좁아 누적면적이 작게 나옵니다. 06 의 이음선은 차오르는 동안에도 누적면적이 1.62% 로 아홉 개 중 가장 작았지만 변한 픽셀 안의 평균 차이는 92.6 에 이르렀습니다. 실제로 02 도 되돌림 컷이 주를 이루어 누적면적이 2.81% 로 낮게 나왔지만 강도는 72.3 을 지켰습니다. 가는 요소는 면적이 아니라 강도로 말하는 셈이라, 측정값을 볼 때 두 값을 나누어 읽어야 한다는 뜻입니다. 그래서 두 값은 늘 따로 확인합니다.

마지막은 좁은 화면입니다. 폰에서는 320px 칸의 세로가 174px 뿐이라 아홉 개 전부가 그대로 들어가지 않습니다. 그래서 줄을 비우는 위젯은 오래된 줄부터 화면에서 빼고(01·03·04·05), 로그는 마지막 줄부터 빼며(07), 나머지 넷은 글자와 간격만 줄입니다. 이 글의 아홉 벌은 전부 그 여유 안에서 재단되었습니다. 아홉 벌의 바닐라 판과 React 판을 같은 값으로 담은 압축 파일의 비밀번호는 sub453jm 이며 안에는 이 글의 데모가 전부 들어 있습니다.

접근성

움직임을 줄여 달라는 설정이 켜진 화면에서는 아홉 벌 전부에서 반복 루프가 멈추고 결과 상태만 남습니다. 새 줄은 펼쳐진 채로, 읽은 항목은 읽은 색으로, 승인 타임라인은 검토를 지난 모습으로 멈춰 움직임이 사라져도 무엇이 확정됐는지는 화면에 그대로 있습니다.

피드 계열의 접근성은 결국 새 항목을 어떻게 알리는가로 정리됩니다. 쌓이는 목록은 role="log"aria-live="polite" 로 묶어(01·04·05), 갱신되는 숫자와 안내 문구도 같은 값으로 읽힙니다(03·08·09). 누른 상태는 aria-pressed(02·07), 펼침은 aria-expanded(05), 지금 단계는 aria-current(06) 로 알립니다.

항목 눈으로 낭독기로 조작
01 팀 활동 스트림 새 줄이 위에서 등장 role="log" 줄 단위 낭독 활동 추가
02 변경 이력 diff 줄 그은 값 → 밑줄 값 되돌림 aria-pressed 되돌리기
03 알림 목록 점 맥박 → 읽은 색 남은 수 aria-live 항목 열기 · 모두 읽음
04 소셜 언급 카드가 옆에서 굴러 들어옴 role="log" 언급 낭독 언급 불러오기
05 댓글 스레드 답글 뭉치 접힘 aria-expanded 답글 토글 · 댓글 달기
06 상태 타임라인 이음선 차오름 aria-current="step" 다음 단계
07 로그 필터 수준 칩 눌림 · 줄 숨김 aria-pressed 칩 토글
08 빈 피드 전환 빈 상태 → 첫 활동 aria-live 첫 활동 안내 새 활동 · 비우기
09 요약 카운터 막대 stagger 성장 건수 aria-live 새로 집계

role="log" 를 언제 토스트 대신 쓸지는 MDN 의 log role 문서에 기준이 정리되어 있습니다. 이 위젯들을 실제로 쓰는 화면은 알림 카테고리대시보드 카테고리에, 자바스크립트가 실제로 도는 부품은 JS 카테고리에 모여 있습니다.

FAQ

토스트 알림과 알림 목록은 어떻게 나누면 되나요?

사라져도 되는 소식이면 토스트, 남아 있어야 하는 기록이면 목록입니다. 토스트는 몇 초 뒤 저절로 사라지니 되돌아볼 수 없지만, 이 글의 목록은 줄이 화면에 남아 지나간 활동을 다시 읽게 해 줍니다. 둘의 접근성 역시 갈라지는데 토스트는 role="status", 쌓이는 목록은 role="log" 가 맞습니다.

새 항목이 계속 들어오면 낭독기가 과하게 읽지 않나요?

그래서 aria-live 를 목록 전체에 붙이고 새 줄 하나만 추가하는 방식을 씁니다. 이렇게 하면 방금 들어온 줄만 읽히고 이미 읽은 줄은 다시 읽히지 않습니다. 건수가 달라질 때는 숫자에 붙은 안내가 그 차이만 전하므로 목록 전체를 반복하지 않습니다.

피드가 수백 줄로 길어지면 어떻게 하나요?

전부 보여 주는 대신 세 갈래로 접습니다. 지난 대화는 접어 두고(05), 보고 싶은 것만 수준으로 걸러 내고(07), 오늘의 양은 막대로 요약합니다(09). 이 세 개를 사이드바에 함께 두면 긴 목록을 펼치지 않고도 흐름을 알 수 있습니다.

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

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