GODRICH

9 mẫu List UI: accordion list css và checklist

Accordion list css là kiểu danh sách mà khi bạn bấm vào một dòng, chiều cao của nó tự giãn ra để lộ nội dung chi tiết — chín mẫu chạy thật dưới đây sẽ cho bạn

Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip

Chín mục dưới đây không xếp theo độ phổ biến mà theo đúng trình tự mà một dòng danh sách thực sự trải qua. Bắt đầu từ thao tác cơ bản nhất — mở một dòng để xem chi tiết (01) — rồi đến nhóm các dòng lại để cuộn dễ hơn (02), chuyển hẳn sang màn hình khác (03). Tiếp theo là khi cần tải thêm dữ liệu (04), giấu bớt thao tác ít dùng (05), đổi thứ tự (06), sửa giá trị ngay tại chỗ (07), ghi lại việc đã xảy ra (08), và cuối cùng là đánh dấu hoàn thành (09).

01Bung chi tiết trong dòng

Bấm vào một dòng lịch sử đơn hàng sẽ xoay mũi tên 90 độ và mở nội dung chi tiết bên dưới.

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 không animate được, nhưng grid-template-rows nội suy mượt từ 0fr sang 1fr, nên dòng vẫn mở êm dù không biết trước chiều cao nội dung.

02Header nhóm cố định

Trong danh bạ được nhóm theo chữ cái đầu, header của nhóm đang cuộn tới sẽ dính trên cùng cho đến khi nhóm kế tiếp đẩy nó lên.

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

position: sticky chỉ dính trong phạm vi chiều cao của phần tử cha, nên mỗi nhóm cần một khung bọc riêng để nhóm sau đẩy được header của nhóm trước lên.

03Chuyển slide từ list sang chi tiết

Bấm vào một mục sẽ trượt panel danh sách sang trái trong khi panel chi tiết trượt vào từ phải — kiểu chuyển màn hay gặp trong cài đặt.

두 패널 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%); }

Danh sách và chi tiết nằm cạnh nhau trong một track duy nhất, nên chỉ cần một transform để đẩy cả hai thay vì animate riêng từng panel.

04Nút tải thêm danh sách

Bấm tải thêm sẽ quay spinner một chút, rồi ba dòng mới lần lượt trượt lên chứ không hiện ra cùng lúc.

stagger translateY+opacity버튼 로딩 상태 토글신뢰감 있는 밝은 톤
btn.addEventListener('click', () => {
  list.classList.add('is-loading');
  setTimeout(() => {
    list.classList.remove('is-loading');
    list.classList.add('has-loaded');
  }, 500);
});

Ba dòng mới bắt đầu ở trạng thái max-height: 0, mỗi dòng có transition-delay khác nhau một chút để chúng hiện lần lượt thay vì cùng lúc.

05Menu hành động khi nhấn giữ

Nhấn giữ một dòng nửa giây sẽ bật lên menu biểu tượng yêu thích/lưu trữ/xóa với hiệu ứng nảy.

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))
);

Thả tay sớm sẽ hủy bộ đếm giờ ngay, nên chạm nhẹ để cuộn không vô tình mở menu. Nút "···" luôn hiển thị cũng mở cùng menu đó, dành cho những ai không nhấn giữ được.

06Sắp xếp lại có vạch báo vị trí thả

Kéo tay cầm sáu chấm sẽ hiện một vạch trống màu xanh báo trước vị trí sẽ thả, thay vì chỉ đẩy các dòng khác sang một bên.

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]);
});

Ai không kéo chuột được vẫn có thể focus vào tay cầm rồi dùng Alt+phím mũi tên để làm được điều tương tự — đó là điều khiến danh sách sắp xếp lại thực sự dùng được cho mọi người.

07Sửa trực tiếp trong danh sách

Bấm vào ô tên, ô đó sẽ biến ngay thành ô nhập liệu; nhấn Enter hoặc bấm ra ngoài để lưu, biểu tượng bút chì đổi thành dấu tick.

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(); });

Dùng <input readonly> bình thường thay vì contenteditable nghĩa là việc kiểm tra giá trị và hủy bằng Escape đã có sẵn nhờ hành vi mặc định của input, nhờ vậy code ngắn hơn hẳn.

08Dòng thời gian hoạt động

