GODRICH

대시보드 차트 디자인 인터랙션 9종 — 호버부터 드릴다운까지

대시보드 차트 디자인에서 진짜 일을 하는 건 그려진 선이 아니라 그 위에서 손이 하는 일입니다. 아홉 개 전부 차트 라이브러리 없이 CSS 막대와 인라인 SVG 위에 바닐라 JS만 얹었고, 만지면 숫자가 바뀌는 쪽으로만 골랐습니다.

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

순서는 사람이 차트를 읽어 가는 순서입니다. 먼저 값 하나를 확인하고(01), 볼 계열을 추리고(02), 기간을 손으로 긁어 잡습니다(03). 그다음은 파고드는 단계입니다. 한 달을 눌러 일별로 쪼개고(04), 미리 만들어 둔 기간으로 갈아 끼우고(05), 같은 값을 쌓기와 나란히로 뒤집어 봅니다(06). 마지막 셋은 판단하는 단계입니다. 목표선을 직접 옮겨 달성 개수를 세고(07), 두 시점을 찍어 차이를 읽고(08), 표의 한 줄을 확대해 그 채널만 들여다봅니다(09). 아홉 개를 하나로 묶는 약속이 있습니다. 눈에 보이는 모든 변화는 @property로 등록한 커스텀 속성과 aria-*·data-* 속성에만 담기고, 자바스크립트는 그 값을 고칠 뿐 그리는 일은 CSS에 맡깁니다. 그래야 자동 재생 미리보기와 사람이 실제로 만졌을 때가 같은 값을 공유합니다.

01마우스를 따라가며 가장 가까운 점에 달라붙는다

선 위에서 포인터를 옮기면 세로 점선이 점과 점 사이에 어정쩡하게 서지 않고 가장 가까운 날짜 칸으로 끌려갑니다. 마우스 x를 칸 비율로 바꿔 반올림한 정수 하나가 스냅의 전부이고, 나머지는 CSS가 그 정수를 읽어 그립니다. 24프레임 렌더에서 움직인 프레임은 23장, 누적면적은 4.833%입니다.

@propertypointermovearia-live
var N = 12;
function snap(i) {
  cur = Math.min(Math.max(i, 0), N - 1);
  ch.classList.remove('is-demo');
  ch.style.setProperty('--i', String(cur));
  ch.style.setProperty('--y', ys[cur] + 'px');
  tip.textContent = nums[cur] + '명';
  val.textContent = (cur + 1) + '일 · ' + nums[cur] + '명';
}
hot.addEventListener('pointermove', function (e) {
  var r = hot.getBoundingClientRect();
  var t = Math.min(Math.max((e.clientX - r.left) / r.width, 0), 1);
  snap(Math.round(t * (N - 1)));
});

02범례를 끄면 남은 막대가 다시 커진다

범례 칩을 누르면 그 계열이 바닥으로 접히고, 남은 계열의 최댓값으로 축이 다시 잡혀 나머지 막대가 전부 더 높이 자랍니다. 숨기는 게 아니라 축을 다시 재는 것이라, 카드 머리에도 합계 대신 축 최대를 460명에서 260명으로 바꿔 보여 줍니다.

aria-pressed@propertytransform-origin
function apply() {
  lg.classList.remove('is-demo');
  var on = [], max = 0;
  for (var k = 0; k < 3; k++) if (chips[k].getAttribute('aria-pressed') === 'true') on.push(k);
  on.forEach(function (k) { V[k].forEach(function (v) { if (v > max) max = v; }); });
  for (var i = 0; i < bars.length; i++) {
    var b = bars[i], s = +b.getAttribute('data-s');
    if (on.indexOf(s) < 0) { b.setAttribute('data-fold', '1'); }
    else {
      b.removeAttribute('data-fold');
      b.style.setProperty('--h', (max ? V[s][+b.getAttribute('data-m')] / max * 100 : 0).toFixed(1) + '%');
    }
  }
  lg.style.setProperty('--mx', String(max));
  live.textContent = '축 최대 ' + max + '명';
}

03드래그로 구간을 긁으면 요약 숫자가 바뀐다

