GODRICH

아코디언 리스트 UI 9가지 — 체크리스트까지

아코디언 리스트는 목록 행을 클릭했을 때 그 자리에서 높이가 부드럽게 늘어나며 상세 내용이 펼쳐지는 UI로, 별도 페이지 이동 없이 리스트 화면 안에서 정보를 더 보여주고 싶을 때 씁니다.

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

아홉 개는 인기순이 아니라 리스트 한 줄이 실제로 겪는 순서로 골랐습니다. 먼저 행 하나를 펼쳐 상세를 보는 가장 기본적인 동작(01)에서 시작해, 리스트를 그룹으로 묶어 스크롤을 돕는 동작(02), 아예 다른 화면처럼 전환되는 동작(03)으로 넘어갑니다. 그다음은 데이터가 더 필요할 때(04), 자주 쓰지 않는 부가 작업을 숨겨둘 때(05), 순서 자체를 바꿀 때(06), 값을 그 자리에서 고칠 때(07), 지나간 일을 기록할 때(08), 마지막으로 할 일을 끝냈다고 표시할 때(09) 순서로 이어집니다.

01행 인라인 펼치기

주문 내역처럼 한 줄만 보이던 행을 클릭하면 화살표 아이콘이 90도로 돌면서 그 아래로 상세 정보가 나타납니다.

grid-template-rows 0fr→1fraria-expandedcaret rotate transform
.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-rows0fr에서 1fr로 보간되기 때문에, 내용 길이를 몰라도 부드럽게 펼쳐집니다.

02그룹 리스트 고정 섹션 헤더

연락처 앱처럼 첫 글자로 묶인 리스트에서, 스크롤하는 동안 지금 보고 있는 그룹의 헤더 글자가 화면 위에 붙어 있다가 다음 그룹이 오면 자연스럽게 밀려 올라갑니다.

position:sticky top:0그룹별 wrapper파스텔 톤
.grp-group__header {
  position: sticky;
  top: 0;
  z-index: 1;
  background: #ff9ebb;
}

position: sticky는 부모 그룹 <li>의 높이 안에서만 붙어 있으므로, 그룹마다 감싸는 요소가 따로 있어야 다음 그룹이 헤더를 밀어낼 수 있습니다.

03리스트 → 상세 슬라이드 전환

목록 항목을 누르면 목록 패널이 왼쪽으로 밀려 나가고 상세 패널이 오른쪽에서 밀려 들어오는, 설정 화면에서 흔히 보는 전환입니다.

두 패널 transform:translateXoverflow:hidden 컨테이너history 뒤로가기 대응
.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더보기 버튼 리스트

더보기 버튼을 누르면 잠깐 스피너가 돌고, 새 항목 세 개가 아래로 하나씩 순서를 두고 밀려 올라오며 나타납니다.

stagger translateY+opacity버튼 로딩 상태 토글신뢰감 있는 밝은 톤
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초 이상 누르고 있으면 즐겨찾기·보관·삭제 아이콘 메뉴가 튀어 오르듯 나타납니다.

pointerdown 타이머 500msscale bounce keyframes키보드용 더보기 버튼 대체
row.addEventListener('pointerdown', (e) => {
  if (e.target.closest('button')) return;
  timer = setTimeout(openMenu, 500);
});
['pointerup', 'pointerleave'].forEach((ev) =>
  row.addEventListener(ev, () => clearTimeout(timer))
);

손가락(마우스)을 떼면 타이머가 즉시 취소되므로, 스크롤하려고 살짝 누른 정도로는 메뉴가 열리지 않습니다. 마우스로 길게 누르기 힘든 사람을 위해 항상 보이는 "···" 버튼도 같은 메뉴를 엽니다.

06드롭 위치 표시 리스트 재정렬

여섯 점 손잡이를 잡고 끌면, 항목이 통째로 밀리는 대신 놓일 자리에 파란 빈 줄이 먼저 나타나 위치를 보여줍니다.

drop-indicator 엘리먼트pointermove 중간 인덱스 계산화살표 키보드 이동
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를 누르거나 밖을 클릭하면 저장되며 연필 아이콘이 체크로 바뀝니다.

