아코디언 리스트 UI 9가지 — 체크리스트까지
아코디언 리스트는 목록 행을 클릭했을 때 그 자리에서 높이가 부드럽게 늘어나며 상세 내용이 펼쳐지는 UI로, 별도 페이지 이동 없이 리스트 화면 안에서 정보를 더 보여주고 싶을 때 씁니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 행 인라인 펼치기
- 02 그룹 리스트 고정 섹션 헤더
- 03 리스트 → 상세 슬라이드 전환
- 04 더보기 버튼 리스트
- 05 롱프레스 액션 메뉴
- 06 드롭 위치 표시 리스트 재정렬
- 07 리스트 인라인 편집
- 08 활동 내역 타임라인
- 09 체크리스트 취소선 완료
아홉 개는 인기순이 아니라 리스트 한 줄이 실제로 겪는 순서로 골랐습니다. 먼저 행 하나를 펼쳐 상세를 보는 가장 기본적인 동작(01)에서 시작해, 리스트를 그룹으로 묶어 스크롤을 돕는 동작(02), 아예 다른 화면처럼 전환되는 동작(03)으로 넘어갑니다. 그다음은 데이터가 더 필요할 때(04), 자주 쓰지 않는 부가 작업을 숨겨둘 때(05), 순서 자체를 바꿀 때(06), 값을 그 자리에서 고칠 때(07), 지나간 일을 기록할 때(08), 마지막으로 할 일을 끝냈다고 표시할 때(09) 순서로 이어집니다.
01행 인라인 펼치기
주문 내역처럼 한 줄만 보이던 행을 클릭하면 화살표 아이콘이 90도로 돌면서 그 아래로 상세 정보가 나타납니다.
.rowx-item__body {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows .3s cubic-bezier(.2,.8,.2,1);
}
.rowx-item.is-open .rowx-item__body { grid-template-rows: 1fr; }
.rowx-item__body-inner { overflow: hidden; }
height: auto는 애니메이션이 안 되지만 grid-template-rows는 0fr에서 1fr로 보간되기 때문에, 내용 길이를 몰라도 부드럽게 펼쳐집니다.
02그룹 리스트 고정 섹션 헤더
연락처 앱처럼 첫 글자로 묶인 리스트에서, 스크롤하는 동안 지금 보고 있는 그룹의 헤더 글자가 화면 위에 붙어 있다가 다음 그룹이 오면 자연스럽게 밀려 올라갑니다.
.grp-group__header {
position: sticky;
top: 0;
z-index: 1;
background: #ff9ebb;
}
position: sticky는 부모 그룹 <li>의 높이 안에서만 붙어 있으므로, 그룹마다 감싸는 요소가 따로 있어야 다음 그룹이 헤더를 밀어낼 수 있습니다.
03리스트 → 상세 슬라이드 전환
목록 항목을 누르면 목록 패널이 왼쪽으로 밀려 나가고 상세 패널이 오른쪽에서 밀려 들어오는, 설정 화면에서 흔히 보는 전환입니다.
.ltd-track {
display: flex;
width: 200%;
transition: transform .3s cubic-bezier(.2,.8,.2,1);
}
.ltd-viewport.is-open .ltd-track { transform: translateX(-50%); }
목록과 상세를 나란히 둔 트랙 하나를 통째로 밀기 때문에, 두 패널을 각각 애니메이션하지 않아도 하나의 transform으로 끝납니다.
04더보기 버튼 리스트
더보기 버튼을 누르면 잠깐 스피너가 돌고, 새 항목 세 개가 아래로 하나씩 순서를 두고 밀려 올라오며 나타납니다.
btn.addEventListener('click', () => {
list.classList.add('is-loading');
setTimeout(() => {
list.classList.remove('is-loading');
list.classList.add('has-loaded');
}, 500);
});
세 행은 처음부터 max-height: 0으로 접혀 있다가 .has-loaded가 붙으면 펼쳐지는데, 행마다 transition-delay를 조금씩 다르게 줘서 한 번에 뿅 나타나지 않고 순서대로 이어집니다.
05롱프레스 액션 메뉴
리스트 행을 0.5초 이상 누르고 있으면 즐겨찾기·보관·삭제 아이콘 메뉴가 튀어 오르듯 나타납니다.
row.addEventListener('pointerdown', (e) => {
if (e.target.closest('button')) return;
timer = setTimeout(openMenu, 500);
});
['pointerup', 'pointerleave'].forEach((ev) =>
row.addEventListener(ev, () => clearTimeout(timer))
);
손가락(마우스)을 떼면 타이머가 즉시 취소되므로, 스크롤하려고 살짝 누른 정도로는 메뉴가 열리지 않습니다. 마우스로 길게 누르기 힘든 사람을 위해 항상 보이는 "···" 버튼도 같은 메뉴를 엽니다.
06드롭 위치 표시 리스트 재정렬
여섯 점 손잡이를 잡고 끌면, 항목이 통째로 밀리는 대신 놓일 자리에 파란 빈 줄이 먼저 나타나 위치를 보여줍니다.
handle.addEventListener('keydown', (e) => {
if (!e.altKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return;
e.preventDefault();
const targetIdx = order().indexOf(item) + (e.key === 'ArrowUp' ? -1 : 1);
list.insertBefore(item, order()[targetIdx]);
});
마우스 드래그가 어려운 사람도 손잡이에 포커스를 두고 Alt+화살표로 같은 결과를 만들 수 있어야 리스트 재정렬이 접근성을 갖춘 컴포넌트가 됩니다.
07리스트 인라인 편집
이름 칸을 클릭하면 그 자리에서 바로 입력창으로 바뀌고, Enter를 누르거나 밖을 클릭하면 저장되며 연필 아이콘이 체크로 바뀝니다.
input.addEventListener('click', () => { if (input.readOnly) startEdit(); });
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') save();
else if (e.key === 'Escape') cancel();
});
input.addEventListener('blur', () => { if (!input.readOnly) save(); });
contenteditable 대신 평범한 <input readonly>를 쓰면 값 검증이나 Escape 취소 같은 처리를 input의 표준 동작 그대로 가져갈 수 있어 코드가 짧아집니다.
08활동 내역 타임라인
세로선을 따라 아이콘 점이 찍힌 활동 기록이 위에서부터 하나씩 순서를 두고 옅게 떠오르며 나타납니다.
.tl-item {
opacity: 0;
transform: translateY(10px);
animation: tl-in .6s ease-out forwards;
animation-delay: calc(var(--n) * 140ms);
}
@keyframes tl-in { to { opacity: 1; transform: translateY(0); } }
각 항목에 --n 커스텀 속성으로 순번을 넘기고 calc()로 지연 시간을 계산하면, 항목이 몇 개든 반복문 없이 순서대로 떠오르는 효과를 만들 수 있습니다.
09체크리스트 취소선 완료
체크박스를 누르면 색이 차오르며 체크 표시가 튀어나오고, 글자엔 취소선이 왼쪽에서 오른쪽으로 그어지며 흐려집니다.
.ck-item__text::after {
content: "";
position: absolute; inset: 50% 0 auto 0; height: 2px;
background: #ff9ebb;
transform: scaleX(0);
transform-origin: left;
transition: transform .3s;
}
.ck-item.is-checked .ck-item__text::after { transform: scaleX(1); }
text-decoration-line은 브라우저마다 애니메이션이 매끄럽지 않아서, 대신 가짜 밑줄 하나를 scaleX(0)에서 scaleX(1)로 늘리는 방식을 씁니다. 왼쪽을 원점(transform-origin: left)으로 고정해야 줄이 왼쪽부터 그어집니다.
아코디언 리스트, 어디서 깨지는가
01번 행 펼치기를 만들면서 실제로 겪은 함정은 grid-template-rows를 auto로 바로 애니메이션하려다 실패한 경우입니다 — auto는 시작값도 끝값도 아니라서 브라우저가 보간할 두 숫자가 없고, 그냥 순간적으로 튀어 버립니다. 반드시 0fr과 1fr처럼 그리드 비율값끼리 애니메이션해야 부드럽게 펼쳐집니다. 두 번째로 04번 더보기 버튼에서는 새 행을 처음부터 display: none으로 숨겼다가 보이는 순간 클래스를 바꾸면, 브라우저가 레이아웃을 계산하기도 전에 전환이 끝나 버려 애니메이션 없이 뿅 나타났습니다 — max-height: 0처럼 실제로 렌더링은 되지만 크기만 0인 상태로 시작해야 전환이 눈에 보입니다. 이 아홉 개 파일의 압축 비밀번호는 pw5ht9pc이고, 압축을 풀면 vanilla·react 폴더에서 같은 아홉 가지 동작을 나란히 비교해 볼 수 있습니다.
다른 리스트·표 주제는 실전 UI 카테고리에, 스와이프·정렬·페이지네이션처럼 리스트가 다루는 다른 아홉 가지는 스와이프 삭제 리스트 UI 9가지에 정리돼 있고, 이 사이트가 무엇을 다루는지는 소개에 있습니다. grid-template-rows가 fr 단위를 어떻게 보간하는지는 MDN CSS grid-template-rows 문서에, position: sticky의 동작 범위는 MDN position 문서에 정리돼 있습니다.
접근성 — 아홉 개가 reduced-motion에서 하는 일
시스템에서 동작 줄이기를 켜면 아홉 개 데모 모두 transition과 animation을 끄고 최종 상태만 바로 보여줍니다. 01·03번처럼 높이나 위치가 바뀌는 항목은 펼침·전환 상태로 곧장 이동하고, 05·09번처럼 튀어 오르는 느낌이 핵심인 항목은 그 느낌만 빠지고 결과(메뉴 열림, 체크 완료)는 그대로 남습니다. 06번 재정렬과 07번 인라인 편집은 원래도 키보드만으로 전부 조작되므로 동작 자체는 줄어들지 않습니다.
FAQ
아코디언 리스트를 만들 때 아코디언 요소를 써도 되나요?
<details>·<summary>는 열고 닫기 자체는 기본 제공하지만 높이 애니메이션은 직접 넣어야 하고 브라우저마다 기본 스타일이 달라, 아홉 개 데모에서는 버튼과 grid-template-rows로 더 세밀하게 제어했습니다. 접근성이 중요하면 <details> 위에 커스텀 스타일을 얹는 방법도 있습니다.
리스트 항목이 몇 백 개로 많아지면 이 코드들도 그대로 써도 되나요?
01·02·08번처럼 화면에 보이는 항목마다 애니메이션이 걸리는 경우 항목이 아주 많아지면 가상 스크롤(화면에 보이는 만큼만 렌더링) 같은 별도 최적화가 필요합니다. zip의 코드는 한 페이지에 보이는 수십 개 규모를 기준으로 만들었습니다.
롱프레스 메뉴는 모바일에서도 그대로 동작하나요?
pointerdown·pointerup 이벤트는 마우스·터치·펜을 모두 같은 코드로 처리하므로 그대로 동작합니다. 다만 모바일 브라우저는 길게 누르면 자체 컨텍스트 메뉴나 텍스트 선택이 뜰 수 있어, 데모 코드는 touch-action: manipulation으로 그 충돌을 막습니다.