GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

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

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

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

순서는 위젯을 만드는 사람이 아니라 목록이 겪는 순서입니다. 먼저 팀의 일이 위에서부터 쌓이고(01), 고친 값의 앞뒤가 남고(02), 읽은 것과 안 읽은 것이 갈라집니다(03). 밖에서 언급이 굴러 들어오고(04), 댓글이 답글 뭉치로 접히며(05), 서류가 제출에서 승인까지 단계를 지나갑니다(06). 여기까지가 쌓이는 일입니다. 다음은 고르는 일입니다. 로그에서 수준을 고르고(07), 막 생긴 서비스의 빈 피드가 첫 활동으로 깨어납니다(08). 마지막으로 쌓인 것을 한눈에 접어 요약 카운터가 오늘을 셉니다(09). 푸시 알림 ui 와 갈리는 지점이 여기인데, 한 번 보여 주고 사라지는 쪽이 아니라 지나간 것까지 남겨 두고 다시 읽게 하는 쪽입니다. 같은 사이드바 한 칸을 차지하는 다른 붙박이로는 코드 블록 UI와 날씨 위젯이 있고, 세 편 모두 여기와 같은 320px 칸을 기준으로 재단했습니다. 다만 저 둘은 한 번 그려 놓으면 값이 잘 바뀌지 않는 붙박이인 반면, 여기 아홉 개는 줄이 계속 늘어난다는 전제 위에서 설계했습니다.

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

무엇이 보이나

누가 무엇을 언제 했는지가 아바타와 문장과 시간 한 줄로 쌓이는 목록입니다.

새 활동이 위에서 등장하는 팀 활동 스트림
새 창에서 열기

01 · 새 활동이 위에서 등장하는 팀 활동 스트림 — 측정 누적면적 22.602% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

role="log"aria-livetranslateY

코드에서 볼 곳

목록 전체에 role="log" 과 aria-live 를 붙여 두었고, 활동 추가를 누르면 새 줄이 접혔던 자리에서 펼쳐지며 위에 얹히고 나머지가 한 칸씩 내려갑니다.

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

타임라인 ui 에서 변경 이력은 화면에서 어떻게 보이나

수정 전 값은 del 에 줄이 그어지고 수정 후 값은 ins 에 밑줄이 그어져 한눈에 앞뒤가 보입니다.

고친 값을 되돌리는 변경 이력 diff
새 창에서 열기

02 · 고친 값을 되돌리는 변경 이력 diff — 측정 누적면적 2.813% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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읽음과 안 읽음이 갈리는 알림 목록

어떤 움직임인가

안 읽은 항목은 흰 카드에 파란 점이 맥박치고, 열면 점이 사라지며 읽은 색으로 한 프레임에 바뀝니다.

읽음과 안 읽음이 갈리는 알림 목록
새 창에서 열기

03 · 읽음과 안 읽음이 갈리는 알림 목록 — 측정 누적면적 15.917% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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옆에서 굴러 들어오는 소셜 언급 카드

무엇이 보이나

누군가 언급할 때마다 카드가 오른쪽에서 굴러 들어와 살짝 틀어졌다 바로 섭니다.

옆에서 굴러 들어오는 소셜 언급 카드
새 창에서 열기

04 · 옆에서 굴러 들어오는 소셜 언급 카드 — 측정 누적면적 30.385% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translateXrotaterole="log"

코드에서 볼 곳

새 언급은 등장만 하고 사라지지 않으며, 목록이 role="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-rows 의 0fr 과 1fr 사이에서 접혔다 펼쳐지고, 토글에는 aria-expanded 가 붙습니다.

답글이 접히는 댓글 스레드 미니
새 창에서 열기

05 · 답글이 접히는 댓글 스레드 미니 — 측정 누적면적 16.678% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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단계마다 불이 들어오는 상태 배지 타임라인

어떤 움직임인가

제출·검토·승인 세 단계가 세로로 이어지고 이음선이 현재 단계까지 차오릅니다.

단계마다 불이 들어오는 상태 배지 타임라인
새 창에서 열기

06 · 단계마다 불이 들어오는 상태 배지 타임라인 — 측정 누적면적 1.622% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

scaleYsteps(1, end)aria-current

어디를 바꾸면 되나

다음 단계를 누르면 지금 단계만 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 으로 화면에서 빠집니다.

수준 칩으로 거르는 시스템 로그
새 창에서 열기

07 · 수준 칩으로 거르는 시스템 로그 — 측정 누적면적 29.574% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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빈 피드가 첫 활동으로 바뀌는 순간

화면에서 일어나는 일

막 생긴 서비스의 피드는 비어 있는 시간이 더 깁니다.

빈 피드가 첫 활동으로 바뀌는 순간
새 창에서 열기

08 · 빈 피드가 첫 활동으로 바뀌는 순간 — 측정 누적면적 18.88% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

steps(1, end)translateYaria-live

코드 읽는 법

빈 상태 안내는 컷 한 번으로 물러나고 첫 활동이 아래에서 떠오르며, 그 자리에 aria-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 위에서 밑뿌리를 두고 자라납니다.

오늘의 수와 시간대 막대를 세는 요약 카운터
새 창에서 열기

09 · 오늘의 수와 시간대 막대를 세는 요약 카운터 — 측정 누적면적 7.003% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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

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

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

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

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

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

전부 보여 주는 대신 세 갈래로 접습니다. 지난 대화는 접어 두고(05), 보고 싶은 것만 수준으로 걸러 내고(07), 오늘의 양은 막대로 요약합니다(09). 이 세 개를 사이드바에 함께 두면 긴 목록을 펼치지 않고도 흐름을 알 수 있습니다. 줄이 아니라 숫자 자체가 1초마다 바뀌는 쪽까지 필요하면 실시간 시세판 UI가 같은 갱신 문제를 값의 편에서 풉니다.

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

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