contenteditable 대신 input 치환Escape 로 되돌리기신뢰감 있는 밝은 SaaS 톤
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활동 내역 타임라인

세로선을 따라 아이콘 점이 찍힌 활동 기록이 위에서부터 하나씩 순서를 두고 옅게 떠오르며 나타납니다.

stagger fade+translateY타임라인 세로선 + 아이콘 점파스텔 톤
.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체크리스트 취소선 완료

체크박스를 누르면 색이 차오르며 체크 표시가 튀어나오고, 글자엔 취소선이 왼쪽에서 오른쪽으로 그어지며 흐려집니다.

scale(1.15) 체크scaleX 취소선캔디 파스텔 톤
.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-rowsauto로 바로 애니메이션하려다 실패한 경우입니다 — auto는 시작값도 끝값도 아니라서 브라우저가 보간할 두 숫자가 없고, 그냥 순간적으로 튀어 버립니다. 반드시 0fr1fr처럼 그리드 비율값끼리 애니메이션해야 부드럽게 펼쳐집니다. 두 번째로 04번 더보기 버튼에서는 새 행을 처음부터 display: none으로 숨겼다가 보이는 순간 클래스를 바꾸면, 브라우저가 레이아웃을 계산하기도 전에 전환이 끝나 버려 애니메이션 없이 뿅 나타났습니다 — max-height: 0처럼 실제로 렌더링은 되지만 크기만 0인 상태로 시작해야 전환이 눈에 보입니다. 이 아홉 개 파일의 압축 비밀번호는 pw5ht9pc이고, 압축을 풀면 vanilla·react 폴더에서 같은 아홉 가지 동작을 나란히 비교해 볼 수 있습니다.

다른 리스트·표 주제는 실전 UI 카테고리에, 스와이프·정렬·페이지네이션처럼 리스트가 다루는 다른 아홉 가지는 스와이프 삭제 리스트 UI 9가지에 정리돼 있고, 이 사이트가 무엇을 다루는지는 소개에 있습니다. grid-template-rowsfr 단위를 어떻게 보간하는지는 MDN CSS grid-template-rows 문서에, position: sticky의 동작 범위는 MDN position 문서에 정리돼 있습니다.

접근성 — 아홉 개가 reduced-motion에서 하는 일

시스템에서 동작 줄이기를 켜면 아홉 개 데모 모두 transitionanimation을 끄고 최종 상태만 바로 보여줍니다. 01·03번처럼 높이나 위치가 바뀌는 항목은 펼침·전환 상태로 곧장 이동하고, 05·09번처럼 튀어 오르는 느낌이 핵심인 항목은 그 느낌만 빠지고 결과(메뉴 열림, 체크 완료)는 그대로 남습니다. 06번 재정렬과 07번 인라인 편집은 원래도 키보드만으로 전부 조작되므로 동작 자체는 줄어들지 않습니다.

FAQ

아코디언 리스트를 만들 때 아코디언 요소를 써도 되나요?

<details>·<summary>는 열고 닫기 자체는 기본 제공하지만 높이 애니메이션은 직접 넣어야 하고 브라우저마다 기본 스타일이 달라, 아홉 개 데모에서는 버튼과 grid-template-rows로 더 세밀하게 제어했습니다. 접근성이 중요하면 <details> 위에 커스텀 스타일을 얹는 방법도 있습니다.

리스트 항목이 몇 백 개로 많아지면 이 코드들도 그대로 써도 되나요?

01·02·08번처럼 화면에 보이는 항목마다 애니메이션이 걸리는 경우 항목이 아주 많아지면 가상 스크롤(화면에 보이는 만큼만 렌더링) 같은 별도 최적화가 필요합니다. zip의 코드는 한 페이지에 보이는 수십 개 규모를 기준으로 만들었습니다.

롱프레스 메뉴는 모바일에서도 그대로 동작하나요?

pointerdown·pointerup 이벤트는 마우스·터치·펜을 모두 같은 코드로 처리하므로 그대로 동작합니다. 다만 모바일 브라우저는 길게 누르면 자체 컨텍스트 메뉴나 텍스트 선택이 뜰 수 있어, 데모 코드는 touch-action: manipulation으로 그 충돌을 막습니다.

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

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