GODRICH

9 Accordion List UI Patterns (Copy-Paste)

An accordion list is a UI where clicking a row grows its height in place to reveal detail content.

Auto-plays · click a tile to jump to its section · all nine in one zip

These nine are ordered the way a real list row actually gets used, not by popularity. It starts with the plainest move — expanding one row for detail (01) — then groups rows for easier scanning (02), and swaps the whole screen for a detail view (03). After that comes fetching more data (04), tucking away rarely used actions (05), changing the order itself (06), editing a value right where it sits (07), logging what already happened (08), and finally marking a task as done (09).

01Row inline expand

Clicking an order-history row spins its arrow icon 90 degrees and unfolds detail content beneath it.

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 can't be animated, but grid-template-rows interpolates cleanly between 0fr and 1fr, so the row opens smoothly even when you don't know the content's height in advance.

02Grouped sticky section header

In a contact-style list grouped by first letter, the header for the group you're currently scrolling through sticks to the top until the next group's header pushes it out of the way.

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

position: sticky only stays pinned within its own parent's box, so each group needs its own wrapper element for the next group to be able to push the previous header off screen.

03List-to-detail slide transition

Tapping a list item slides the list panel out to the left while the detail panel slides in from the right — the drill-down pattern you see in most settings screens.

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

Both panels sit side by side inside one track, so a single transform moves them together instead of animating each panel separately.

04Load-more button list

Clicking the load-more button briefly spins a loader, then three new rows slide up into place one after another instead of popping in all at once.

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

The three rows start collapsed at max-height: 0, and each gets a slightly different transition-delay so they reveal in sequence rather than all at once.

05Long-press action menu

Holding a row for half a second pops a menu of star, archive, and delete icons into view with a bouncy scale-in.

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

Releasing early cancels the timer, so a quick tap meant for scrolling never accidentally opens the menu. An always-visible "···" button opens the same menu for anyone who can't long-press.

06Drop-gap indicator reorder

Dragging the six-dot handle shows a highlighted empty gap line where the item will land, instead of just shoving the other rows aside.

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

Anyone who can't drag with a mouse can focus the handle and reorder with Alt+Arrow keys instead — that's what makes a reorderable list actually accessible.

07List inline edit

Clicking a name field turns it into a text input right there; pressing Enter or clicking away saves it and swaps the pencil icon for a check mark.

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

Using a plain <input readonly> instead of contenteditable means validation and Escape-to-cancel come from the input's normal behavior for free, which keeps the code short.

08Activity timeline log

Icon dots along a vertical line fade and rise into place one after another, from top to bottom.

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

Passing each item's index in as a --n custom property and computing the delay with calc() lets any number of entries stagger in order without a loop in the animation itself.

09Checklist strikethrough complete

Tapping the checkbox fills it with color and pops in a check mark, while the text gets a left-to-right strikethrough and fades.

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 doesn't animate smoothly across browsers, so instead this uses a fake underline scaled from scaleX(0) to scaleX(1). Pinning transform-origin: left is what makes the line draw from the left edge.

Where does an accordion list break?

The first trap we actually hit building item 01 was trying to animate grid-template-rows straight to autoauto isn't a real start or end value the browser can interpolate between, so the row just snaps open instead of easing; it has to animate between two fr values like 0fr and 1fr instead. The second trap showed up in item 04: hiding the new rows with display: none and only then swapping the class would mean the browser finishes the layout change before it has a chance to transition — starting from max-height: 0, which is actually rendered at zero size, is what keeps the animation visible. The zip password for these nine files is pw5ht9pc, and unzipping it lets you compare the vanilla and react versions of all nine side by side.

Other list and table topics live under the real-world UI category; the other nine list patterns — swipe, sort, and pagination — are in 9 Swipe to Delete List UI Patterns; and what this site covers is on the about page. How grid-template-rows interpolates fr units is documented on MDN's grid-template-rows page, and how far position: sticky reaches is on MDN's position page.

Accessibility — what each of the nine drops under reduced-motion

With reduced motion turned on, all nine demos turn off transition and animation and jump straight to the end state. Items like 01 and 03, where height or position changes, land directly on the open/transitioned state; items like 05 and 09, where the bounce itself is the point, lose the bounce but keep the result — the menu open, the checkbox checked. Items 06 and 09 are already fully operable by keyboard, so nothing about how they work changes.

FAQ

Can I just use the HTML <details> element instead of building this myself?

<details>/<summary> gives you open/close for free, but you still have to add the height animation yourself and the default styling varies by browser — that's why item 01 above uses a button plus grid-template-rows for finer control instead. If accessibility is the priority, styling on top of <details> is a reasonable alternative.

Do these patterns still work if the list has hundreds of items?

For 01 and 08, where every visible row carries its own animation, and for 02, where every group needs its own sticky-header wrapper element, a list that grows into the hundreds needs extra work like virtualization (rendering only what's on screen). The zip code is built for the tens-of-items scale you'd see on one page.

Does the long-press menu work the same way on mobile?

Yes — pointerdown/pointerup handle mouse, touch, and pen through the same code. Mobile browsers can trigger their own context menu or text selection on a long press, though, so the demo sets touch-action: manipulation to prevent that conflict.

Enter the archive password

The password is inside this article. You will find it as you read.