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.
01 row-expand-inline-detail
02 grouped-header-sticky-section
03 list-to-detail-slide-transition
04 load-more-button
05 long-press-actions-menu
06 reorder-indicator-drop-gap
07 inline-edit-field
08 activity-timeline-log
09 checklist-strikethrough-complete
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Row inline expand
- 02 Grouped sticky section header
- 03 List-to-detail slide transition
- 04 Load-more button list
- 05 Long-press action menu
- 06 Drop-gap indicator reorder
- 07 List inline edit
- 08 Activity timeline log
- 09 Checklist strikethrough complete
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 with drag and drop list items (06), editing a value right where it sits (07), logging what already happened (08), and finally marking a task as done (09). The finger-driven cousin of item 06's drag reorder is collected in Swipe to Delete List UI Patterns, and the screen you show when there is nothing to expand lives in Empty State UI Design Cards.
01Row inline expand
What you see in this accordion list ui
Clicking an order-history row spins its arrow icon 90 degrees and unfolds detail content beneath it.

<main class="stage">
<ul class="rowx-list" aria-label="Order history">
<li class="rowx-item">
<button class="rowx-item__head is-demo" type="button" aria-expanded="false">
<svg class="rowx-item__caret" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>
<span class="rowx-item__title">Order #10493</span>
<span class="rowx-item__meta">₩32,000</span>
</button>
<div class="rowx-item__body">
<div class="rowx-item__body-inner">
<p>Ships to Gangnam, Seoul · Paid via KakaoPay</p>
<p>Arriving 2026-09-08</p>
</div>
</div>
</li>
<li class="rowx-item">
<button class="rowx-item__head" type="button" aria-expanded="false">
<svg class="rowx-item__caret" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>
<span class="rowx-item__title">Order #10488</span>
<span class="rowx-item__meta">₩18,500</span>
</button>
<div class="rowx-item__body">
<div class="rowx-item__body-inner">
<p>Ships to Seongnam, Gyeonggi · Paid by card</p>
<p>Delivered 2026-09-05</p>
</div>
</div>
</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 펼침 시간
$easing: $ease-spring;
$color: $subject-blue; // 캐럿·강조색
@include stage($stage-ink);
.rowx-list {
list-style: none;
margin: 0;
padding: 0;
width: min(300px, 84vw);
display: flex;
flex-direction: column;
gap: $sp-3;
}
.rowx-item {
border-radius: $r-card;
background: $stage-paper;
overflow: hidden;
}
.rowx-item__head {
width: 100%;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-4;
border: 0;
background: none;
cursor: pointer;
text-align: left;
font-family: inherit;
}
.rowx-item__head:focus-visible { outline: 2px solid $color; outline-offset: -2px; border-radius: $r-card; }
.rowx-item__caret {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: $color;
transition: transform $duration $easing;
}
.rowx-item.is-open .rowx-item__caret { transform: rotate(90deg); }
.rowx-item__title { flex: 1 1 auto; font-weight: 700; font-size: 14px; color: $stage-ink; }
.rowx-item__meta { font-size: 12px; color: rgba(23, 20, 26, .6); }
.rowx-item__body {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows $duration $easing;
}
.rowx-item.is-open .rowx-item__body { grid-template-rows: 1fr; }
.rowx-item__body-inner {
overflow: hidden;
visibility: hidden; // 접힌 상태(0fr) 에서 글자가 겹쳐 보이는 것을 막는다 — 열리면 보임
padding: 0 $sp-4;
display: flex;
flex-direction: column;
gap: $sp-1;
}
.rowx-item.is-open .rowx-item__body-inner { padding: 0 $sp-4 $sp-4; visibility: visible; }
.rowx-item__body-inner p { margin: 0; font-size: 12px; color: rgba(23, 20, 26, .72); }
// 데모 강제 루프 — 첫 항목만 열림/닫힘을 keyframes 로 반복 (document.getAnimations() 가 잡도록 transition 이 아니라 animation)
.rowx-item__head.is-demo { pointer-events: none; }
.rowx-item__head.is-demo .rowx-item__caret { animation: rowx-caret-loop $dur-loop $easing infinite; }
.rowx-item:has(.rowx-item__head.is-demo) .rowx-item__body { animation: rowx-rows-loop $dur-loop $easing infinite; }
@keyframes rowx-caret-loop {
0%, 10% { transform: rotate(0deg); }
40%, 65% { transform: rotate(90deg); }
95%, 100% { transform: rotate(0deg); }
}
@keyframes rowx-rows-loop {
0%, 10% { grid-template-rows: 0fr; }
40%, 65% { grid-template-rows: 1fr; }
95%, 100% { grid-template-rows: 0fr; }
}
@media (prefers-reduced-motion: reduce) {
.rowx-item__caret, .rowx-item__body { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rowx-list {
list-style: none;
margin: 0;
padding: 0;
width: min(300px, 84vw);
display: flex;
flex-direction: column;
gap: 12px;
}
.rowx-item {
border-radius: 16px;
background: #ffffff;
overflow: hidden;
}
.rowx-item__head {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 16px;
border: 0;
background: none;
cursor: pointer;
text-align: left;
font-family: inherit;
}
.rowx-item__head:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
border-radius: 16px;
}
.rowx-item__caret {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rowx-item.is-open .rowx-item__caret {
transform: rotate(90deg);
}
.rowx-item__title {
flex: 1 1 auto;
font-weight: 700;
font-size: 14px;
color: #17141a;
}
.rowx-item__meta {
font-size: 12px;
color: rgba(23, 20, 26, 0.6);
}
.rowx-item__body {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rowx-item.is-open .rowx-item__body {
grid-template-rows: 1fr;
}
.rowx-item__body-inner {
overflow: hidden;
visibility: hidden;
padding: 0 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.rowx-item.is-open .rowx-item__body-inner {
padding: 0 16px 16px;
visibility: visible;
}
.rowx-item__body-inner p {
margin: 0;
font-size: 12px;
color: rgba(23, 20, 26, 0.72);
}
.rowx-item__head.is-demo {
pointer-events: none;
}
.rowx-item__head.is-demo .rowx-item__caret {
animation: rowx-caret-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.rowx-item:has(.rowx-item__head.is-demo) .rowx-item__body {
animation: rowx-rows-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes rowx-caret-loop {
0%, 10% {
transform: rotate(0deg);
}
40%, 65% {
transform: rotate(90deg);
}
95%, 100% {
transform: rotate(0deg);
}
}
@keyframes rowx-rows-loop {
0%, 10% {
grid-template-rows: 0fr;
}
40%, 65% {
grid-template-rows: 1fr;
}
95%, 100% {
grid-template-rows: 0fr;
}
}
@media (prefers-reduced-motion: reduce) {
.rowx-item__caret, .rowx-item__body {
transition: none;
}
}(function () {
document.querySelectorAll('.rowx-item__head').forEach(function (btn) {
var item = btn.closest('.rowx-item');
btn.addEventListener('click', function () {
btn.classList.remove('is-demo');
var open = item.classList.toggle('is-open');
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
});
});
})();import { useState, type CSSProperties } from "react";
import styles from "./RowExpandInlineDetail.module.scss";
export type OrderRow = { title: string; meta: string; lines: string[] };
export type RowExpandInlineDetailProps = {
rows: OrderRow[];
/** 펼침 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 캐럿·강조색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 클릭한 행의 인덱스를 열림 상태로 토글. grid-template-rows 0fr→1fr 로 높이를 부드럽게 편다 */
export default function RowExpandInlineDetail({
rows,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: RowExpandInlineDetailProps) {
const [openIdx, setOpenIdx] = useState<number | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<ul className={styles.list} style={vars}>
{rows.map((row, i) => {
const open = openIdx === i;
return (
<li key={row.title} className={styles.item}>
<button
className={styles.head}
type="button"
aria-expanded={open}
onClick={() => setOpenIdx(open ? null : i)}
>
<svg className={styles.caret} style={{ transform: open ? "rotate(90deg)" : undefined }} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2" /><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z" /></g>
</svg>
<span className={styles.title}>{row.title}</span>
<span className={styles.meta}>{row.meta}</span>
</button>
<div className={styles.body} style={{ gridTemplateRows: open ? "1fr" : "0fr" }}>
<div className={styles.bodyInner} style={{ visibility: open ? "visible" : "hidden" }}>
{row.lines.map((line) => <p key={line}>{line}</p>)}
</div>
</div>
</li>
);
})}
</ul>
);
}01 · Row inline expand — Measured area 19.034% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
.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
What happens on screen
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.

<main class="stage">
<div class="grp-wrap is-demo" tabindex="0" aria-label="Contact list. Scroll with arrow keys or drag">
<ul class="grp-list">
<li class="grp-group">
<div class="grp-group__header">A</div>
<ul class="grp-group__rows">
<li>Ari</li><li>Aiden</li><li>Ava</li>
</ul>
</li>
<li class="grp-group">
<div class="grp-group__header">B</div>
<ul class="grp-group__rows">
<li>Bella</li><li>Ben</li><li>Bora</li>
</ul>
</li>
<li class="grp-group">
<div class="grp-group__header">C</div>
<ul class="grp-group__rows">
<li>Chloe</li><li>Charlie</li><li>Cyan</li>
</ul>
</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 스크롤 자동 시연 왕복 시간(스크립트 값과 맞춤)
$easing: $ease-out;
$color: $subject-pink; // 그룹 헤더 배경 (파스텔)
@include stage($stage-paper);
.grp-wrap {
width: 220px;
height: 168px;
overflow: auto;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
}
.grp-wrap:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.grp-list { list-style: none; margin: 0; padding: 0; }
.grp-group { margin: 0; padding: 0; }
.grp-group__header {
position: sticky;
top: 0;
z-index: 1;
padding: $sp-2 $sp-4;
font-weight: 700;
font-size: 13px;
color: $stage-ink;
background: $color;
}
.grp-group:nth-of-type(2) .grp-group__header { background: $subject-mint; }
.grp-group:nth-of-type(3) .grp-group__header { background: $subject-lilac; }
.grp-group__rows {
list-style: none;
margin: 0;
padding: 0;
}
.grp-group__rows li {
padding: $sp-2 $sp-4;
font-size: 13px;
color: $stage-ink;
border-bottom: 1px solid rgba(23, 20, 26, .06);
}
@media (prefers-reduced-motion: reduce) {
.grp-wrap { scroll-behavior: auto; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.grp-wrap {
width: 220px;
height: 168px;
overflow: auto;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.grp-wrap:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.grp-list {
list-style: none;
margin: 0;
padding: 0;
}
.grp-group {
margin: 0;
padding: 0;
}
.grp-group__header {
position: sticky;
top: 0;
z-index: 1;
padding: 8px 16px;
font-weight: 700;
font-size: 13px;
color: #17141a;
background: #ff9ebb;
}
.grp-group:nth-of-type(2) .grp-group__header {
background: #4cd4a6;
}
.grp-group:nth-of-type(3) .grp-group__header {
background: #b9a0ff;
}
.grp-group__rows {
list-style: none;
margin: 0;
padding: 0;
}
.grp-group__rows li {
padding: 8px 16px;
font-size: 13px;
color: #17141a;
border-bottom: 1px solid rgba(23, 20, 26, 0.06);
}
@media (prefers-reduced-motion: reduce) {
.grp-wrap {
scroll-behavior: auto;
}
}(function () {
var wrap = document.querySelector('.grp-wrap');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = null, t0 = null;
function maxY() { return wrap.scrollHeight - wrap.clientHeight; }
function loop(ts) {
if (!wrap.classList.contains('is-demo')) return;
if (t0 === null) t0 = ts;
var p = ((ts - t0) / 2000) % 1;
var s = p < .5 ? p * 2 : 2 - p * 2;
wrap.scrollTop = s * maxY();
raf = requestAnimationFrame(loop);
}
function stop() {
wrap.classList.remove('is-demo');
if (raf) cancelAnimationFrame(raf);
}
wrap.addEventListener('pointerdown', stop);
wrap.addEventListener('wheel', stop, { passive: true });
wrap.addEventListener('keydown', function (e) {
var step = 24;
if (e.key === 'ArrowDown') { stop(); wrap.scrollTop += step; }
else if (e.key === 'ArrowUp') { stop(); wrap.scrollTop -= step; }
else return;
e.preventDefault();
});
if (!reduce) requestAnimationFrame(loop);
})();import type { CSSProperties } from "react";
import styles from "./GroupedHeaderStickySection.module.scss";
export type Group = { letter: string; names: string[] };
export type GroupedHeaderStickySectionProps = {
groups: Group[];
/** 스크롤 이징 (기본 300ms) */
duration?: number;
easing?: string;
/** 첫 그룹 헤더 배경(파스텔) */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 그룹 헤더가 position:sticky 로 스크롤 중 위에 붙어 있다가 다음 그룹이 밀어 올린다 */
export default function GroupedHeaderStickySection({
groups,
duration = 300,
easing = "ease-out",
color = "#ff9ebb",
}: GroupedHeaderStickySectionProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.wrap} style={vars} tabIndex={0} aria-label="연락처 목록">
<ul className={styles.list}>
{groups.map((g, i) => (
<li key={g.letter} className={styles.group}>
<div className={styles.header} data-i={i % 3}>{g.letter}</div>
<ul className={styles.rows}>
{g.names.map((n) => <li key={n}>{n}</li>)}
</ul>
</li>
))}
</ul>
</div>
);
}02 · Grouped sticky section header — Measured area 25.533% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
.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
What moves
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.

<main class="stage">
<div class="ltd-viewport is-demo">
<div class="ltd-track">
<ul class="ltd-list" aria-label="Settings">
<li>
<button class="ltd-list__row" type="button">
<span>Notification settings</span>
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>
</button>
</li>
<li>
<button class="ltd-list__row" type="button">
<span>Privacy</span>
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>
</button>
</li>
</ul>
<div class="ltd-detail">
<button class="ltd-detail__back" type="button">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M112 56v144l-72-72Z" opacity=".2"/><path d="M216 120h-96V56a8 8 0 0 0-13.66-5.66l-72 72a8 8 0 0 0 0 11.32l72 72A8 8 0 0 0 120 200v-64h96a8 8 0 0 0 0-16m-112 60.69L51.31 128L104 75.31Z"/></g></svg>
Back
</button>
<h2>Notification settings</h2>
<p>Turn push, email, and text alerts on or off per item.</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 패널 슬라이드 시간
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.ltd-viewport {
width: min(260px, 80vw);
height: 168px;
overflow: hidden;
border-radius: $r-card;
background: $stage-paper;
position: relative;
}
.ltd-track {
display: flex;
width: 200%;
height: 100%;
transition: transform $duration $easing;
}
.ltd-viewport.is-open .ltd-track { transform: translateX(-50%); }
.ltd-list, .ltd-detail {
width: 50%;
flex: 0 0 auto;
box-sizing: border-box;
padding: $sp-4;
}
.ltd-list { list-style: none; margin: 0; display: flex; flex-direction: column; gap: $sp-2; }
.ltd-list__row {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
border: 0;
background: rgba(23, 20, 26, .04);
border-radius: $r-sm;
padding: $sp-3;
font-size: 13px;
color: $stage-ink;
cursor: pointer;
font-family: inherit;
}
.ltd-list__row:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ltd-list__row svg { width: 14px; height: 14px; color: $color; }
.ltd-detail { display: flex; flex-direction: column; gap: $sp-2; }
.ltd-detail h2 { margin: 0; font-size: 15px; color: $stage-ink; }
.ltd-detail p { margin: 0; font-size: 12px; color: rgba(23, 20, 26, .65); }
.ltd-detail__back {
display: inline-flex;
align-items: center;
gap: $sp-1;
align-self: flex-start;
border: 0;
background: none;
color: $subject-blue-deep; // 페이퍼 패널 위 파란 글자 — $subject-blue 는 4.25:1
font-weight: 700;
font-size: 13px;
cursor: pointer;
padding: $sp-1 0;
font-family: inherit;
}
.ltd-detail__back:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
.ltd-detail__back svg { width: 14px; height: 14px; }
// 데모 강제 루프 — track 을 keyframes 로 자동 왕복
.ltd-viewport.is-demo .ltd-track { animation: ltd-loop $dur-loop $easing infinite; }
@keyframes ltd-loop {
0%, 10% { transform: translateX(0); }
45%, 65% { transform: translateX(-50%); }
95%, 100% { transform: translateX(0); }
}
@media (prefers-reduced-motion: reduce) {
.ltd-track { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ltd-viewport {
width: min(260px, 80vw);
height: 168px;
overflow: hidden;
border-radius: 16px;
background: #ffffff;
position: relative;
}
.ltd-track {
display: flex;
width: 200%;
height: 100%;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ltd-viewport.is-open .ltd-track {
transform: translateX(-50%);
}
.ltd-list, .ltd-detail {
width: 50%;
flex: 0 0 auto;
box-sizing: border-box;
padding: 16px;
}
.ltd-list {
list-style: none;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.ltd-list__row {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
border: 0;
background: rgba(23, 20, 26, 0.04);
border-radius: 8px;
padding: 12px;
font-size: 13px;
color: #17141a;
cursor: pointer;
font-family: inherit;
}
.ltd-list__row:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ltd-list__row svg {
width: 14px;
height: 14px;
color: #2f6df6;
}
.ltd-detail {
display: flex;
flex-direction: column;
gap: 8px;
}
.ltd-detail h2 {
margin: 0;
font-size: 15px;
color: #17141a;
}
.ltd-detail p {
margin: 0;
font-size: 12px;
color: rgba(23, 20, 26, 0.65);
}
.ltd-detail__back {
display: inline-flex;
align-items: center;
gap: 4px;
align-self: flex-start;
border: 0;
background: none;
color: #0a4fe8;
font-weight: 700;
font-size: 13px;
cursor: pointer;
padding: 4px 0;
font-family: inherit;
}
.ltd-detail__back:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.ltd-detail__back svg {
width: 14px;
height: 14px;
}
.ltd-viewport.is-demo .ltd-track {
animation: ltd-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes ltd-loop {
0%, 10% {
transform: translateX(0);
}
45%, 65% {
transform: translateX(-50%);
}
95%, 100% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.ltd-track {
transition: none;
}
}(function () {
var view = document.querySelector('.ltd-viewport');
function open() { view.classList.remove('is-demo'); view.classList.add('is-open'); view.querySelector('.ltd-detail__back').focus({ preventScroll: true }); }
function close() { view.classList.remove('is-demo'); view.classList.remove('is-open'); view.querySelector('.ltd-list__row').focus({ preventScroll: true }); }
view.querySelectorAll('.ltd-list__row').forEach(function (row) {
row.addEventListener('click', open);
});
view.querySelector('.ltd-detail__back').addEventListener('click', close);
view.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && view.classList.contains('is-open')) close();
});
})();import { useRef, useState, type CSSProperties } from "react";
import styles from "./ListToDetailSlideTransition.module.scss";
export type ListRow = { title: string; heading: string; body: string };
export type ListToDetailSlideTransitionProps = {
rows: ListRow[];
/** 패널 슬라이드 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 목록 패널과 상세 패널을 나란히 두고 track 전체를 translateX 로 밀어 전환한다 */
export default function ListToDetailSlideTransition({
rows,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ListToDetailSlideTransitionProps) {
const [open, setOpen] = useState<ListRow | null>(null);
const backRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div
className={styles.viewport}
style={vars}
onKeyDown={(e: any) => { if (e.key === "Escape" && open) setOpen(null); }}
>
<div className={styles.track} style={{ transform: open ? "translateX(-50%)" : undefined }}>
<ul className={styles.list}>
{rows.map((row) => (
<li key={row.title}>
<button className={styles.row} type="button" onClick={() => setOpen(row)}>
<span>{row.title}</span>
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2" /><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z" /></g></svg>
</button>
</li>
))}
</ul>
<div className={styles.detail}>
<button ref={backRef} className={styles.back} type="button" onClick={() => setOpen(null)}>
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M112 56v144l-72-72Z" opacity=".2" /><path d="M216 120h-96V56a8 8 0 0 0-13.66-5.66l-72 72a8 8 0 0 0 0 11.32l72 72A8 8 0 0 0 120 200v-64h96a8 8 0 0 0 0-16m-112 60.69L51.31 128L104 75.31Z" /></g></svg>
목록
</button>
{open && <><h2>{open.heading}</h2><p>{open.body}</p></>}
</div>
</div>
</div>
);
}03 · List-to-detail slide transition — Measured area 6.846% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
.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
What you see
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.

<main class="stage">
<div class="lm-list is-demo">
<ul class="lm-rows">
<li class="lm-row">Notice board updated</li>
<li class="lm-row">Scheduled maintenance in September</li>
<li class="lm-row lm-row--pending" data-n="1">New feature release notes</li>
<li class="lm-row lm-row--pending" data-n="2">Event winners announced</li>
<li class="lm-row lm-row--pending" data-n="3">Terms of service updated</li>
</ul>
<button class="lm-btn" type="button">
<svg class="lm-btn__icon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M240 56v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h27.4l-26.59-24.36l-.25-.24a80 80 0 1 0-1.67 114.78a8 8 0 0 1 11 11.63A95.44 95.44 0 0 1 128 224h-1.32a96 96 0 1 1 69.07-164L224 85.8V56a8 8 0 1 1 16 0"/></g></svg>
<span class="lm-btn__label">Load more</span>
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter; // 새 행이 밀려 올라오는 시간
$easing: $ease-spring;
$color: $subject-blue; // 신뢰감 있는 브랜드 컬러(밝은 SaaS 톤)
@include stage($stage-paper);
.lm-list {
width: min(260px, 82vw);
display: flex;
flex-direction: column;
gap: $sp-3;
}
.lm-rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $sp-2; }
.lm-row {
padding: $sp-3;
border-radius: $r-sm;
background: #eef4ff; // $subject-blue 를 흰 배경에 6% 섞은 불투명 톤
font-size: 13px;
color: $stage-ink;
}
.lm-row--pending {
max-height: 0;
opacity: 0;
transform: translateY(10px);
overflow: hidden;
padding-top: 0;
padding-bottom: 0;
margin: 0;
transition: max-height $duration $easing, opacity $duration $easing, transform $duration $easing, padding $duration $easing;
}
.lm-list.has-loaded .lm-row--pending { max-height: 40px; opacity: 1; transform: translateY(0); padding-top: $sp-3; padding-bottom: $sp-3; }
.lm-list.has-loaded .lm-row--pending[data-n="2"] { transition-delay: 90ms; }
.lm-list.has-loaded .lm-row--pending[data-n="3"] { transition-delay: 180ms; }
.lm-btn {
align-self: center;
display: inline-flex;
align-items: center;
gap: $sp-2;
border: 1px solid $color;
background: $stage-paper;
color: $color;
font-weight: 700;
font-size: 13px;
border-radius: $r-pill;
padding: $sp-2 $sp-5;
cursor: pointer;
font-family: inherit;
}
.lm-btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.lm-btn:disabled { opacity: .5; cursor: default; }
.lm-btn__icon { width: 14px; height: 14px; opacity: 0; width: 0; transition: opacity $dur-quick, width $dur-quick; }
.lm-list.is-loading .lm-btn__icon { opacity: 1; width: 14px; animation: lm-spin .6s linear infinite; }
@keyframes lm-spin { to { transform: rotate(360deg); } }
// 데모 강제 루프 — 스피너 회전 → 대기 행 순차 등장 → 리셋 을 한 keyframes 로
.lm-list.is-demo .lm-btn__icon {
opacity: 1; width: 14px;
animation: lm-loop-spin $dur-loop linear infinite;
}
.lm-list.is-demo .lm-row--pending {
animation: lm-loop-row $dur-loop $easing infinite;
}
.lm-list.is-demo .lm-row--pending[data-n="2"] { animation-delay: 60ms; }
.lm-list.is-demo .lm-row--pending[data-n="3"] { animation-delay: 120ms; }
@keyframes lm-loop-spin {
0%, 15% { transform: rotate(0deg); opacity: 1; }
30% { transform: rotate(360deg); opacity: 1; }
35%, 100% { opacity: 0; }
}
@keyframes lm-loop-row {
0%, 30% { max-height: 0; opacity: 0; transform: translateY(10px); padding-top: 0; padding-bottom: 0; }
55%, 85% { max-height: 40px; opacity: 1; transform: translateY(0); padding-top: $sp-3; padding-bottom: $sp-3; }
100% { max-height: 0; opacity: 0; transform: translateY(10px); padding-top: 0; padding-bottom: 0; }
}
@media (prefers-reduced-motion: reduce) {
.lm-row--pending, .lm-btn__icon { transition: none; animation: none; }
.lm-list.has-loaded .lm-row--pending { opacity: 1; transform: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lm-list {
width: min(260px, 82vw);
display: flex;
flex-direction: column;
gap: 12px;
}
.lm-rows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.lm-row {
padding: 12px;
border-radius: 8px;
background: #eef4ff;
font-size: 13px;
color: #17141a;
}
.lm-row--pending {
max-height: 0;
opacity: 0;
transform: translateY(10px);
overflow: hidden;
padding-top: 0;
padding-bottom: 0;
margin: 0;
transition: max-height 600ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1), padding 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.lm-list.has-loaded .lm-row--pending {
max-height: 40px;
opacity: 1;
transform: translateY(0);
padding-top: 12px;
padding-bottom: 12px;
}
.lm-list.has-loaded .lm-row--pending[data-n="2"] {
transition-delay: 90ms;
}
.lm-list.has-loaded .lm-row--pending[data-n="3"] {
transition-delay: 180ms;
}
.lm-btn {
align-self: center;
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid #2f6df6;
background: #ffffff;
color: #2f6df6;
font-weight: 700;
font-size: 13px;
border-radius: 999px;
padding: 8px 20px;
cursor: pointer;
font-family: inherit;
}
.lm-btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.lm-btn:disabled {
opacity: 0.5;
cursor: default;
}
.lm-btn__icon {
width: 14px;
height: 14px;
opacity: 0;
width: 0;
transition: opacity 220ms, width 220ms;
}
.lm-list.is-loading .lm-btn__icon {
opacity: 1;
width: 14px;
animation: lm-spin 0.6s linear infinite;
}
@keyframes lm-spin {
to {
transform: rotate(360deg);
}
}
.lm-list.is-demo .lm-btn__icon {
opacity: 1;
width: 14px;
animation: lm-loop-spin 2s linear infinite;
}
.lm-list.is-demo .lm-row--pending {
animation: lm-loop-row 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.lm-list.is-demo .lm-row--pending[data-n="2"] {
animation-delay: 60ms;
}
.lm-list.is-demo .lm-row--pending[data-n="3"] {
animation-delay: 120ms;
}
@keyframes lm-loop-spin {
0%, 15% {
transform: rotate(0deg);
opacity: 1;
}
30% {
transform: rotate(360deg);
opacity: 1;
}
35%, 100% {
opacity: 0;
}
}
@keyframes lm-loop-row {
0%, 30% {
max-height: 0;
opacity: 0;
transform: translateY(10px);
padding-top: 0;
padding-bottom: 0;
}
55%, 85% {
max-height: 40px;
opacity: 1;
transform: translateY(0);
padding-top: 12px;
padding-bottom: 12px;
}
100% {
max-height: 0;
opacity: 0;
transform: translateY(10px);
padding-top: 0;
padding-bottom: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.lm-row--pending, .lm-btn__icon {
transition: none;
animation: none;
}
.lm-list.has-loaded .lm-row--pending {
opacity: 1;
transform: none;
}
}(function () {
var list = document.querySelector('.lm-list');
var btn = list.querySelector('.lm-btn');
btn.addEventListener('click', function () {
if (list.classList.contains('has-loaded')) return;
list.classList.remove('is-demo');
list.classList.add('is-loading');
setTimeout(function () {
list.classList.remove('is-loading');
list.classList.add('has-loaded');
btn.querySelector('.lm-btn__label').textContent = 'No more items';
btn.disabled = true;
}, 500);
});
})();import { useState, type CSSProperties } from "react";
import styles from "./LoadMoreButton.module.scss";
export type LoadMoreButtonProps = {
initial: string[];
more: string[];
/** 새 행이 밀려 올라오는 시간 (기본 600ms) */
duration?: number;
easing?: string;
/** 브랜드 컬러 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 더보기 클릭 → 로딩 스피너 → 대기 행이 stagger 로 펼쳐진다 */
export default function LoadMoreButton({
initial,
more,
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: LoadMoreButtonProps) {
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const click = () => {
if (loaded) return;
setLoading(true);
setTimeout(() => { setLoading(false); setLoaded(true); }, 500);
};
return (
<div className={styles.list} style={vars}>
<ul className={styles.rows}>
{initial.map((t) => <li key={t} className={styles.row}>{t}</li>)}
{more.map((t, i) => (
<li key={t} className={`${styles.row} ${styles.pending} ${loaded ? styles.shown : ""}`} style={{ transitionDelay: `${i * 90}ms` }}>{t}</li>
))}
</ul>
<button className={styles.btn} type="button" onClick={click} disabled={loaded}>
<svg className={`${styles.icon} ${loading ? styles.spin : ""}`} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2" /><path d="M240 56v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h27.4l-26.59-24.36l-.25-.24a80 80 0 1 0-1.67 114.78a8 8 0 0 1 11 11.63A95.44 95.44 0 0 1 128 224h-1.32a96 96 0 1 1 69.07-164L224 85.8V56a8 8 0 1 1 16 0" /></g>
</svg>
<span>{loaded ? "더 이상 없음" : "더보기"}</span>
</button>
</div>
);
}04 · Load-more button list — Measured area 43.192% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
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
What happens on screen
Holding a row for half a second pops a menu of star, archive, and delete icons into view with a bouncy scale-in.

<main class="stage">
<div class="lp-row is-demo">
<span class="lp-row__title">Design meeting notes</span>
<button class="lp-row__more" type="button" aria-haspopup="true" aria-expanded="false" aria-label="Open item menu">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M176 32v192a16 16 0 0 1-16 16H96a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h64a16 16 0 0 1 16 16" opacity=".2"/><path d="M140 128a12 12 0 1 1-12-12a12 12 0 0 1 12 12m-12-56a12 12 0 1 0-12-12a12 12 0 0 0 12 12m0 112a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg>
</button>
<div class="lp-menu" role="menu">
<button class="lp-menu__item" type="button" role="menuitem" aria-label="Favorite">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg>
</button>
<button class="lp-menu__item" type="button" role="menuitem" aria-label="Archive">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 96v96a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96Z" opacity=".2"/><path d="M224 48H32a16 16 0 0 0-16 16v24a16 16 0 0 0 16 16v88a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16v-88a16 16 0 0 0 16-16V64a16 16 0 0 0-16-16m-16 144H48v-88h160Zm16-104H32V64h192zM96 136a8 8 0 0 1 8-8h48a8 8 0 0 1 0 16h-48a8 8 0 0 1-8-8"/></g></svg>
</button>
<button class="lp-menu__item lp-menu__item--danger" type="button" role="menuitem" aria-label="Delete">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 메뉴 확대 시간
$easing: $ease-pop;
$color: $stage-ink;
@include stage($stage-ink);
.lp-row {
position: relative;
width: min(240px, 78vw);
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-4;
border-radius: $r-card;
background: $stage-paper;
touch-action: manipulation;
}
.lp-row__title { font-size: 14px; font-weight: 700; color: $color; }
.lp-row__more {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .06);
color: $color;
cursor: pointer;
}
.lp-row__more svg { width: 16px; height: 16px; }
.lp-row__more:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.lp-menu {
position: absolute;
top: 100%;
right: $sp-4;
margin-top: $sp-2;
display: flex;
gap: $sp-2;
padding: $sp-2;
border-radius: $r-control;
background: $color;
box-shadow: $shadow-raised;
transform: scale(.6);
opacity: 0;
transform-origin: top right; // 규칙8: 우상단 참조 변에서 펼침
transition: transform $duration $easing, opacity $duration $easing;
pointer-events: none;
}
.lp-row.is-open .lp-menu { transform: scale(1); opacity: 1; pointer-events: auto; }
.lp-menu__item {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: $r-sm;
background: rgba(255, 255, 255, .08);
color: $stage-paper;
cursor: pointer;
}
.lp-menu__item svg { width: 16px; height: 16px; }
.lp-menu__item--danger { color: $error; }
.lp-menu__item:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
// 데모 강제 루프 — 메뉴가 튀어 오르듯 나타났다 사라짐을 keyframes 로
.lp-row.is-demo .lp-menu { animation: lp-loop $dur-loop $easing infinite; }
@keyframes lp-loop {
0%, 10% { transform: scale(.6); opacity: 0; }
35% { transform: scale(1.06); opacity: 1; }
45%, 70% { transform: scale(1); opacity: 1; }
90%, 100% { transform: scale(.6); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.lp-menu { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lp-row {
position: relative;
width: min(240px, 78vw);
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-radius: 16px;
background: #ffffff;
touch-action: manipulation;
}
.lp-row__title {
font-size: 14px;
font-weight: 700;
color: #17141a;
}
.lp-row__more {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.06);
color: #17141a;
cursor: pointer;
}
.lp-row__more svg {
width: 16px;
height: 16px;
}
.lp-row__more:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.lp-menu {
position: absolute;
top: 100%;
right: 16px;
margin-top: 8px;
display: flex;
gap: 8px;
padding: 8px;
border-radius: 12px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: scale(0.6);
opacity: 0;
transform-origin: top right;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
pointer-events: none;
}
.lp-row.is-open .lp-menu {
transform: scale(1);
opacity: 1;
pointer-events: auto;
}
.lp-menu__item {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
cursor: pointer;
}
.lp-menu__item svg {
width: 16px;
height: 16px;
}
.lp-menu__item--danger {
color: #c2361a;
}
.lp-menu__item:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.lp-row.is-demo .lp-menu {
animation: lp-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes lp-loop {
0%, 10% {
transform: scale(0.6);
opacity: 0;
}
35% {
transform: scale(1.06);
opacity: 1;
}
45%, 70% {
transform: scale(1);
opacity: 1;
}
90%, 100% {
transform: scale(0.6);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.lp-menu {
transition: none;
}
}(function () {
var row = document.querySelector('.lp-row');
var more = row.querySelector('.lp-row__more');
var timer = null;
function openMenu() {
row.classList.remove('is-demo');
row.classList.add('is-open');
more.setAttribute('aria-expanded', 'true');
}
function closeMenu() {
row.classList.remove('is-open');
more.setAttribute('aria-expanded', 'false');
}
row.addEventListener('pointerdown', function (e) {
if (e.target.closest('button')) return;
timer = setTimeout(openMenu, 500);
});
['pointerup', 'pointerleave', 'pointercancel'].forEach(function (ev) {
row.addEventListener(ev, function () { clearTimeout(timer); });
});
more.addEventListener('click', function () {
row.classList.contains('is-open') ? closeMenu() : openMenu();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && row.classList.contains('is-open')) { closeMenu(); more.focus({ preventScroll: true }); }
});
})();import { useRef, useState, type CSSProperties, type PointerEvent } from "react";
import styles from "./LongPressActionsMenu.module.scss";
export type LongPressActionsMenuProps = {
title: string;
/** 메뉴 확대 시간 (기본 220ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const HOLD_MS = 500;
/** pointerdown 후 500ms 유지하면 메뉴가 열린다. "..." 버튼은 길게 누르기 없이도 같은 메뉴를 여는 키보드 대체 수단 */
export default function LongPressActionsMenu({
title,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#17141a",
}: LongPressActionsMenuProps) {
const [open, setOpen] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const moreRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const down = (e: PointerEvent<HTMLDivElement>) => {
if ((e.target as HTMLElement).closest("button")) return;
timer.current = setTimeout(() => setOpen(true), HOLD_MS);
};
const cancel = () => { if (timer.current) clearTimeout(timer.current); };
return (
<div
className={styles.row}
style={vars}
onPointerDown={down}
onPointerUp={cancel}
onPointerLeave={cancel}
onPointerCancel={cancel}
onKeyDown={(e: any) => { if (e.key === "Escape" && open) { setOpen(false); moreRef.current?.focus(); } }}
>
<span className={styles.title}>{title}</span>
<button ref={moreRef} className={styles.more} type="button" aria-haspopup="true" aria-expanded={open} aria-label="작업 메뉴 열기" onClick={() => setOpen((v) => !v)}>
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M176 32v192a16 16 0 0 1-16 16H96a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h64a16 16 0 0 1 16 16" opacity=".2" /><path d="M140 128a12 12 0 1 1-12-12a12 12 0 0 1 12 12m-12-56a12 12 0 1 0-12-12a12 12 0 0 0 12 12m0 112a12 12 0 1 0 12 12a12 12 0 0 0-12-12" /></g></svg>
</button>
<div className={`${styles.menu} ${open ? styles.open : ""}`} role="menu">
<button className={styles.item} type="button" role="menuitem" aria-label="즐겨찾기">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2" /><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5" /></g></svg>
</button>
<button className={styles.item} type="button" role="menuitem" aria-label="보관">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 96v96a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96Z" opacity=".2" /><path d="M224 48H32a16 16 0 0 0-16 16v24a16 16 0 0 0 16 16v88a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16v-88a16 16 0 0 0 16-16V64a16 16 0 0 0-16-16m-16 144H48v-88h160Zm16-104H32V64h192zM96 136a8 8 0 0 1 8-8h48a8 8 0 0 1 0 16h-48a8 8 0 0 1-8-8" /></g></svg>
</button>
<button className={`${styles.item} ${styles.danger}`} type="button" role="menuitem" aria-label="삭제">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2" /><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0" /></g></svg>
</button>
</div>
</div>
);
}05 · Long-press action menu — Measured area 2.742% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
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
What moves
Dragging the six-dot handle shows a highlighted empty gap line where the item will land, instead of just shoving the other rows aside.

<main class="stage">
<ul class="rd-list is-demo" aria-label="Priority list. On the handle, reorder with Alt+Up/Down">
<li class="rd-item" data-i="0">
<button class="rd-item__handle" type="button" aria-label="Reorder, up/down arrows">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2"/><path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg>
</button>
<span>Fix payment error</span>
</li>
<li class="rd-drop-indicator" aria-hidden="true"></li>
<li class="rd-item rd-item--lifted" data-i="1">
<button class="rd-item__handle" type="button" aria-label="Reorder, up/down arrows">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2"/><path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg>
</button>
<span>Login page QA</span>
</li>
<li class="rd-item" data-i="2">
<button class="rd-item__handle" type="button" aria-label="Reorder, up/down arrows">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2"/><path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg>
</button>
<span>Organize deploy docs</span>
</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 들어올림·이동 시간
$easing: $ease-spring;
$color: $subject-blue; // 드롭 인디케이터 색
@include stage($stage-ink);
.rd-list {
list-style: none;
margin: 0;
padding: 0;
width: min(260px, 82vw);
display: flex;
flex-direction: column;
}
.rd-item {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-3 $sp-4;
margin: $sp-1 0;
border-radius: $r-sm;
background: $stage-paper;
transition: transform $duration $easing, box-shadow $duration $easing;
}
.rd-item span { font-size: 13px; color: $stage-ink; }
.rd-item.is-dragging {
position: relative;
z-index: 2;
box-shadow: $shadow-raised;
transition: none;
cursor: grabbing;
}
.rd-item__handle {
width: 24px;
height: 24px;
display: grid;
place-items: center;
border: 0;
background: none;
color: rgba(23, 20, 26, .5);
cursor: grab;
flex: 0 0 auto;
touch-action: none;
}
.rd-item__handle svg { width: 14px; height: 14px; }
.rd-item__handle:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-xs; }
.rd-drop-indicator {
height: 0;
margin: 0 $sp-4;
border-radius: $r-xs;
background: $color;
opacity: 0;
transition: height $duration $easing, opacity $duration $easing;
}
.rd-list.is-dragging .rd-drop-indicator { height: 8px; opacity: 1; }
// 데모 강제 루프 — 가운데 항목이 들리고, 그 아래 자리에 드롭 인디케이터가 나타났다 정리되는 흐름
.rd-list.is-demo .rd-item--lifted {
animation: rd-lift-loop $dur-loop $easing infinite;
}
.rd-list.is-demo .rd-drop-indicator {
animation: rd-gap-loop $dur-loop $easing infinite;
}
@keyframes rd-lift-loop {
0%, 10% { transform: translateY(0) scale(1); box-shadow: none; }
40%, 65% { transform: translateY(52px) scale(1.03); box-shadow: $shadow-raised; }
95%, 100% { transform: translateY(0) scale(1); box-shadow: none; }
}
@keyframes rd-gap-loop {
0%, 10% { height: 0; opacity: 0; }
40%, 65% { height: 8px; opacity: 1; }
95%, 100% { height: 0; opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.rd-item, .rd-drop-indicator { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rd-list {
list-style: none;
margin: 0;
padding: 0;
width: min(260px, 82vw);
display: flex;
flex-direction: column;
}
.rd-item {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
margin: 4px 0;
border-radius: 8px;
background: #ffffff;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rd-item span {
font-size: 13px;
color: #17141a;
}
.rd-item.is-dragging {
position: relative;
z-index: 2;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transition: none;
cursor: grabbing;
}
.rd-item__handle {
width: 24px;
height: 24px;
display: grid;
place-items: center;
border: 0;
background: none;
color: rgba(23, 20, 26, 0.5);
cursor: grab;
flex: 0 0 auto;
touch-action: none;
}
.rd-item__handle svg {
width: 14px;
height: 14px;
}
.rd-item__handle:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 4px;
}
.rd-drop-indicator {
height: 0;
margin: 0 16px;
border-radius: 4px;
background: #2f6df6;
opacity: 0;
transition: height 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rd-list.is-dragging .rd-drop-indicator {
height: 8px;
opacity: 1;
}
.rd-list.is-demo .rd-item--lifted {
animation: rd-lift-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.rd-list.is-demo .rd-drop-indicator {
animation: rd-gap-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes rd-lift-loop {
0%, 10% {
transform: translateY(0) scale(1);
box-shadow: none;
}
40%, 65% {
transform: translateY(52px) scale(1.03);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
95%, 100% {
transform: translateY(0) scale(1);
box-shadow: none;
}
}
@keyframes rd-gap-loop {
0%, 10% {
height: 0;
opacity: 0;
}
40%, 65% {
height: 8px;
opacity: 1;
}
95%, 100% {
height: 0;
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.rd-item, .rd-drop-indicator {
transition: none;
}
}(function () {
var list = document.querySelector('.rd-list');
var indicator = list.querySelector('.rd-drop-indicator');
function order() { return Array.prototype.slice.call(list.querySelectorAll('.rd-item')); }
order().forEach(function (item) {
var handle = item.querySelector('.rd-item__handle');
var dragging = false, startY = 0;
handle.addEventListener('pointerdown', function (e) {
list.classList.remove('is-demo');
dragging = true;
startY = e.clientY;
item.classList.add('is-dragging');
list.classList.add('is-dragging');
handle.setPointerCapture(e.pointerId);
});
handle.addEventListener('pointermove', function (e) {
if (!dragging) return;
item.style.transform = 'translateY(' + (e.clientY - startY) + 'px)';
var rest = order().filter(function (r) { return r !== item; });
var target = rest.filter(function (r) {
var rect = r.getBoundingClientRect();
return e.clientY < rect.top + rect.height / 2;
})[0];
if (target) list.insertBefore(indicator, target);
else list.insertBefore(indicator, null);
});
function drop() {
if (!dragging) return;
dragging = false;
item.style.transform = '';
item.classList.remove('is-dragging');
list.classList.remove('is-dragging');
list.insertBefore(item, indicator);
}
handle.addEventListener('pointerup', drop);
handle.addEventListener('pointercancel', drop);
handle.addEventListener('keydown', function (e) {
if ((e.key !== 'ArrowUp' && e.key !== 'ArrowDown') || !e.altKey) return;
e.preventDefault();
list.classList.remove('is-demo');
var o = order();
var idx = o.indexOf(item);
var targetIdx = e.key === 'ArrowUp' ? idx - 1 : idx + 1;
if (targetIdx < 0 || targetIdx >= o.length) return;
if (e.key === 'ArrowUp') list.insertBefore(item, o[targetIdx]);
else list.insertBefore(item, o[targetIdx].nextSibling);
handle.focus({ preventScroll: true });
});
});
})();import { useRef, useState, type CSSProperties } from "react";
import styles from "./ReorderIndicatorDropGap.module.scss";
export type ReorderIndicatorDropGapProps = {
items: string[];
/** 들어올림·이동 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 드롭 인디케이터 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/**
* 손잡이를 드래그하면 항목이 통째로 밀리는 대신 놓일 자리에 빈 줄 인디케이터가 먼저 나타난다.
* 마우스를 못 쓰는 사람은 손잡이 포커스 후 Alt+화살표로 같은 순서 변경을 할 수 있다.
*/
export default function ReorderIndicatorDropGap({
items,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ReorderIndicatorDropGapProps) {
const [order, setOrder] = useState(items);
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [dragY, setDragY] = useState(0);
const [dropIdx, setDropIdx] = useState<number | null>(null);
const rowRefs = useRef<Array<HTMLLIElement | null>>([]);
const startY = useRef(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const move = (i: number, dir: -1 | 1) => {
const j = i + dir;
if (j < 0 || j >= order.length) return;
const next = order.slice();
[next[i], next[j]] = [next[j], next[i]];
setOrder(next);
};
const down = (i: number, e: any) => {
setDragIdx(i);
setDropIdx(i);
startY.current = e.clientY;
e.currentTarget.setPointerCapture(e.pointerId);
};
const dragMove = (e: any) => {
if (dragIdx === null) return;
setDragY(e.clientY - (startY.current ?? e.clientY));
let target = order.length;
const refs = rowRefs.current ?? [];
for (let k = 0; k < order.length; k++) {
const el = refs[k];
if (!el || k === dragIdx) continue;
const rect = el.getBoundingClientRect();
if (e.clientY < rect.top + rect.height / 2) { target = k; break; }
}
setDropIdx(target);
};
const drop = () => {
if (dragIdx === null || dropIdx === null) { setDragIdx(null); return; }
const next = order.slice();
const [moved] = next.splice(dragIdx, 1);
next.splice(dropIdx > dragIdx ? dropIdx - 1 : dropIdx, 0, moved);
setOrder(next);
setDragIdx(null); setDropIdx(null); setDragY(0);
};
const rows = order.flatMap((label, i) => {
const nodes = [];
if (dropIdx === i && dragIdx !== null) {
nodes.push(<li key={`gap-${label}`} className={styles.indicator} aria-hidden="true" />);
}
nodes.push(
<li
key={label}
ref={(el: HTMLLIElement | null) => { if (rowRefs.current) rowRefs.current[i] = el; }}
className={`${styles.item} ${dragIdx === i ? styles.dragging : ""}`}
style={dragIdx === i ? { transform: `translateY(${dragY}px)` } : undefined}
>
<button
className={styles.handle}
type="button"
aria-label="순서 변경, 위/아래 화살표"
onPointerDown={(e: any) => down(i, e)}
onPointerMove={dragMove}
onPointerUp={drop}
onPointerCancel={drop}
onKeyDown={(e: any) => {
if ((e.key === "ArrowUp" || e.key === "ArrowDown") && e.altKey) {
e.preventDefault();
move(i, e.key === "ArrowUp" ? -1 : 1);
}
}}
>
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2" /><path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12" /></g></svg>
</button>
<span>{label}</span>
</li>
);
return nodes;
});
return (
<ul className={styles.list} style={vars} aria-label="우선순위 목록. 손잡이에서 화살표 키(Alt+위/아래)로 순서 변경">
{rows}
{dropIdx === order.length && dragIdx !== null && <li className={styles.indicator} aria-hidden="true" />}
</ul>
);
}06 · Drop-gap indicator reorder — Measured area 19.851% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
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
What you see
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.

<main class="stage">
<div class="ie-row is-demo">
<label class="ie-row__label" for="ie-name">Name</label>
<div class="ie-row__field">
<input id="ie-name" class="ie-row__input" type="text" value="Kim Do-yoon" readonly>
<button class="ie-row__toggle" type="button" aria-label="Edit name">
<svg class="ie-row__icon ie-row__icon--pencil" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M221.66 90.34L192 120l-56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg>
<svg class="ie-row__icon ie-row__icon--check" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 테두리·아이콘 전환 시간
$easing: $ease-out;
$color: $subject-blue; // 신뢰감 있는 밝은 SaaS 브랜드 컬러
@include stage($stage-paper);
.ie-row {
width: min(240px, 78vw);
display: flex;
flex-direction: column;
gap: $sp-1;
padding: $sp-4;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
}
.ie-row__label { font-size: 11px; color: rgba(23, 20, 26, .72); }
.ie-row__field {
display: flex;
align-items: center;
gap: $sp-2;
border-radius: $r-sm;
border: 1px solid transparent;
padding: $sp-2 $sp-3;
transition: border-color $duration $easing, background $duration $easing;
}
.ie-row.is-editing .ie-row__field { border-color: $color; background: #eef4ff; }
.ie-row__input {
flex: 1 1 auto;
border: 0;
background: none;
font: 700 14px/1.3 sans-serif;
font-family: inherit;
color: $stage-ink;
padding: 0;
}
.ie-row__input:focus { outline: none; }
.ie-row__toggle {
position: relative;
width: 24px;
height: 24px;
flex: 0 0 auto;
display: grid;
place-items: center;
border: 0;
background: none;
color: $color;
cursor: pointer;
}
.ie-row__toggle:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-xs; }
.ie-row__icon {
position: absolute;
width: 15px;
height: 15px;
transition: opacity $duration $easing, transform $duration $easing;
}
.ie-row__icon--check { opacity: 0; transform: scale(.6); color: $subject-mint; }
.ie-row.is-saved .ie-row__icon--pencil { opacity: 0; transform: scale(.6); }
.ie-row.is-saved .ie-row__icon--check { opacity: 1; transform: scale(1); }
// 데모 강제 루프 — 편집 상태 진입 → 저장(체크 표시) → 원복 을 keyframes 로
.ie-row.is-demo .ie-row__field { animation: ie-field-loop $dur-loop $easing infinite; }
.ie-row.is-demo .ie-row__icon--pencil { animation: ie-pencil-loop $dur-loop $easing infinite; }
.ie-row.is-demo .ie-row__icon--check { animation: ie-check-loop $dur-loop $easing infinite; }
@keyframes ie-field-loop {
0%, 10% { border-color: transparent; background: transparent; }
30%, 80% { border-color: $color; background: #eef4ff; }
95%, 100% { border-color: transparent; background: transparent; }
}
@keyframes ie-pencil-loop {
0%, 10% { opacity: 1; transform: scale(1); }
35%, 100% { opacity: 0; transform: scale(.6); }
}
@keyframes ie-check-loop {
0%, 30% { opacity: 0; transform: scale(.6); }
40%, 80% { opacity: 1; transform: scale(1); }
95%, 100% { opacity: 0; transform: scale(.6); }
}
@media (prefers-reduced-motion: reduce) {
.ie-row__field, .ie-row__icon { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ie-row {
width: min(240px, 78vw);
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ie-row__label {
font-size: 11px;
color: rgba(23, 20, 26, 0.72);
}
.ie-row__field {
display: flex;
align-items: center;
gap: 8px;
border-radius: 8px;
border: 1px solid transparent;
padding: 8px 12px;
transition: border-color 220ms ease-out, background 220ms ease-out;
}
.ie-row.is-editing .ie-row__field {
border-color: #2f6df6;
background: #eef4ff;
}
.ie-row__input {
flex: 1 1 auto;
border: 0;
background: none;
font: 700 14px/1.3 sans-serif;
font-family: inherit;
color: #17141a;
padding: 0;
}
.ie-row__input:focus {
outline: none;
}
.ie-row__toggle {
position: relative;
width: 24px;
height: 24px;
flex: 0 0 auto;
display: grid;
place-items: center;
border: 0;
background: none;
color: #2f6df6;
cursor: pointer;
}
.ie-row__toggle:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 4px;
}
.ie-row__icon {
position: absolute;
width: 15px;
height: 15px;
transition: opacity 220ms ease-out, transform 220ms ease-out;
}
.ie-row__icon--check {
opacity: 0;
transform: scale(0.6);
color: #4cd4a6;
}
.ie-row.is-saved .ie-row__icon--pencil {
opacity: 0;
transform: scale(0.6);
}
.ie-row.is-saved .ie-row__icon--check {
opacity: 1;
transform: scale(1);
}
.ie-row.is-demo .ie-row__field {
animation: ie-field-loop 2s ease-out infinite;
}
.ie-row.is-demo .ie-row__icon--pencil {
animation: ie-pencil-loop 2s ease-out infinite;
}
.ie-row.is-demo .ie-row__icon--check {
animation: ie-check-loop 2s ease-out infinite;
}
@keyframes ie-field-loop {
0%, 10% {
border-color: transparent;
background: transparent;
}
30%, 80% {
border-color: #2f6df6;
background: #eef4ff;
}
95%, 100% {
border-color: transparent;
background: transparent;
}
}
@keyframes ie-pencil-loop {
0%, 10% {
opacity: 1;
transform: scale(1);
}
35%, 100% {
opacity: 0;
transform: scale(0.6);
}
}
@keyframes ie-check-loop {
0%, 30% {
opacity: 0;
transform: scale(0.6);
}
40%, 80% {
opacity: 1;
transform: scale(1);
}
95%, 100% {
opacity: 0;
transform: scale(0.6);
}
}
@media (prefers-reduced-motion: reduce) {
.ie-row__field, .ie-row__icon {
transition: none;
}
}(function () {
var row = document.querySelector('.ie-row');
var input = row.querySelector('.ie-row__input');
var toggle = row.querySelector('.ie-row__toggle');
var original = input.value;
function edit() {
row.classList.remove('is-demo');
row.classList.add('is-editing');
input.readOnly = false;
input.focus({ preventScroll: true });
input.select();
}
function save() {
original = input.value;
row.classList.remove('is-editing');
row.classList.add('is-saved');
input.readOnly = true;
setTimeout(function () { row.classList.remove('is-saved'); }, 900);
}
function cancel() {
input.value = original;
row.classList.remove('is-editing');
input.readOnly = true;
}
toggle.addEventListener('click', function () {
row.classList.contains('is-editing') ? save() : edit();
});
input.addEventListener('click', function () { if (input.readOnly) edit(); });
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); save(); }
else if (e.key === 'Escape') { cancel(); }
});
input.addEventListener('blur', function () { if (!input.readOnly) save(); });
})();import { useRef, useState, type CSSProperties } from "react";
import styles from "./InlineEditField.module.scss";
export type InlineEditFieldProps = {
label: string;
initialValue: string;
onSave?: (value: string) => void;
/** 테두리·아이콘 전환 시간 (기본 220ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 클릭하면 readOnly 를 풀고 focus, Enter·blur 로 저장, Escape 로 되돌린다 */
export default function InlineEditField({
label,
initialValue,
onSave,
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: InlineEditFieldProps) {
const [value, setValue] = useState(initialValue);
const [editing, setEditing] = useState(false);
const [saved, setSaved] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const before = useRef(initialValue);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const startEdit = () => {
before.current = value;
setEditing(true);
requestAnimationFrame(() => { inputRef.current?.focus(); inputRef.current?.select(); });
};
const save = () => {
setEditing(false);
setSaved(true);
onSave?.(value);
setTimeout(() => setSaved(false), 900);
};
const cancel = () => { setValue(before.current ?? initialValue); setEditing(false); };
return (
<div className={`${styles.row} ${saved ? styles.savedFlash : ""}`} style={vars}>
<label className={styles.label} htmlFor="ie-name-react">{label}</label>
<div className={`${styles.field} ${editing ? styles.editing : ""}`}>
<input
id="ie-name-react"
ref={inputRef}
className={styles.input}
type="text"
readOnly={!editing}
value={value}
onChange={(e: any) => setValue(e.target.value)}
onClick={() => !editing && startEdit()}
onBlur={() => editing && save()}
onKeyDown={(e: any) => {
if (e.key === "Enter") { e.preventDefault(); save(); }
else if (e.key === "Escape") cancel();
}}
/>
<button className={styles.toggle} type="button" aria-label={`${label} 편집`} onClick={() => (editing ? save() : startEdit())}>
<svg className={`${styles.icon} ${saved ? styles.hidden : ""}`} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M221.66 90.34L192 120l-56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2" /><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z" /></g></svg>
<svg className={`${styles.icon} ${styles.check} ${saved ? styles.shown : ""}`} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2" /><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32" /></g></svg>
</button>
</div>
</div>
);
}07 · List inline edit — Measured area 6.801% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
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
What happens on screen
Icon dots along a vertical line fade and rise into place one after another, from top to bottom.

<main class="stage">
<ul class="tl-list is-demo" aria-label="Activity log">
<li class="tl-item" style="--n:0">
<span class="tl-item__dot">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216"/></g></svg>
</span>
<div class="tl-item__body"><b>Bora</b> left a comment<time>2 min ago</time></div>
</li>
<li class="tl-item" style="--n:1">
<span class="tl-item__dot">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 48v160H40V48a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 144v64a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-64a8 8 0 0 1 16 0v56h160v-56a8 8 0 0 1 16 0M93.66 77.66L120 51.31V144a8 8 0 0 0 16 0V51.31l26.34 26.35a8 8 0 0 0 11.32-11.32l-40-40a8 8 0 0 0-11.32 0l-40 40a8 8 0 0 0 11.32 11.32"/></g></svg>
</span>
<div class="tl-item__body"><b>Ji-ho</b> uploaded a file<time>18 min ago</time></div>
</li>
<li class="tl-item" style="--n:2">
<span class="tl-item__dot">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>
</span>
<div class="tl-item__body">Task <b>Checkout UI review</b> done<time>1 hour ago</time></div>
</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter; // 항목이 떠오르는 시간
$easing: $ease-out;
$color: $subject-lilac; // 세로선·점 배경(파스텔)
@include stage($stage-paper);
.tl-list {
list-style: none;
margin: 0;
padding: 0;
width: min(240px, 78vw);
position: relative;
}
.tl-list::before {
content: "";
position: absolute;
left: 15px;
top: 4px;
bottom: 4px;
width: 2px;
background: #ece3ff; // $subject-lilac 를 흰 배경에 옅게 섞은 불투명 톤
}
.tl-item {
position: relative;
display: flex;
gap: $sp-3;
padding: $sp-2 0;
opacity: 0;
transform: translateY(10px);
}
.tl-item__dot {
position: relative;
z-index: 1;
width: 32px;
height: 32px;
flex: 0 0 auto;
display: grid;
place-items: center;
border-radius: $r-pill;
background: $color;
color: $stage-paper;
}
.tl-item:nth-child(2) .tl-item__dot { background: $subject-mint; }
.tl-item:nth-child(3) .tl-item__dot { background: $subject-pink; }
.tl-item__dot svg { width: 16px; height: 16px; }
.tl-item__body { font-size: 12px; color: $stage-ink; line-height: 1.5; padding-top: $sp-2; }
.tl-item__body b { font-weight: 700; }
.tl-item__body time { display: block; font-size: 11px; color: rgba(23, 20, 26, .5); }
// 데모 강제 루프 — 위에서부터 순서대로 옅게 떠오름을 stagger keyframes 로
.tl-list.is-demo .tl-item {
animation: tl-loop $dur-loop $easing infinite;
animation-delay: calc(var(--n) * 140ms);
}
@keyframes tl-loop {
0% { opacity: 0; transform: translateY(10px); }
20%, 90% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(10px); }
}
@media (prefers-reduced-motion: reduce) {
.tl-item { animation: none !important; opacity: 1; transform: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tl-list {
list-style: none;
margin: 0;
padding: 0;
width: min(240px, 78vw);
position: relative;
}
.tl-list::before {
content: "";
position: absolute;
left: 15px;
top: 4px;
bottom: 4px;
width: 2px;
background: #ece3ff;
}
.tl-item {
position: relative;
display: flex;
gap: 12px;
padding: 8px 0;
opacity: 0;
transform: translateY(10px);
}
.tl-item__dot {
position: relative;
z-index: 1;
width: 32px;
height: 32px;
flex: 0 0 auto;
display: grid;
place-items: center;
border-radius: 999px;
background: #b9a0ff;
color: #ffffff;
}
.tl-item:nth-child(2) .tl-item__dot {
background: #4cd4a6;
}
.tl-item:nth-child(3) .tl-item__dot {
background: #ff9ebb;
}
.tl-item__dot svg {
width: 16px;
height: 16px;
}
.tl-item__body {
font-size: 12px;
color: #17141a;
line-height: 1.5;
padding-top: 8px;
}
.tl-item__body b {
font-weight: 700;
}
.tl-item__body time {
display: block;
font-size: 11px;
color: rgba(23, 20, 26, 0.5);
}
.tl-list.is-demo .tl-item {
animation: tl-loop 2s ease-out infinite;
animation-delay: calc(var(--n) * 140ms);
}
@keyframes tl-loop {
0% {
opacity: 0;
transform: translateY(10px);
}
20%, 90% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(10px);
}
}
@media (prefers-reduced-motion: reduce) {
.tl-item {
animation: none !important;
opacity: 1;
transform: none;
}
}import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
import styles from "./ActivityTimelineLog.module.scss";
export type ActivityEvent = { text: ReactNode; time: string; icon: "chat" | "upload" | "check" };
export type ActivityTimelineLogProps = {
events: ActivityEvent[];
/** 항목이 떠오르는 시간 (기본 600ms) */
duration?: number;
easing?: string;
/** 세로선·점 배경(파스텔) */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const ICONS: Record<ActivityEvent["icon"], ReactNode> = {
chat: (
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2" /><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216" /></g></svg>
),
upload: (
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 48v160H40V48a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2" /><path d="M224 144v64a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-64a8 8 0 0 1 16 0v56h160v-56a8 8 0 0 1 16 0M93.66 77.66L120 51.31V144a8 8 0 0 0 16 0V51.31l26.34 26.35a8 8 0 0 0 11.32-11.32l-40-40a8 8 0 0 0-11.32 0l-40 40a8 8 0 0 0 11.32 11.32" /></g></svg>
),
check: (
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" /><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" /></g></svg>
),
};
/** 위에서부터 순서대로 옅게 떠오르는 stagger — CSS 변수 --n 을 각 li 에 주고 transition-delay 로 순서를 만든다 */
export default function ActivityTimelineLog({
events,
duration = 600,
easing = "ease-out",
color = "#b9a0ff",
}: ActivityTimelineLogProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const [shown, setShown] = useState(false);
useEffect(() => { const id = requestAnimationFrame(() => setShown(true)); return () => cancelAnimationFrame(id); }, []);
return (
<ul className={styles.list} style={vars} aria-label="활동 내역">
{events.map((ev, i) => (
<li key={i} className={`${styles.item} ${shown ? styles.shown : ""}`} data-i={i % 3} style={{ transitionDelay: `${i * 140}ms` }}>
<span className={styles.dot}>{ICONS[ev.icon]}</span>
<div className={styles.body}>{ev.text}<time>{ev.time}</time></div>
</li>
))}
</ul>
);
}08 · Activity timeline log — Measured area 6.644% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
.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
What moves
Tapping the checkbox fills it with color and pops in a check mark, while the text gets a left-to-right strikethrough and fades.

<main class="stage">
<ul class="ck-list" aria-label="Shopping list">
<li class="ck-item is-demo">
<button class="ck-item__box" type="button" role="checkbox" aria-checked="false">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
</button>
<span class="ck-item__text">Buy a pack of strawberries</span>
</li>
<li class="ck-item">
<button class="ck-item__box" type="button" role="checkbox" aria-checked="false">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
</button>
<span class="ck-item__text">Buy milk</span>
</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 체크·취소선 시간
$easing: $ease-pop;
$color: $subject-pink; // 캔디 체크 색
@include stage($stage-paper);
.ck-list {
list-style: none;
margin: 0;
padding: 0;
width: min(240px, 78vw);
display: flex;
flex-direction: column;
gap: $sp-3;
}
.ck-item {
display: flex;
align-items: center;
gap: $sp-3;
padding: $sp-3;
border-radius: $r-card;
background: #fff0f5; // $subject-pink 를 흰 배경에 옅게 섞은 불투명 톤
}
.ck-item__box {
position: relative;
width: 24px;
height: 24px;
flex: 0 0 auto;
border: 2px solid $color;
border-radius: $r-xs;
background: $stage-paper;
cursor: pointer;
display: grid;
place-items: center;
transition: background $duration $easing;
}
.ck-item__box:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.ck-item__box svg { width: 14px; height: 14px; color: $stage-paper; transform: scale(.4); opacity: 0; transition: transform $duration $easing, opacity $duration $easing; }
.ck-item.is-checked .ck-item__box { background: $color; }
.ck-item.is-checked .ck-item__box svg { transform: scale(1); opacity: 1; }
.ck-item__text {
position: relative;
font-size: 13px;
color: $stage-ink;
transition: opacity $duration $easing;
}
.ck-item__text::after {
content: "";
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
background: $color;
transform: scaleX(0);
transform-origin: left; // 규칙8: 취소선 왼쪽 그리기
transition: transform $duration $easing;
}
.ck-item.is-checked .ck-item__text { opacity: .5; }
.ck-item.is-checked .ck-item__text::after { transform: scaleX(1); }
// 데모 강제 루프 — 체크 → 취소선 → 원복 을 keyframes 로
.ck-item.is-demo .ck-item__box { animation: ck-box-loop $dur-loop $easing infinite; }
.ck-item.is-demo .ck-item__box svg { animation: ck-check-loop $dur-loop $easing infinite; }
.ck-item.is-demo .ck-item__text { animation: ck-text-loop $dur-loop $easing infinite; }
.ck-item.is-demo .ck-item__text::after { animation: ck-line-loop $dur-loop $easing infinite; }
@keyframes ck-box-loop {
0%, 10% { background: $stage-paper; }
35%, 80% { background: $color; }
95%, 100% { background: $stage-paper; }
}
@keyframes ck-check-loop {
0%, 10% { transform: scale(.4); opacity: 0; }
40% { transform: scale(1.15); opacity: 1; }
50%, 80% { transform: scale(1); opacity: 1; }
95%, 100% { transform: scale(.4); opacity: 0; }
}
@keyframes ck-text-loop {
0%, 10% { opacity: 1; }
35%, 80% { opacity: .5; }
95%, 100% { opacity: 1; }
}
@keyframes ck-line-loop {
0%, 10% { transform: scaleX(0); }
40%, 80% { transform: scaleX(1); }
95%, 100% { transform: scaleX(0); }
}
@media (prefers-reduced-motion: reduce) {
.ck-item__box, .ck-item__box svg, .ck-item__text, .ck-item__text::after { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ck-list {
list-style: none;
margin: 0;
padding: 0;
width: min(240px, 78vw);
display: flex;
flex-direction: column;
gap: 12px;
}
.ck-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 16px;
background: #fff0f5;
}
.ck-item__box {
position: relative;
width: 24px;
height: 24px;
flex: 0 0 auto;
border: 2px solid #ff9ebb;
border-radius: 4px;
background: #ffffff;
cursor: pointer;
display: grid;
place-items: center;
transition: background 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ck-item__box:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ck-item__box svg {
width: 14px;
height: 14px;
color: #ffffff;
transform: scale(0.4);
opacity: 0;
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ck-item.is-checked .ck-item__box {
background: #ff9ebb;
}
.ck-item.is-checked .ck-item__box svg {
transform: scale(1);
opacity: 1;
}
.ck-item__text {
position: relative;
font-size: 13px;
color: #17141a;
transition: opacity 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ck-item__text::after {
content: "";
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
background: #ff9ebb;
transform: scaleX(0);
transform-origin: left;
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ck-item.is-checked .ck-item__text {
opacity: 0.5;
}
.ck-item.is-checked .ck-item__text::after {
transform: scaleX(1);
}
.ck-item.is-demo .ck-item__box {
animation: ck-box-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.ck-item.is-demo .ck-item__box svg {
animation: ck-check-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.ck-item.is-demo .ck-item__text {
animation: ck-text-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.ck-item.is-demo .ck-item__text::after {
animation: ck-line-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes ck-box-loop {
0%, 10% {
background: #ffffff;
}
35%, 80% {
background: #ff9ebb;
}
95%, 100% {
background: #ffffff;
}
}
@keyframes ck-check-loop {
0%, 10% {
transform: scale(0.4);
opacity: 0;
}
40% {
transform: scale(1.15);
opacity: 1;
}
50%, 80% {
transform: scale(1);
opacity: 1;
}
95%, 100% {
transform: scale(0.4);
opacity: 0;
}
}
@keyframes ck-text-loop {
0%, 10% {
opacity: 1;
}
35%, 80% {
opacity: 0.5;
}
95%, 100% {
opacity: 1;
}
}
@keyframes ck-line-loop {
0%, 10% {
transform: scaleX(0);
}
40%, 80% {
transform: scaleX(1);
}
95%, 100% {
transform: scaleX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.ck-item__box, .ck-item__box svg, .ck-item__text, .ck-item__text::after {
transition: none;
}
}(function () {
document.querySelectorAll('.ck-item__box').forEach(function (box) {
box.addEventListener('click', function () {
var item = box.closest('.ck-item');
item.classList.remove('is-demo');
var checked = box.getAttribute('aria-checked') === 'true';
box.setAttribute('aria-checked', checked ? 'false' : 'true');
item.classList.toggle('is-checked', !checked);
});
});
})();import { useState, type CSSProperties } from "react";
import styles from "./ChecklistStrikethroughComplete.module.scss";
export type ChecklistStrikethroughCompleteProps = {
items: string[];
/** 체크·취소선 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 캔디 체크 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 체크박스를 누르면 캔디 색으로 차오르고, 글자엔 왼쪽에서 오른쪽으로 취소선이 그어진다 */
export default function ChecklistStrikethroughComplete({
items,
duration = 300,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#ff9ebb",
}: ChecklistStrikethroughCompleteProps) {
const [checked, setChecked] = useState<Record<number, boolean>>({});
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<ul className={styles.list} style={vars} aria-label="장보기 목록">
{items.map((text, i) => {
const done = !!checked[i];
return (
<li key={text} className={`${styles.item} ${done ? styles.checked : ""}`}>
<button
className={styles.box}
type="button"
role="checkbox"
aria-checked={done}
onClick={() => setChecked((c) => ({ ...c, [i]: !c[i] }))}
>
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2" /><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32" /></g></svg>
</button>
<span className={styles.text}>{text}</span>
</li>
);
})}
</ul>
);
}09 · Checklist strikethrough complete — Measured area 0.621% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
.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 auto — auto 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 same height-animation trap resurfaces on exactly the same principle in Portfolio Gallery HTML CSS, where cards swap places inside a grid. 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 for this accordion list design instead of building it 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. Showing where the reader stands once a row opens into detail is what Breadcrumb UI Patterns answers.
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.









