아코디언 리스트 UI 9가지 — 체크리스트까지
아코디언 리스트는 목록 행을 클릭했을 때 그 자리에서 높이가 부드럽게 늘어나며 상세 내용이 펼쳐지는 UI로, 별도 페이지 이동 없이 리스트 화면 안에서 정보를 더 보여주고 싶을 때 씁니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 행 인라인 펼치기
- 02 그룹 리스트 고정 섹션 헤더
- 03 리스트 → 상세 슬라이드 전환
- 04 더보기 버튼 리스트
- 05 롱프레스 액션 메뉴
- 06 드롭 위치 표시 리스트 재정렬
- 07 리스트 인라인 편집
- 08 활동 내역 타임라인
- 09 체크리스트 취소선 완료
아홉 개는 인기순이 아니라 리스트 한 줄이 실제로 겪는 순서로 골랐습니다. 먼저 행 하나를 펼쳐 상세를 보는 가장 기본적인 동작(01)에서 시작해, 리스트를 그룹으로 묶어 스크롤을 돕는 동작(02), 아예 다른 화면처럼 전환되는 동작(03)으로 넘어갑니다. 그다음은 데이터가 더 필요할 때(04), 자주 쓰지 않는 부가 작업을 숨겨둘 때(05), 리스트 드래그 앤 드롭으로 순서 자체를 바꿀 때(06), 값을 그 자리에서 고칠 때(07), 지나간 일을 기록할 때(08), 마지막으로 할 일을 끝냈다고 표시할 때(09) 순서로 이어집니다. 06번 드래그 재정렬처럼 행을 손가락으로 다루는 쪽은 스와이프 삭제 리스트 UI에 따로 모았고, 펼칠 내용 자체가 없을 때 보여 줄 화면은 빈 화면 디자인에서 다룹니다.
01행 인라인 펼치기
리스트 ui 디자인에서 무엇이 보이나
주문 내역처럼 한 줄만 보이던 행을 클릭하면 화살표 아이콘이 90도로 돌면서 그 아래로 상세 정보가 나타납니다.

<main class="stage">
<ul class="rowx-list" aria-label="주문 내역">
<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">주문 #10493</span>
<span class="rowx-item__meta">32,000원</span>
</button>
<div class="rowx-item__body">
<div class="rowx-item__body-inner">
<p>배송지 서울 강남구 · 결제 카카오페이</p>
<p>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">주문 #10488</span>
<span class="rowx-item__meta">18,500원</span>
</button>
<div class="rowx-item__body">
<div class="rowx-item__body-inner">
<p>배송지 경기 성남시 · 결제 카드</p>
<p>배송 완료 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 · 행 인라인 펼치기 — 측정 누적면적 19.034% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
.rowx-item__body {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows .3s cubic-bezier(.2,.8,.2,1);
}
.rowx-item.is-open .rowx-item__body { grid-template-rows: 1fr; }
.rowx-item__body-inner { overflow: hidden; }
height: auto는 애니메이션이 안 되지만 grid-template-rows는 0fr에서 1fr로 보간되기 때문에, 내용 길이를 몰라도 부드럽게 펼쳐집니다.
02그룹 리스트 고정 섹션 헤더
화면에서 일어나는 일
연락처 앱처럼 첫 글자로 묶인 리스트에서, 스크롤하는 동안 지금 보고 있는 그룹의 헤더 글자가 화면 위에 붙어 있다가 다음 그룹이 오면 자연스럽게 밀려 올라갑니다.

<main class="stage">
<div class="grp-wrap is-demo" tabindex="0" aria-label="연락처 목록. 화살표 키나 드래그로 스크롤">
<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 · 그룹 리스트 고정 섹션 헤더 — 측정 누적면적 25.533% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
.grp-group__header {
position: sticky;
top: 0;
z-index: 1;
background: #ff9ebb;
}
position: sticky는 부모 그룹 <li>의 높이 안에서만 붙어 있으므로, 그룹마다 감싸는 요소가 따로 있어야 다음 그룹이 헤더를 밀어낼 수 있습니다.
03리스트 → 상세 슬라이드 전환
어떤 움직임인가
목록 항목을 누르면 목록 패널이 왼쪽으로 밀려 나가고 상세 패널이 오른쪽에서 밀려 들어오는, 설정 화면에서 흔히 보는 전환입니다.

