대시보드 사이드바·위젯 UI 9종 — 완성 페이지 말고 이 조각 하나만
대시보드 사이드바 하나, 통계 카드 한 줄, 알림 드롭다운 하나처럼 완성된 관리자 페이지 전체가 아니라 화면 한 조각만 그대로 가져다 붙이는 대시보드 위젯 아홉 개를 모았습니다. 아홉 개 중 하나는 숫자가 실제로 세어 올라가는 도넛 차트를 담았습니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 단독 사이드바 내비게이션
- 02 스탯 카드 로우 (스파크라인 호버)
- 03 정렬 가능한 미니 테이블
- 04 도넛 차트 카드 (가운데 카운터)
- 05 칩형 필터 바
- 06 빈 상태 일러스트 (추가 모프)
- 07 설정 폼 섹션 (저장 상태)
- 08 알림 드롭다운 패널
- 09 유저 아바타 드롭다운 메뉴
아홉 개는 인기순이 아니라 화면에 들어오는 순서대로 놓았습니다. 먼저 전체 구조를 잡는 자리(사이드바)를 놓고, 한눈에 보이는 요약(스탯 카드·표·차트)을 지나, 사용자가 직접 조작하는 자리(필터·빈 화면·설정)를 거쳐, 마지막으로 헤더 구석에 붙는 작은 팝업 둘(알림·유저 메뉴)로 마무리합니다. 완성된 대시보드 템플릿이 아니라 이 조각 하나만 떼어 내 프로젝트에 붙일 수 있도록, 각 데모는 페이지가 아니라 위젯 하나만 화면 가운데 놓았습니다.
01단독 사이드바 내비게이션
접기 버튼을 누르면 사이드바 폭이 실제로 208px에서 76px로 줄어들며 글자 라벨이 사라지고 아이콘만 남습니다. 지금 선택된 메뉴는 왼쪽의 얇은 세로 막대와 옅은 배경으로 표시됩니다.
.sd { width: 208px; transition: width $duration $easing; }
.sd.is-collapsed { width: 76px; }
.sd__label { transition: opacity $duration $easing; }
.sd.is-collapsed .sd__label { opacity: 0; }
.sd__item.is-active .sd__link::before {
content: "";
position: absolute; left: 0; top: 18%; bottom: 18%;
width: 3px; background: $color;
}
02스탯 카드 로우 (스파크라인 드로잉)
기간 스위치를 누르면 방문자·전환율·매출 세 카드의 숫자가 실제로 바뀌고, 카드 아래 미니 그래프 선은 pathLength 속성으로 길이를 100으로 맞춰 두어 어떤 모양이든 같은 방식으로 그려집니다.
.sc__spark-path {
stroke-dasharray: 100;
stroke-dashoffset: 100;
}
.sc__card.is-demo .sc__spark-path { animation: sc-draw 2s $easing infinite; }
@keyframes sc-draw {
0% { stroke-dashoffset: 100; }
60% { stroke-dashoffset: 0; }
}
03정렬 가능한 미니 테이블
열 제목을 누르면 화살표 방향이 바뀌며 행 순서가 실제로 다시 매겨집니다. 이름 열은 문자열 비교로, 금액 열은 숫자 비교로 정렬하도록 나눠 두었습니다.
rows.sort(function (a, b) {
var av = a.dataset[key], bv = b.dataset[key];
if (key === 'amount') { av = Number(av); bv = Number(bv); return (av - bv) * dir; }
return av.localeCompare(bv) * dir;
});
rows.forEach(function (r) { tbody.appendChild(r); });
04도넛 차트 카드 (가운데 카운터)
범례를 누르면 그 구간이 실제로 링에서 빠지고 가운데 숫자가 새 합계까지 부드럽게 세어 올라갑니다. 링은 SVG가 아니라 conic-gradient와 등록된 사용자 지정 속성(@property)만으로 그렸습니다.
@property --pct-a { syntax: "<percentage>"; inherits: true; initial-value: 0%; }
@property --pct-b { syntax: "<percentage>"; inherits: true; initial-value: 0%; }
.cc__ring {
background: conic-gradient($color 0 var(--pct-a), $stage-yellow var(--pct-a) var(--pct-b), rgba(23,20,26,.08) var(--pct-b) 100%);
mask: radial-gradient(farthest-side, transparent 60%, #000 61%);
transition: --pct-a $duration $easing, --pct-b $duration $easing;
}
05칩형 필터 바
칩을 누르면 실제로 눌린 상태(색이 채워진 알약 모양)로 바뀌고, 아래 선택 개수가 함께 올라갑니다. 하나라도 선택되면 '전체 해제' 글자 버튼이 나타납니다.
c.addEventListener('click', function () {
c.classList.remove('is-demo');
var on = c.getAttribute('aria-pressed') === 'true';
c.setAttribute('aria-pressed', String(!on));
refresh();
});
06빈 상태 일러스트 (추가 모프)
도형만으로 그린 빈 상자 일러스트가 계속 살짝 떠 있다가, '첫 항목 추가' 버튼을 누르면 그 자리가 실제로 리스트 카드 한 장으로 바뀝니다. 다시 누르면 원래 빈 상태로 되돌아갑니다.
.es__empty, .es__item { position: absolute; inset: 0; transition: opacity $duration $easing, transform $duration $easing; }
.es__item { opacity: 0; transform: scale(.9); pointer-events: none; }
.es.is-added .es__item { opacity: 1; transform: scale(1); pointer-events: auto; }
.es.is-added .es__empty { opacity: 0; transform: scale(.9); }
07설정 폼 섹션 (저장 상태)
토글을 켜고 저장 버튼을 누르면 버튼이 링 스피너로 바뀌었다가, 700ms 뒤 실제로 초록색 '저장됨' 상태로 바뀌고 1.6초 뒤 원래 문구로 돌아갑니다. 완성된 설정 페이지가 아니라 이 섹션 하나만 옮겨 붙일 수 있게 폼 태그 하나로 닫아 두었습니다.
save.classList.add('is-busy');
label.textContent = '저장 중';
setTimeout(function () {
save.classList.remove('is-busy');
save.classList.add('is-done');
label.textContent = '저장됨';
setTimeout(function () { save.classList.remove('is-done'); label.textContent = '저장'; }, 1600);
}, 700);
08알림 드롭다운 패널
종 아이콘을 누르면 패널이 실제로 그 자리에서 커지며 열리고 닫힙니다. 알림 한 줄을 누르면 그 항목만 읽음 처리되고, 위 배지 숫자가 남은 안 읽은 개수만큼 실제로 줄어듭니다.
function syncBadge() {
var n = document.querySelectorAll('.np__item.is-unread').length;
badge.textContent = String(n);
badge.style.display = n > 0 ? '' : 'none';
}
it.addEventListener('click', function () { it.classList.remove('is-unread'); syncBadge(); });
09유저 아바타 드롭다운 메뉴
아바타를 누르면 메뉴가 실제로 오른쪽 위를 기준으로 커지며 열립니다. 메뉴 바깥을 클릭하거나 Esc 키를 누르면 실제로 닫히고, 메뉴 자체를 클릭했을 땐 닫히지 않도록 closest('.um')으로 안쪽 클릭인지 구분했습니다.
document.addEventListener('click', function (e) {
if (!e.target.closest('.um')) close();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') close();
});
어디서 깨지는가 — 함정
아홉 개를 만드는 동안 진짜 걸린 함정은 드롭다운 두 개(알림·유저 메뉴)의 세로 위치였습니다. 무대 CSS가 위젯을 화면 가운데 정렬하다 보니, 종 아이콘이나 아바타가 캔버스 중앙에 놓이고 그 아래로 펼쳐지는 패널이 300px 무대 바닥 밑으로 넘어가 스크롤바가 떴습니다. 정렬을 가운데 대신 위쪽으로 바꾸고 나서야 패널 전체가 한 화면에 들어왔습니다. 320px 폭·짧은 세로 화면에서는 더 심했습니다 — 패널 제목과 설명 줄까지 다 넣으면 세로 200px 안에 못 들어가, 좁은 화면에서는 패널 제목과 알림 설명 문장을 숨기고 진한 글자만 남기도록 따로 손봤습니다. 정렬 가능한 표는 처음엔 행마다 배경색이 순서대로 훑고 지나가게 만들었는데, 실제로 측정해 보니 프레임 사이 변화가 너무 작아 "움직이지 않는 것"으로 잡혔습니다. 하이라이트 띠 하나가 표 위에서 아래로 실제로 이동하는 방식으로 바꾸자, 압축 비밀번호 rzae7dqh로 연 최신 zip에서는 표도 다른 여덟 개처럼 또렷하게 움직입니다. 도넛 차트는 SVG로 먼저 그리려다 두 구간의 시작 각도를 맞추는 계산이 계속 어긋나, conic-gradient와 @property로 바꾸고 나서야 값 하나만 바꿔도 부드럽게 전환됐습니다.
접근성
아홉 개 전부 prefers-reduced-motion: reduce에서 장식용 반복 애니메이션(사이드바 자동 접힘, 스파크라인 그려짐, 필터 칩 순서대로 눌림, 종·아바타의 은은한 펄스)을 끄되 실제 상태 값은 그대로 둡니다. 알림 패널(08)과 유저 메뉴(09)는 role="menu"·role="menuitem"을 갖추고 바깥 클릭·Esc로 닫히며, 설정 폼(07)의 토글은 role="switch"와 aria-checked로 키보드(Enter·스페이스)만으로도 조작됩니다. 표(03)의 정렬 버튼은 aria-sort로 지금 정렬 상태를 알리고, 빈 상태(06)의 추가된 카드는 aria-hidden을 실제로 토글해 스크린 리더가 안 보이는 카드를 읽지 않게 했습니다. 이 패턴은 MDN의 ARIA 위젯 역할 문서를 따랐습니다.
다른 대시보드 소재는 템플릿 카테고리에 있고, 이 사이트가 무엇을 다루는지는 소개에 있습니다.
FAQ
완성된 관리자 대시보드 템플릿이 아니라 이 위젯 하나만 써도 되나요?
네, 그게 이 아홉 개를 만든 이유입니다. 각 데모는 페이지 전체가 아니라 위젯 하나만 화면에 두었고, zip 안에도 그 위젯 하나의 HTML·SCSS·JS만 들어 있어 자기 프로젝트의 사이드바나 헤더에 그대로 옮기면 됩니다.
React 버전은 CSS 버전과 다르게 동작하나요?
동작은 같습니다. 열림·닫힘·선택 같은 상태는 useState로 들고, 변수 3개(duration·easing·color)는 props로 받아 CSS 사용자 지정 속성으로 넘깁니다. 도넛 차트의 숫자 카운트업은 useEffect 안에서 requestAnimationFrame으로 같은 계산을 반복합니다.
알림 배지 숫자나 사이드바 접힘은 실제로 작동하나요, 장식인가요?
실제로 작동합니다. 알림을 누르면 그 항목의 안 읽음 상태가 실제로 바뀌고 배지 숫자는 남은 개수를 다시 세어 표시하며, 사이드바 접기 버튼은 실제로 폭 값을 바꿉니다. 자동 재생 GIF에서 보이는 반복 움직임은 사람이 손대지 않았을 때만 도는 장식 루프이고, 첫 클릭·탭 순간 바로 꺼집니다.