アコーディオンリスト 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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 行のインライン展開
- 02 グループ固定セクションヘッダー
- 03 リスト→詳細スライド遷移
- 04 もっと見るボタンリスト
- 05 ロングプレスアクションメニュー
- 06 ドロップ位置表示の並び替え
- 07 リストのインライン編集
- 08 アクティビティタイムライン
- 09 チェックリスト取り消し線
九つは人気順ではなく、リストの一行が実際にたどる順番で並べています。まず行を一つ開いて詳細を見る一番基本の動き(01)から始め、リストをグループにまとめてスクロールを助ける動き(02)、画面ごと切り替わる動き(03)へ進みます。続いてもっと見るボタンでデータをさらに読み込むとき(04)、あまり使わない操作を隠しておくとき(05)、順番そのものを変えるとき(06)、値をその場で直すとき(07)、過去の出来事を記録するとき(08)、最後にやることを完了にするとき(09)の順です。06番のドラッグ並べ替えと同じく指で行を扱う側はスワイプ削除リストUIにまとめてあり、開く中身そのものが無いときの画面はエンプティステート デザインで扱っています。
01行のインライン展開
html アコーディオンリストで何が見えるか
注文履歴のような行をクリックすると矢印アイコンが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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
.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>アリ</li><li>エイデン</li><li>アヴァ</li>
</ul>
</li>
<li class="grp-group">
<div class="grp-group__header">B</div>
<ul class="grp-group__rows">
<li>ベラ</li><li>ベン</li><li>ボラ</li>
</ul>
</li>
<li class="grp-group">
<div class="grp-group__header">C</div>
<ul class="grp-group__rows">
<li>クロエ</li><li>チャーリー</li><li>サイアン</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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
.grp-group__header {
position: sticky;
top: 0;
z-index: 1;
background: #ff9ebb;
}
position: stickyは親要素の高さの中でしか固定されないため、グループごとに囲む要素を分けて初めて次のグループが前のヘッダーを押し出せます。
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>プッシュ・メール・SMS通知を項目ごとにオン・オフできます。</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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
.ltd-track {
display: flex;
width: 200%;
transition: transform .3s cubic-bezier(.2,.8,.2,1);
}
.ltd-viewport.is-open .ltd-track { transform: translateX(-50%); }
リストと詳細を並べた一本のtrackをまとめて動かすので、パネルごとに別々にアニメーションせず一つのtransformだけで済みます。
04もっと見るボタンリスト
何が見えるか
もっと見るを押すと一瞬スピナーが回り、新しい3行が一度にではなく順番に少しずつ下からせり上がって現れます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
btn.addEventListener('click', () => {
list.classList.add('is-loading');
setTimeout(() => {
list.classList.remove('is-loading');
list.classList.add('has-loaded');
}, 500);
});
3行は最初max-height: 0で畳まれていて、行ごとに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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
ハンドルにフォーカスしてAlt+矢印キーでも同じ並び替えができます。
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
.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">いちご1パック買う</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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
.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カテゴリに、スワイプ・並び替え・ページネーションを扱うもう一つの九選は9 Swipe to Delete List UI Patternsに、このサイトが何を扱っているかはサイト紹介にまとまっています。grid-template-rowsがfr単位をどう補間するかはMDNのgrid-template-rowsページに、position: stickyの効く範囲はMDNのpositionページにまとまっています。
アクセシビリティ — 九つがreduced-motionで何をするか
システムで動きを減らす設定をオンにすると、九つのデモはすべてtransitionとanimationを止め、最終状態だけをすぐに表示します。01・03番のように高さや位置が変わる項目は展開・遷移後の状態へ直接移り、05・09番のように弾む感じ自体が要点の項目はその感じだけが消えて結果(メニューが開く、チェックが付く)は残ります。06番の並び替えと07番のインライン編集はもともとキーボードだけで操作できるため、動作そのものは変わりません。
FAQ
インライン編集 ui まで自作せずHTMLの<details>要素で済ませてもいいですか?
<details>・<summary>は開閉そのものは標準で提供されますが、高さのアニメーションは自分で追加する必要があり、既定の見た目もブラウザによって違います。そのため九つのデモではボタンとgrid-template-rowsでより細かく制御しました。アクセシビリティを優先するなら<details>の上にスタイルを重ねる方法も選択肢です。行を開いて詳細に入った後で現在地を示す話はパンくずリストが引き受けています。
リストの項目が数百個に増えても、このコードはそのまま使えますか?
01・02・08番のように表示中の行すべてにアニメーションが付く場合、項目が数百個規模になると仮想スクロール(画面に見える分だけ描画する手法)のような追加の最適化が必要です。zipのコードは1画面に収まる数十個規模を前提にしています。
ロングプレスメニューはモバイルでも同じように動きますか?
pointerdown・pointerupイベントはマウス・タッチ・ペンを同じコードで扱うため、そのまま動作します。ただしモバイルブラウザは長押しで独自のコンテキストメニューやテキスト選択が出ることがあるため、デモのコードはtouch-action: manipulationでその競合を防いでいます。