<main class="stage">
<div class="ltd-viewport is-demo">
<div class="ltd-track">
<ul class="ltd-list" aria-label="설정">
<li>
<button class="ltd-list__row" type="button">
<span>알림 설정</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>개인정보 보호</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>
목록
</button>
<h2>알림 설정</h2>
<p>푸시·이메일·문자 알림을 항목별로 켜고 끌 수 있습니다.</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 · 리스트 → 상세 슬라이드 전환 — 측정 누적면적 6.846% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
.ltd-track {
display: flex;
width: 200%;
transition: transform .3s cubic-bezier(.2,.8,.2,1);
}
.ltd-viewport.is-open .ltd-track { transform: translateX(-50%); }
목록과 상세를 나란히 둔 트랙 하나를 통째로 밀기 때문에, 두 패널을 각각 애니메이션하지 않아도 하나의 transform으로 끝납니다.
04더보기 버튼 리스트
무엇이 보이나
더보기 버튼을 누르면 잠깐 스피너가 돌고, 새 항목 세 개가 아래로 하나씩 순서를 두고 밀려 올라오며 나타납니다.

<main class="stage">
<div class="lm-list is-demo">
<ul class="lm-rows">
<li class="lm-row">공지사항 업데이트 안내</li>
<li class="lm-row">9월 정기 점검 예정</li>
<li class="lm-row lm-row--pending" data-n="1">신규 기능 릴리즈 노트</li>
<li class="lm-row lm-row--pending" data-n="2">이벤트 당첨자 발표</li>
<li class="lm-row lm-row--pending" data-n="3">서비스 이용약관 개정</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">더보기</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 = '더 이상 없음';
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 · 더보기 버튼 리스트 — 측정 누적면적 43.192% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
btn.addEventListener('click', () => {
list.classList.add('is-loading');
setTimeout(() => {
list.classList.remove('is-loading');
list.classList.add('has-loaded');
}, 500);
});
세 행은 처음부터 max-height: 0으로 접혀 있다가 .has-loaded가 붙으면 펼쳐지는데, 행마다 transition-delay를 조금씩 다르게 줘서 한 번에 뿅 나타나지 않고 순서대로 이어집니다.
05롱프레스 액션 메뉴
화면에서 일어나는 일
리스트 행을 0.5초 이상 누르고 있으면 즐겨찾기·보관·삭제 아이콘 메뉴가 튀어 오르듯 나타납니다.

<main class="stage">
<div class="lp-row is-demo">
<span class="lp-row__title">디자인 회의 메모</span>
<button class="lp-row__more" type="button" aria-haspopup="true" aria-expanded="false" aria-label="작업 메뉴 열기">
<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="즐겨찾기">
<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="보관">
<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="삭제">
<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 · 롱프레스 액션 메뉴 — 측정 누적면적 2.742% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
row.addEventListener('pointerdown', (e) => {
if (e.target.closest('button')) return;
timer = setTimeout(openMenu, 500);
});
['pointerup', 'pointerleave'].forEach((ev) =>
row.addEventListener(ev, () => clearTimeout(timer))
);
손가락(마우스)을 떼면 타이머가 즉시 취소되므로, 스크롤하려고 살짝 누른 정도로는 메뉴가 열리지 않습니다. 마우스로 길게 누르기 힘든 사람을 위해 항상 보이는 "···" 버튼도 같은 메뉴를 엽니다.
06드롭 위치 표시 리스트 재정렬
어떤 움직임인가
여섯 점 손잡이를 잡고 끌면, 항목이 통째로 밀리는 대신 놓일 자리에 파란 빈 줄이 먼저 나타나 위치를 보여줍니다.