14칸 막대 위를 옆으로 긁으면 그 구간만 밝게 남고 바깥은 가라앉으며, 머리글의 합계와 일수가 손을 떼기 전부터 다시 세어집니다. 밝은 띠는 절대 배치가 아니라 칸 하나 폭의 요소를 시작칸만큼 밀고 칸 수만큼 늘린 것이라, 칸이 몇 px이든 수식이 그대로 맞습니다.

pointerdownsetPointerCapturearia-live
.br__band {
  display: block;
  height: 100%;
  width: calc(100% / $cell);
  border-radius: $r-xs;
  background: rgba(47, 109, 246, .16);
  transform-origin: left center;
  transform: translateX(calc(var(--s) * 100%)) scaleX(calc(var(--e) - var(--s) + 1));
}

04월 막대를 누르면 그 자리에서 일별로 쪼개진다

월 막대를 누르면 화면이 갈아 끼워지지 않고 그 막대가 선 자리에서 일별 막대 다섯 개로 벌어집니다. 다섯은 처음부터 같은 칸에 겹쳐 있다가 월 막대가 비켜나면 각자 제 자리로 밀려나는데, 키프레임 안의 var(--i)가 그 애니메이션을 타는 요소마다 따로 풀리기 때문에 규칙 하나가 다섯 개를 각각 보냅니다.

aria-expandedtransform-originanimation-fill-mode
.dd.is-demo .dd__col[data-m="#{$demo-month}"] .dd__day {
  animation-name: dd-day;
  animation-duration: $dur-loop;
  animation-timing-function: $easing;
  animation-iteration-count: infinite;
  animation-delay: calc(var(--i) * 40ms);
  animation-fill-mode: backwards;
}
@keyframes dd-day {
  0%, 14% { transform: translateX(0) scaleX(1); }
  36%, 58% { transform: translateX(calc((var(--i) - 2) * 30%)) scaleX(.3); }
  78%, 100% { transform: translateX(0) scaleX(1); }
}

057일·30일·90일을 고르면 막대가 갈아입는다

기간을 바꿔도 막대는 여덟 칸 그대로고 높이만 새 값으로 흘러갑니다. 자바스크립트가 하는 일은 data-periodaria-selected를 고치는 것뿐이고, 여덟 칸의 높이도 밑줄 위치도 평균·최고 숫자도 전부 data-period 하나를 읽는 선택자에서 갈라집니다.

aria-selected@propertygrid-template-columns
.pg[data-period="7"]  { --seg: 0; --avg: 343; --max: 430; }
.pg[data-period="30"] { --seg: 1; --avg: 293; --max: 372; }
.pg[data-period="90"] { --seg: 2; --avg: 253; --max: 310; }

