デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
ゴミ箱は、ふた(横線と取っ手)・缶・中の 2 本線、それに上から落とす紙の 4 つの部品で描きます。押すと、ふたが左端を支点に開き、紙が口の上に現れて落ち、缶の中へ消えます。続いてふたが弾んで閉じ、缶が少しつぶれて戻ります。そのあと行が右へ抜けて詰まり、下に「元に戻す」の知らせが出ます。
ふたは 0.24 秒の back.out(2) で −70° まで開きます。大きく開くので、口の右寄りに落とす紙と、ふたの線が重なりません。紙は 0.36 秒の power2.in で、少し回しながら落とします。ふたは back.out(3) で弾ませて閉じ、缶は底の中央を起点に 0.9 倍までつぶして、elastic で戻します。行は押してから 0.8 秒後に右へ 90px 抜き、高さを 0 まで縮めます。
紙は、缶の口より上だけを見せる clipPath の中に置きます。こうすると、口を越えたところから缶の中へ隠れて見えます。clipPath は id で指すので、同じページにアイコンを並べると id が重なります。部品ごとに id を付け直します。知らせは、行が詰まりきってから出します。重ねると、行と知らせが同時に動いて落ち着きません。
ゴミ箱はアイコンだけのボタンなので、aria-label に何を消すかまで書きます。知らせの文は role="status" で読み上げに届け、キーボードで消したときは、焦点を「元に戻す」へ移します。知らせは 6 秒で閉じ(ポインタや焦点が乗っているあいだは待ちます)、そのとき初めて行を取り除きます。次の行を消したときも、前の行はもう戻せません。動きを減らす設定では、ほぼ一瞬で切り替えます。
ふたを開く角度、紙の落ちる時間、缶のつぶれ方、知らせの弾みで印象が変わります。一覧の削除ボタン、ファイルやメールの削除、カートから商品を外す操作に向いています。
コード
<section class="drafts" aria-labelledby="drafts-title">
<div class="drafts__head">
<!-- 最後の行を消したとき、焦点をここへ戻す -->
<h2 class="drafts__title" id="drafts-title" tabindex="-1">下書き</h2>
<span class="drafts__count">3</span>
</div>
<ul class="drafts__list">
<li class="draft">
<div class="draft__inner">
<span class="draft__tag" style="--tag: #4a5b66" aria-hidden="true"></span>
<div class="draft__text">
<p class="draft__title">週末の買い物リスト</p>
<p class="draft__body">桃、天然酵母のパン、玄関に飾る花…</p>
</div>
<!-- アイコンだけのボタンなので、何を消すかまで aria-label に書く -->
<button class="trash" type="button" aria-label="「週末の買い物リスト」を削除">
<svg class="trash__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<!-- 缶の口(y 7.5)より上だけを見せる。紙は口を越えたところから缶の中へ隠れる -->
<clipPath id="trash-mouth"><rect x="-6" y="-24" width="36" height="31.5" /></clipPath>
<g class="trash__can">
<g clip-path="url(#trash-mouth)"><path class="trash__paper" d="M11 -6h3.8l2.2 2.2V2H11zM14.8 -6v2.2H17" /></g>
<path class="trash__lid" d="M3.5 7h17M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7" />
<path d="M5.5 7l.9 12.2A2 2 0 0 0 8.4 21h7.2a2 2 0 0 0 2-1.8L18.5 7M10 11v6M14 11v6" />
</g>
</svg>
</button>
</div>
</li>
<li class="draft">
<div class="draft__inner">
<span class="draft__tag" style="--tag: #b27771" aria-hidden="true"></span>
<div class="draft__text">
<p class="draft__title">提案資料のメモ</p>
<p class="draft__body">製品の話より先に、困りごとから話しはじめる…</p>
</div>
<button class="trash" type="button" aria-label="「提案資料のメモ」を削除">
<svg class="trash__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<clipPath id="trash-mouth"><rect x="-6" y="-24" width="36" height="31.5" /></clipPath>
<g class="trash__can">
<g clip-path="url(#trash-mouth)"><path class="trash__paper" d="M11 -6h3.8l2.2 2.2V2H11zM14.8 -6v2.2H17" /></g>
<path class="trash__lid" d="M3.5 7h17M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7" />
<path d="M5.5 7l.9 12.2A2 2 0 0 0 8.4 21h7.2a2 2 0 0 0 2-1.8L18.5 7M10 11v6M14 11v6" />
</g>
</svg>
</button>
</div>
</li>
<li class="draft">
<div class="draft__inner">
<span class="draft__tag" style="--tag: #a5c0a3" aria-hidden="true"></span>
<div class="draft__text">
<p class="draft__title">読書会で読みたい本</p>
<p class="draft__body">春に読みたい、短めのSF小説を3冊…</p>
</div>
<button class="trash" type="button" aria-label="「読書会で読みたい本」を削除">
<svg class="trash__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<clipPath id="trash-mouth"><rect x="-6" y="-24" width="36" height="31.5" /></clipPath>
<g class="trash__can">
<g clip-path="url(#trash-mouth)"><path class="trash__paper" d="M11 -6h3.8l2.2 2.2V2H11zM14.8 -6v2.2H17" /></g>
<path class="trash__lid" d="M3.5 7h17M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7" />
<path d="M5.5 7l.9 12.2A2 2 0 0 0 8.4 21h7.2a2 2 0 0 0 2-1.8L18.5 7M10 11v6M14 11v6" />
</g>
</svg>
</button>
</div>
</li>
</ul>
<!-- 元に戻す知らせ(一覧に 1 つ)。文は script.js が、出したときに入れ直して読み上げに届ける -->
<div class="undo-toast">
<p class="undo-toast__text" role="status">下書きを削除しました</p>
<button class="undo-toast__button" type="button">元に戻す</button>
</div>
</section>
.drafts {
--ink: #262626; /* 文字・線・知らせの地 */
--mute: #6b6b6b; /* 補足の文字 */
--line: rgba(38, 38, 38, 0.12); /* 行の区切り線 */
--surface: #ffffff; /* 一覧とボタンの地 */
--error: #c4553d; /* 削除の赤(ホバー中のゴミ箱の地) */
max-width: 800px;
border-radius: 4px;
background-color: var(--surface);
color: var(--ink);
}
.drafts__head { display: flex; align-items: center; gap: 16px; padding: 32px 40px; }
.drafts__title { margin: 0; font-size: 24px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.drafts__title:focus { outline: none; }
/* 件数のバッジ(墨色の地に白い数字) */
.drafts__count {
display: grid; place-items: center;
min-width: 24px; height: 24px; box-sizing: border-box; padding: 0 8px; border-radius: 999px;
background-color: var(--ink); color: #ffffff;
font-size: 13px; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums;
}
.drafts__list { margin: 0; padding: 0; list-style: none; }
/* 行は外側(li)の高さを縮め、中身(__inner)を右へ抜く */
.draft { overflow: hidden; }
.draft__inner { display: flex; align-items: center; gap: 24px; padding: 24px 40px; border-top: 1px solid var(--line); }
.draft__tag { flex: none; width: 48px; height: 48px; border-radius: 4px; background-color: var(--tag); }
.draft__text { flex: 1; min-width: 0; }
.draft__title { margin: 0; font-size: 20px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; overflow-wrap: anywhere; word-break: auto-phrase; }
.draft__body { margin: 4px 0 0; overflow: hidden; color: var(--mute); font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; white-space: nowrap; text-overflow: ellipsis; }
/* アイコンだけのボタンは線の丸。重ねると削除の赤で塗り、アイコンを白くする */
.trash {
display: grid; place-items: center; flex: none;
width: 48px; height: 48px; padding: 0; box-sizing: border-box;
border: 1px solid var(--ink); border-radius: 50%;
background-color: var(--surface); color: var(--ink);
cursor: pointer;
transition: background-color 0.15s linear, border-color 0.15s linear, color 0.15s linear;
}
.trash:hover,
.trash:focus-visible { border-color: var(--error); background-color: var(--error); color: #ffffff; }
.trash:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
/* 24 グリッドで線幅 2(24px で描けば 2px)。線端と角は丸 */
.trash__icon { width: 24px; height: 24px; overflow: visible; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
/* 落とす紙。中を赤い地と同じ色で塗り、線だけの紙に見せる。押すまで隠しておく */
.trash__paper { fill: var(--error); opacity: 0; visibility: hidden; }
/* 元に戻す知らせ。画面の下の中央に固定し、明暗を反転する。押すまで隠しておく */
.undo-toast {
position: fixed; inset: auto 0 48px; z-index: 10;
display: flex; align-items: center; gap: 24px;
width: fit-content; max-width: calc(100% - 32px); box-sizing: border-box; margin: 0 auto; padding: 16px 24px;
border-radius: 4px; background-color: var(--ink); color: #ffffff;
font-size: 15px; line-height: 1.5; letter-spacing: 0.04em;
opacity: 0; visibility: hidden;
}
.undo-toast__text { margin: 0; }
.undo-toast__button {
flex: none; padding: 0; border: 0; background: none; color: inherit; cursor: pointer;
font: inherit; font-weight: 600; letter-spacing: inherit;
text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 0.3em;
}
.undo-toast__button:focus-visible { outline: 2px solid #ffffff; outline-offset: 4px; }
@media (max-width: 600px) {
.drafts__head, .draft__inner { gap: 16px; padding-inline: 16px; }
.undo-toast { bottom: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.trash { transition: none; }
}
import { gsap } from "gsap";
const HOLD = 6; // 元に戻せる秒数(知らせを出しておく時間)
const SHIFT = 90; // 行が右へ抜ける量(px)
const REOPEN = 0.2; // 「元に戻す」から行が開き直すまで(先に知らせを消しきる)
const speed = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で
document.querySelectorAll(".drafts").forEach((drafts, n) => {
const [count, toast] = [".drafts__count", ".undo-toast"].map((s) => drafts.querySelector(s));
const [status, undoButton] = [".undo-toast__text", ".undo-toast__button"].map((s) => toast.querySelector(s));
const message = status.textContent; // 知らせの文は HTML から。出すたびに入れ直して、読み上げに届ける
status.textContent = "";
let pending = null; // 元に戻せる行(最後に消した行)
const timer = gsap.delayedCall(HOLD, commit).pause(); // 知らせを閉じる時計。触れている間は止める
const recount = () => (count.textContent = drafts.querySelectorAll(".draft:not([hidden]):not([data-removing])").length);
const showToast = (focus) => {
let first = true;
status.textContent = "";
gsap.fromTo(toast, { y: 90, autoAlpha: 0 }, {
y: 0, autoAlpha: 1, duration: 0.45, delay: 0.05, ease: "back.out(1.6)", overwrite: true,
// 見えてから文を入れる(隠したまま入れると、読み上げに届かない。最初の描画はまだ隠れている)
onUpdate: () => {
if (!first || toast.style.visibility === "hidden") return;
first = false;
status.textContent = message;
if (focus) undoButton.focus(); // 消えた行から焦点を移し、すぐ戻せるようにする
},
}).timeScale(speed);
timer.restart(true);
};
const hideToast = () => {
timer.pause();
gsap.to(toast, { y: 40, autoAlpha: 0, duration: 0.16, ease: "power1.in", overwrite: true, onComplete: () => (status.textContent = "") }).timeScale(speed);
};
// 知らせが閉じたら、もう戻せない。行を取り除く(ここでサーバーから消す)
function commit() {
if (!pending) return;
if (toast.contains(document.activeElement)) (drafts.querySelector(".draft:not([hidden]) .trash") ?? drafts.querySelector(".drafts__title")).focus();
pending.remove();
pending = null;
hideToast();
}
// 知らせに触れている間は閉じない
["pointerenter", "focusin"].forEach((type) => toast.addEventListener(type, () => timer.pause()));
["pointerleave", "focusout"].forEach((type) => toast.addEventListener(type, () => pending && timer.resume()));
// 元に戻す: 知らせを引いてから、行の高さを戻し、中身を右から戻す
undoButton.addEventListener("click", () => {
if (!pending) return;
const row = pending;
pending = null;
const focus = toast.contains(document.activeElement);
hideToast();
row.hidden = false;
gsap.timeline({ onComplete: () => focus && row.querySelector(".trash").focus() })
.fromTo(row, { height: 0 }, { height: "auto", duration: 0.35, ease: "power2.out", clearProps: "height" }, REOPEN)
.call(recount, null, REOPEN)
.fromTo(row.querySelector(".draft__inner"), { x: SHIFT, autoAlpha: 0 }, { x: 0, autoAlpha: 1, duration: 0.4, ease: "power3.out" }, REOPEN + 0.2)
.timeScale(speed);
});
drafts.querySelectorAll(".draft").forEach((row, i) => {
const button = row.querySelector(".trash");
const [lid, paper, can] = [".trash__lid", ".trash__paper", ".trash__can"].map((s) => row.querySelector(s));
// clipPath は id で指すので、アイコンがいくつあっても取り違えないよう付け直す
const clip = row.querySelector("clipPath");
clip.id = `trash-mouth-${n}-${i}`;
row.querySelector("[clip-path]").setAttribute("clip-path", `url(#${clip.id})`);
// 押す → ふたが左端を支点に開き、紙が落ちて缶に隠れ、ふたが弾んで閉じる → 行が右へ抜けて詰まる
const remove = gsap
.timeline({ paused: true })
.to(button, { scale: 0.92, duration: 0.08, ease: "power2.out" }, 0)
.to(button, { scale: 1, duration: 0.4, ease: "back.out(3)" }, 0.08)
.fromTo(lid, { rotation: 0, y: 0, svgOrigin: "3.5 7" }, { rotation: -70, y: -1, svgOrigin: "3.5 7", duration: 0.24, ease: "back.out(2)" }, 0)
.fromTo(paper, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.06, ease: "none" }, 0.16)
.fromTo(paper, { y: -3, rotation: -6, svgOrigin: "14 -2" }, { y: 16, rotation: 12, svgOrigin: "14 -2", duration: 0.36, ease: "power2.in" }, 0.16)
.fromTo(lid, { rotation: -70, y: -1, svgOrigin: "3.5 7" }, { rotation: 0, y: 0, svgOrigin: "3.5 7", duration: 0.3, ease: "back.out(3)", immediateRender: false }, 0.52)
.fromTo(can, { scaleY: 1, svgOrigin: "12 21" }, { scaleY: 0.9, svgOrigin: "12 21", duration: 0.07, ease: "power2.out" }, 0.56)
.fromTo(can, { scaleY: 0.9, svgOrigin: "12 21" }, { scaleY: 1, svgOrigin: "12 21", duration: 0.35, ease: "elastic.out(1, 0.4)", immediateRender: false }, 0.63)
.to(row.querySelector(".draft__inner"), { x: SHIFT, autoAlpha: 0, duration: 0.3, ease: "power2.in" }, 0.8)
.to(row, { height: 0, duration: 0.3, ease: "power2.inOut" }, 1.05)
.call(recount, null, 1.05);
button.addEventListener("click", async () => {
if (row.dataset.removing) return; // 消している途中は受けない
row.dataset.removing = "true";
const focus = button.matches(":focus-visible"); // キーボードで押したときだけ、焦点を「元に戻す」へ移す
commit(); // 前に消した行は、もう戻せない
await remove.invalidate().restart().timeScale(speed); // 開き直したあとの高さから測り直す
row.hidden = true;
delete row.dataset.removing;
pending = row;
showToast(focus);
});
});
});