<main class="stage">
<ul class="rd-list is-demo" aria-label="우선순위 목록. 손잡이에서 화살표 키(Alt+위/아래)로 순서 변경">
<li class="rd-item" data-i="0">
<button class="rd-item__handle" type="button" aria-label="순서 변경, 위/아래 화살표">
<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>결제 오류 수정</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="순서 변경, 위/아래 화살표">
<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>로그인 페이지 QA</span>
</li>
<li class="rd-item" data-i="2">
<button class="rd-item__handle" type="button" aria-label="순서 변경, 위/아래 화살표">
<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>배포 문서 정리</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 · 드롭 위치 표시 리스트 재정렬 — 측정 누적면적 19.851% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
handle.addEventListener('keydown', (e) => {
if (!e.altKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return;
e.preventDefault();
const targetIdx = order().indexOf(item) + (e.key === 'ArrowUp' ? -1 : 1);
list.insertBefore(item, order()[targetIdx]);
});
마우스 드래그가 어려운 사람도 손잡이에 포커스를 두고 Alt+화살표로 같은 결과를 만들 수 있어야 리스트 재정렬이 접근성을 갖춘 컴포넌트가 됩니다.
07리스트 인라인 편집
무엇이 보이나
이름 칸을 클릭하면 그 자리에서 바로 입력창으로 바뀌고, Enter를 누르거나 밖을 클릭하면 저장되며 연필 아이콘이 체크로 바뀝니다.

<main class="stage">
<div class="ie-row is-demo">
<label class="ie-row__label" for="ie-name">이름</label>
<div class="ie-row__field">
<input id="ie-name" class="ie-row__input" type="text" value="김도윤" readonly>
<button class="ie-row__toggle" type="button" aria-label="이름 편집">
<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 · 리스트 인라인 편집 — 측정 누적면적 6.801% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
input.addEventListener('click', () => { if (input.readOnly) startEdit(); });
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') save();
else if (e.key === 'Escape') cancel();
});
input.addEventListener('blur', () => { if (!input.readOnly) save(); });
contenteditable 대신 평범한 <input readonly>를 쓰면 값 검증이나 Escape 취소 같은 처리를 input의 표준 동작 그대로 가져갈 수 있어 코드가 짧아집니다.
08활동 내역 타임라인
화면에서 일어나는 일
세로선을 따라 아이콘 점이 찍힌 활동 기록이 위에서부터 하나씩 순서를 두고 옅게 떠오르며 나타납니다.

<main class="stage">
<ul class="tl-list is-demo" aria-label="활동 내역">
<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>보라</b>님이 댓글을 남겼습니다<time>2분 전</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>지호</b>님이 파일을 업로드했습니다<time>18분 전</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">작업 <b>결제 UI 검수</b> 완료<time>1시간 전</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 · 활동 내역 타임라인 — 측정 누적면적 6.644% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
.tl-item {
opacity: 0;
transform: translateY(10px);
animation: tl-in .6s ease-out forwards;
animation-delay: calc(var(--n) * 140ms);
}
@keyframes tl-in { to { opacity: 1; transform: translateY(0); } }
각 항목에 --n 커스텀 속성으로 순번을 넘기고 calc()로 지연 시간을 계산하면, 항목이 몇 개든 반복문 없이 순서대로 떠오르는 효과를 만들 수 있습니다.
09체크리스트 취소선 완료
어떤 움직임인가
체크박스를 누르면 색이 차오르며 체크 표시가 튀어나오고, 글자엔 취소선이 왼쪽에서 오른쪽으로 그어지며 흐려집니다.