@each $p, $d in (7: $d7, 30: $d30, 90: $d90) {
  @for $i from 1 through 8 {
    .pg[data-period="#{$p}"] .pg__bar:nth-child(#{$i}) { --h: #{nth($d, $i)}; }
  }
}

06쌓기와 나란히를 오가며 같은 값을 다르게 본다

토글 하나로 같은 데이터가 쌓은 막대와 나란한 막대로 갈립니다. 지난 달 계열은 가로로 먼저 비켜난 뒤에 내려앉고 돌아올 때는 반대 순서를 밟는데, 가로와 세로를 한꺼번에 보간하면 검은 막대가 파란 막대를 대각선으로 뚫고 지나가는 프레임이 생기기 때문입니다.

aria-pressedgrid-columnsteps(1, end)
@keyframes sk-b#{$i} {
  0%, 34%   { transform: translateX(0) translateY(#{-1 * nth($ha, $i)}) scaleX(1); }
  44%       { transform: translateX(53%) translateY(#{-1 * nth($ha, $i)}) scaleX(.44); }
  52%, 76%  { transform: translateX(53%) translateY(0) scaleX(.44); }
  82%       { transform: translateX(53%) translateY(#{-1 * nth($ha, $i)}) scaleX(.44); }
  86%, 100% { transform: translateX(0) translateY(#{-1 * nth($ha, $i)}) scaleX(1); }
}

07목표선을 끌어올리면 넘긴 막대만 색이 바뀐다

가로 목표선을 위아래로 끌면 선을 넘은 막대만 색이 바뀌고 옆의 달성 개수가 손을 떼기 전에도 따라옵니다. 드래그는 setPointerCapture로 포인터를 붙잡아 차트 밖으로 나가도 끊기지 않고, 손잡이는 role="slider"라 ↑↓ 키로도 같은 값을 움직입니다. 24프레임 중 22장이 움직이고 강도는 151.0입니다.

setPointerCapture@propertyaria-valuenow
function setGoal(v) {
  v = Math.max(MIN, Math.min(MAX, Math.round(v)));
  var n = 0;
  bars.forEach(function (b, i) {
    var over = DATA[i] >= v ? '1' : '0';
    b.dataset.over = over;
    if (over === '1') { n += 1; }
  });
  root.style.setProperty('--goal', v / 4 + '%');
  root.style.setProperty('--gv', String(v));
  root.style.setProperty('--n', String(n));
  grab.setAttribute('aria-valuenow', String(v));
  grab.setAttribute('aria-valuetext', v + '명');
}

08두 점을 찍으면 그 사이 차이가 뜬다

첫 점이 기준이 되고 두 번째 점을 찍으면 두 점을 잇는 점선과 차이 배지가 뜨며, 세 번째를 찍으면 기준이 그 점으로 옮겨 갑니다. 1주 128명에서 8주 262명이면 +104.7%, 8주에서 5주 233명으로 되짚으면 −11.1%처럼 배지 숫자는 표의 값으로 그때그때 계산합니다.

aria-pressedstroke-dasharrayaria-live
  put('--mx', (xs[a] + xs[b]) / 2 + 'px');
  put('--my', (ys[a] + ys[b]) / 2 + 'px');
  line.setAttribute('x2', xs[b] - xs[a]);
  line.setAttribute('y2', ys[b] - ys[a]);
  var d = (vs[b] - vs[a]) / vs[a] * 100;
  var pct = (d >= 0 ? '+' : '−') + Math.abs(d).toFixed(1) + '%';
  badge.textContent = pct;
  root.setAttribute('data-b', '1');

09표 안의 작은 선이 카드로 커진다

행에 손을 올리거나 키보드로 닿으면 그 줄이 카드로 커지면서 선이 굵어지고 축과 최고점 표시가 나타납니다. 높이 분배는 grid-template-rows 한 줄이 독점하는데, 열린 행이 68px로 커지는 만큼 나머지 두 행이 20px로 줄어 목록 전체 높이가 108px 그대로라 카드가 무대를 넘지 않습니다.

vector-effectgrid-template-rows:focus-visible
.sp__rows {
  display: grid;
  grid-template-rows: $fold $fold $fold;
  transition-property: grid-template-rows;
}
.sp__rows:has(.sp__row[data-i="0"]:focus-visible) { grid-template-rows: $open $squash $squash; }
.sp__rows:has(.sp__row[data-i="1"]:focus-visible) { grid-template-rows: $squash $open $squash; }
.sp__rows:has(.sp__row[data-i="2"]:focus-visible) { grid-template-rows: $squash $squash $open; }
.sp__line { vector-effect: non-scaling-stroke; }

어디서 깨지는가 — 값이 먼저 도착하면 그 프레임이 대표 이미지가 된다

이 편에서 제일 많이 고친 건 코드가 아니라 시각이었습니다. 미리보기 대표 이미지는 2초를 스물넷으로 나눈 프레임 가운데 앞 장과 가장 크게 달라진 한 장으로 뽑히는데, 값이 도착하는 시각과 그림이 도착하는 시각이 어긋나면 그 사이의 어느 장이 대표 이미지가 됩니다. 03번이 그랬습니다. 밝은 띠는 scaleX로 부드럽게 늘어나는데 합계 숫자와 바깥 막대의 밝아짐은 띠가 출발하는 15%에 컷으로 걸어 두었더니, 띠는 다섯 칸인데 머리글은 165만원·14일이라고 적힌 장이 뽑혔습니다. 세 가지를 전부 띠가 다 도착한 40%로 옮기고 복귀를 88%에 끝내자 한 프레임도 어긋나지 않게 됐습니다. 06번은 반대 방향이었습니다. 두 계열이 자리를 바꾸는 동안 가로와 세로를 한꺼번에 보간했더니 검은 막대가 파란 막대를 대각선으로 뚫고 지나가면서 두 색이 겹친 장이 제일 크게 변한 프레임이 됐고, 그 장이 그대로 대표 이미지였습니다. 가로 이동과 세로 이동을 두 단계로 쪼개 먼저 비켜난 다음 내려앉게 하니 겹치는 순간 자체가 사라졌습니다. 02번은 시각이 아니라 값이 문제였습니다. 미리보기가 검색 계열을 끄는 동안에도 머리글은 계속 합계 3,290명을 붙들고 있었는데, 실제로 칩을 누르면 자바스크립트가 1,590명으로 다시 세니 미리보기와 조작이 다른 말을 하고 있었습니다. 머리글을 축 최대로 바꾸고 그 숫자마저 @property로 등록해 카운터로 그리자 460명과 260명이 미리보기에서도 제대로 갈렸습니다. 셋 다 코드를 읽어서는 안 보이고 프레임을 한 장씩 열어 봐야 보이는 종류라, 아홉 장의 대표 이미지를 전부 눈으로 확인한 뒤에 넘어갔습니다. 같은 요령으로 뗀 정적 상태도 한 장씩 찍어 확인했습니다. 자동 재생의 .is-demo만 뺀 화면을 열어 숫자·라벨·선택 표시가 전부 나오는지를 보는 것입니다. 루프용 연출에만 값을 두고 있으면 사람이 실제로 만졌을 때 화면이 비는데, 측정도 게이트도 루프 상태만 보므로 어느 쪽도 이걸 잡지 못합니다. 프레임 밖에서 걸린 것도 둘 있었습니다. 하나는 01번의 요약 표였습니다. 시각적으로만 숨기려고 width: 1pxclip-path를 표에 직접 걸었더니, 표는 제 내용 크기대로 자라 열두 행이 그대로 쌓이면서 480×300 무대에 세로 411px짜리 문서가 만들어졌습니다. 화면에는 아무것도 안 보이는데 스크롤바만 생기니 원인을 찾는 데 한참 걸렸고, 답은 표를 감싸는 div에 그 규칙을 옮기는 것이었습니다. 다른 하나는 07번의 아이콘과 스냅입니다. 목표선 손잡이에 넣은 Phosphor 아이콘의 호 좌표가 A104.1 104.1 0 1 1이어야 하는데 회전 자리에 1이 들어가 A104.1 104.1 1 1 1로 적혀 있었습니다. 두 원이 겹친 과녁 모양이라 육안으로는 멀쩡해 보였고, 받아 둔 원본 SVG와 글자 단위로 맞춰 보고서야 드러났습니다. 같은 데모에서 목표값을 10 단위로 반올림해 스냅하던 줄도 걷어냈습니다. 자동 재생은 값을 연속으로 굴려 목표 337명 같은 장면을 보여 주는데, 스냅이 남아 있으면 사람이 아무리 끌어도 그 숫자에 닿지 못해 미리보기가 보여 준 화면이 거짓말이 됩니다. 이 편의 규칙은 그래서 하나로 모입니다. 미리보기가 보여 주는 모든 장면은 사람이 실제로 손으로 만들어 낼 수 있는 장면이어야 합니다. 아홉 벌을 담은 zip의 압축 비밀번호는 3b3n74ca이고, 바닐라와 React 포트가 같은 이름의 폴더로 나란히 들어 있습니다.

접근성 — reduced-motion 에서 무엇이 남는가

아홉 개 모두 prefers-reduced-motion: reduce에서 자동 재생 루프를 끄고 값과 상태는 남깁니다. 그 블록 안의 선언에는 !important를 붙였는데, .x.is-demo .y 꼴의 선택자가 특이도에서 이겨 버리기 때문입니다. 상태를 말로 전하는 쪽은 애니메이션을 꺼도 그대로 남습니다. 01·08번은 aria-live="polite" 한 곳이 짚은 점과 차이를 한 문장으로 읽고, 07번 손잡이는 role="slider"aria-valuetext로 목표값을 말하며, 02번 칩은 aria-pressed, 04번 막대는 aria-expanded, 05번 세그먼트는 aria-selected가 상태를 남깁니다. 아홉 개 전부 차트에 role="img"와 실제 숫자가 들어간 aria-label을 주고, 같은 값을 담은 요약 표를 시각적으로만 숨겨 카드 안에 함께 넣었습니다. 숨길 때 표 자체에 width: 1px을 주면 표가 내용 크기로 커져 문서가 세로로 411px까지 늘어나므로, 감싸는 div에 주어야 합니다. 키보드도 장식이 아닙니다. 01·08번은 ←→로 점 사이를, 03번은 ←→로 끝점과 Shift+←→로 시작점을, 05번은 ←→로 세그먼트를, 07번은 ↑↓로 목표선을, 09번은 ↑↓로 행을 옮깁니다.

글자와 막대 대비는 WCAG 상대휘도 공식으로 직접 계산했습니다. 반투명 글자는 카드 배경과 합성한 실효색으로 쟀습니다.

쓰이는 곳 대비
01·03·04 본문 잉크 / 흰 카드 #17141a #ffffff 18.24:1
01·02·03 값 · 파랑 / 흰 카드 #2f6df6 #ffffff 4.53:1
08 안내 · 옐로 / 먹 카드 #ffd23f #17141a 12.63:1
보조 글자 · 잉크 62% / 흰 카드 #17141a 62% #ffffff 5.12:1
02·07 계열 막대 · 내린 민트 / 흰 카드 #3d9e7f #ffffff 3.28:1

두 종류를 고쳐서 통과시켰습니다. 하나는 축 라벨·단위·꺼진 칩처럼 일부러 흐리게 둔 글자들로, 잉크 50~58% 투명도에서 3.44:1부터 4.45:1까지 흩어져 있어 쉰한 곳을 62%로 올려 5.12:1을 맞췄습니다. 다른 하나는 막대 색입니다. 파스텔 민트 #4cd4a6는 어두운 무대를 전제로 만든 색이라 흰 카드 위에서는 1.86:1밖에 안 나와 막대가 바탕에 녹아 버립니다. 같은 민트를 잉크와 72:28로 섞어 3.28:1로 끌어올리니 비텍스트 대비 기준인 3:1을 넘겼습니다. 07번은 넘긴 막대의 색이 유일한 신호가 아니라 목표선 위라는 위치가 같은 사실을 한 번 더 말해 주므로, 색만으로 판단해야 하는 상황도 남지 않습니다. 이웃한 대시보드 컴포넌트 9종은 같은 기둥의 부품을 다루고, 대시보드 분류에 이 기둥의 다른 편들이 모여 있습니다. 커스텀 속성 등록 규격은 MDN @property 문서를 따랐습니다.

자주 묻는 질문

차트 라이브러리를 안 쓰면 뭐가 불편한가요

축 눈금 자동 계산, 로그 스케일, 수천 점 렌더링처럼 라이브러리가 대신 해 주던 일을 직접 짜야 합니다. 이 아홉 개는 반대쪽을 고른 것입니다. 점이 열몇 개고 조작이 한두 가지인 대시보드 카드라면 라이브러리가 가져오는 번들과 그 안의 DOM 구조를 감당하는 대신, 막대 높이를 커스텀 속성 하나로 두고 CSS가 보간하게 하는 편이 코드도 짧고 손댈 곳도 분명합니다.

@property 없이 막대 높이를 애니메이션할 수 있나요

커스텀 속성을 등록하지 않으면 브라우저가 값을 그냥 글자로 보기 때문에 두 값 사이에 중간값이 없습니다. 막대가 자라는 대신 어느 순간 툭 바뀌고, 자동 재생 미리보기에서는 움직인 프레임이 두세 장으로 떨어집니다. syntax"<percentage>""<integer>"로 선언하고 initial-value까지 주어야 그 사이가 채워집니다.

크로스헤어를 왜 가장 가까운 점에 붙이나요

붙이지 않으면 사람이 읽는 값과 커서가 가리키는 자리가 조금씩 어긋납니다. 점과 점 사이에 세로선이 서 있으면 어느 날의 값인지 다시 눈으로 확인해야 하고, 값을 읽어 주는 보조기술 입장에서는 아예 가리킬 대상이 없습니다. 칸 비율을 반올림하는 한 줄이면 커서 위치와 읽히는 값이 언제나 같은 날을 가리킵니다.

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

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