Các chấm biểu tượng nằm dọc theo đường kẻ đứng lần lượt mờ dần hiện lên từ trên xuống dưới.

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); } }

Truyền số thứ tự của mỗi mục qua biến --n rồi tính độ trễ bằng calc() giúp bao nhiêu mục cũng hiện lần lượt mà không cần vòng lặp trong chính animation.

09Checklist gạch ngang khi hoàn thành

Bấm checkbox sẽ tô đầy màu và bật ra dấu tick, chữ bị gạch ngang từ trái sang phải rồi mờ dần.

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 không animate mượt trên mọi trình duyệt, nên thay vào đó dùng một đường gạch giả co giãn từ scaleX(0) sang scaleX(1). Cố định transform-origin: left là lý do đường kẻ vẽ từ bên trái.

Accordion list dễ vỡ ở đâu

Cái bẫy thật sự ở mục 01 là thử animate grid-template-rows thẳng tới autoauto không phải giá trị đầu hay cuối mà trình duyệt nội suy được, nên dòng bật mở tức thì thay vì giãn êm. Phải animate giữa hai giá trị tỷ lệ lưới như 0fr1fr. Cái thứ hai gặp ở mục 04: giấu các dòng mới bằng display: none rồi mới đổi class khiến trình duyệt hoàn tất thay đổi layout trước khi kịp chuyển tiếp, nên dòng hiện ra đột ngột không animate — phải bắt đầu từ max-height: 0, vốn vẫn được render nhưng kích thước bằng 0, animation mới hiện được. Mật khẩu giải nén chín file này là pw5ht9pc, giải nén xong bạn có thể so sánh trực tiếp hai bản vanilla và react của cả chín mục.

Các chủ đề list và table khác nằm ở danh mục UI thực chiến, chín mẫu khác về vuốt, sắp xếp và phân trang nằm ở 9 mẫu List UI: swipe to delete css và phân trang, còn trang này là gì thì xem ở trang giới thiệu. Cách grid-template-rows nội suy đơn vị fr được ghi ở trang grid-template-rows của MDN, phạm vi hoạt động của position: stickytrang position của MDN.

Khả năng tiếp cận — reduced-motion tắt gì ở từng mục

Khi bật giảm chuyển động trong hệ thống, cả chín demo đều tắt transitionanimation, nhảy thẳng đến trạng thái cuối. Các mục như 01, 03 nơi chiều cao hay vị trí thay đổi sẽ chuyển thẳng đến trạng thái mở/đã chuyển; các mục như 05, 09 nơi hiệu ứng nảy là trọng tâm sẽ mất hiệu ứng đó nhưng kết quả (menu mở, checkbox tick) vẫn còn nguyên. Mục 06 sắp xếp lại và 07 sửa trực tiếp vốn đã thao tác được hoàn toàn bằng bàn phím nên không đổi gì cả.

FAQ

Có dùng thẳng thẻ <details> của HTML thay vì tự làm được không?

<details>/<summary> cho sẵn việc mở/đóng, nhưng animation chiều cao vẫn phải tự thêm và giao diện mặc định khác nhau tùy trình duyệt — vì vậy chín demo này dùng button cùng grid-template-rows để kiểm soát chi tiết hơn. Nếu ưu tiên khả năng tiếp cận, gắn style lên trên <details> cũng là một lựa chọn hợp lý.

Danh sách có vài trăm mục thì các mẫu này còn dùng được không?

Với 01, 02, 08 — nơi mỗi dòng hiển thị đều mang animation riêng — danh sách lên tới vài trăm mục cần thêm tối ưu như virtual scroll (chỉ render phần đang hiện trên màn hình). Code trong zip được làm cho quy mô vài chục mục trên một trang.

Menu nhấn giữ có hoạt động giống vậy trên di động không?

Có — sự kiện pointerdown/pointerup xử lý chuột, cảm ứng và bút bằng cùng một đoạn code. Tuy nhiên trình duyệt di động có thể bật menu ngữ cảnh hoặc chọn văn bản riêng khi nhấn giữ, nên demo dùng touch-action: manipulation để tránh xung đột đó.

Nhập mật khẩu file nén

Mật khẩu nằm ngay trong bài viết này. Đọc tới đó bạn sẽ thấy.