<main class="stage">
<ul class="ck-list" aria-label="장보기 목록">
<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">딸기 한 팩 사기</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">우유 사기</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 · 체크리스트 취소선 완료 — 측정 누적면적 0.621% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
.ck-item__text::after {
content: "";
position: absolute; inset: 50% 0 auto 0; height: 2px;
background: #ff9ebb;
transform: scaleX(0);
transform-origin: left;
transition: transform .3s;
}
.ck-item.is-checked .ck-item__text::after { transform: scaleX(1); }
text-decoration-line은 브라우저마다 애니메이션이 매끄럽지 않아서, 대신 가짜 밑줄 하나를 scaleX(0)에서 scaleX(1)로 늘리는 방식을 씁니다. 왼쪽을 원점(transform-origin: left)으로 고정해야 줄이 왼쪽부터 그어집니다.
아코디언 리스트, 어디서 깨지는가
01번 행 펼치기를 만들면서 실제로 겪은 함정은 grid-template-rows를 auto로 바로 애니메이션하려다 실패한 경우입니다 — auto는 시작값도 끝값도 아니라서 브라우저가 보간할 두 숫자가 없고, 그냥 순간적으로 튀어 버립니다. 반드시 0fr과 1fr처럼 그리드 비율값끼리 애니메이션해야 부드럽게 펼쳐집니다. 두 번째로 04번 더보기 버튼에서는 새 행을 처음부터 display: none으로 숨겼다가 보이는 순간 클래스를 바꾸면, 브라우저가 레이아웃을 계산하기도 전에 전환이 끝나 버려 애니메이션 없이 뿅 나타났습니다 — max-height: 0처럼 실제로 렌더링은 되지만 크기만 0인 상태로 시작해야 전환이 눈에 보입니다. 같은 높이 애니메이션 함정은 카드가 격자 안에서 자리를 바꾸는 포트폴리오 갤러리 UI에서도 똑같은 원리로 튀어나옵니다. 이 아홉 개 파일의 압축 비밀번호는 pw5ht9pc이고, 압축을 풀면 vanilla·react 폴더에서 같은 아홉 가지 동작을 나란히 비교해 볼 수 있습니다.
다른 리스트·표 주제는 실전 UI 카테고리에, 스와이프·정렬·페이지네이션처럼 리스트가 다루는 다른 아홉 가지는 스와이프 삭제 리스트 UI 9가지에 정리돼 있고, 이 사이트가 무엇을 다루는지는 소개에 있습니다. grid-template-rows가 fr 단위를 어떻게 보간하는지는 MDN CSS grid-template-rows 문서에, position: sticky의 동작 범위는 MDN position 문서에 정리돼 있습니다.
접근성 — 아홉 개가 reduced-motion에서 하는 일
시스템에서 동작 줄이기를 켜면 아홉 개 데모 모두 transition과 animation을 끄고 최종 상태만 바로 보여줍니다. 01·03번처럼 높이나 위치가 바뀌는 항목은 펼침·전환 상태로 곧장 이동하고, 05·09번처럼 튀어 오르는 느낌이 핵심인 항목은 그 느낌만 빠지고 결과(메뉴 열림, 체크 완료)는 그대로 남습니다. 06번 재정렬과 07번 인라인 편집은 원래도 키보드만으로 전부 조작되므로 동작 자체는 줄어들지 않습니다.
FAQ
아코디언 리스트를 만들 때 아코디언 요소를 써도 되나요?
<details>·<summary>는 열고 닫기 자체는 기본 제공하지만 높이 애니메이션은 직접 넣어야 하고 브라우저마다 기본 스타일이 달라, 아홉 개 데모에서는 버튼과 grid-template-rows로 더 세밀하게 제어했습니다. 접근성이 중요하면 <details> 위에 커스텀 스타일을 얹는 방법도 있습니다. 행을 열어 상세로 들어갔을 때 지금 위치를 표시하는 문제는 브레드크럼 UI가 답을 들고 있습니다.
리스트 항목이 몇 백 개로 많아지면 체크 리스트 ui 코드도 그대로 써도 되나요?
01·02·08번처럼 화면에 보이는 항목마다 애니메이션이 걸리는 경우 항목이 아주 많아지면 가상 스크롤(화면에 보이는 만큼만 렌더링) 같은 별도 최적화가 필요합니다. zip의 코드는 한 페이지에 보이는 수십 개 규모를 기준으로 만들었습니다.
롱프레스 메뉴는 모바일에서도 그대로 동작하나요?
pointerdown·pointerup 이벤트는 마우스·터치·펜을 모두 같은 코드로 처리하므로 그대로 동작합니다. 다만 모바일 브라우저는 길게 누르면 자체 컨텍스트 메뉴나 텍스트 선택이 뜰 수 있어, 데모 코드는 touch-action: manipulation으로 그 충돌을 막습니다.









