Toast Notifications (undo and stacked queue)
Toast notifications that show the result of saving, sending or deleting in the top-right corner (at the bottom on narrow screens) for a few seconds. Successive toasts stack with the newest in front and older ones peeking out by their edge; hovering or moving focus into the stack spreads it out so every message is readable. At most three are shown at once, and a fourth pushes out the oldest. The countdown pauses while the pointer is over the stack, while focus is inside it, while the tab is hidden and while it is off screen, and the remaining time is visible as seconds and a hairline bar. Delete notices alone last eight seconds and carry an Undo button that puts the row back in its original position and turns the same toast into a "restored" notice. Screen readers hear each message once through a separate aria-live region; Alt+T (Option+T on Mac) jumps to the newest toast and Escape closes it, returning focus to where it was. Motion uses only transform and opacity; with prefers-reduced-motion the stack is laid out spread instead of overlapped, so it reads fully as a still image. No dependencies, no rounded corners, no small English kickers or trailing arrow links, just hairline rules and whitespace.
- Added:
- 2026-10-02
- Dependencies:
- None
- tags
- #toast #notification #snackbar #undo #aria-live #keyboard #accessibility #ui #no-image
Preview
結果は画面の端に短く、消したものはすぐ戻せる
保存・送信・削除の結果を右上に数秒だけ出します。続けて届いたら積み重ね、古いものから消えます。ポインタを載せるか中にフォーカスがある間は時計が止まり、削除の知らせには「元に戻す」が付きます。
添付ファイル 4 件
- 見積書_1002.pdf182 KB
- 現場写真_01.jpg2.4 MB
- 現場写真_02.jpg2.1 MB
- 要件メモ.txt4 KB
通知のきまり
- 1新しいものが手前続けて届いた通知は重ねて置き、奥のものは縁だけ見せます。載せると広がります。
- 2同時に出すのは3件まで4件目が来たら一番古いものから消します。残り時間も古い順に尽きます。
- 3読んでいる間は止めるポインタが載っている間と、中にフォーカスがある間は時計が進みません。
- 4削除だけ8秒と取り消し元に戻すと、その通知が「戻しました」に変わり、行は元の位置へ戻ります。
- Alt+T
- 最新の通知へ移る(Mac は Option+T)
- Tab
- 通知の中のボタンを順に選ぶ
- Enter
- 元に戻す・閉じるを押す
- Esc
- フォーカス中の通知を閉じる
"use client";
import { useEffect, useId, useRef, useState, useSyncExternalStore, type KeyboardEvent } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({ weight: ["500", "600"], subsets: ["latin"], display: "swap" });
/**
* トースト通知(取り消しつき・重なりの整列)
*
* 差別化: 既存のフォーム・UI部品(ステータスバッジ・ボタン・入力欄・フォーム)はどれも置いたら動かない静的な部品で、
* 時間で現れて消える一過性の知らせは無かった。本ブロックは「待ち行列」そのもの(最大3件の積み上げ・古いものから退場・
* ホバー中は時計が止まる・削除は元に戻せる)で、構造も操作も別物。角丸・英字の小見出し・末尾の矢印リンクは置かない。
*
* 汎用技法メモ:
* 1. **残り時間は rAF の差分で減らす**。各通知の残りミリ秒を Map に持ち、毎フレーム dt を引く。止めたいとき
* (ポインタが載っている・中にフォーカスがある・タブが裏・画面外・一覧の縮小カード)は引かないだけで一時停止になる。
* 進み具合の細線(scaleX)と残り秒数は state を通さず DOM へ直接書くので、毎フレーム再描画しない。
* 2. **重なりは transform だけで整える**。全件を上端に absolute で重ね、新しい順の番号 i で
* 畳んだ時は scale(1-0.05×i) と、縁が12px×i覗くだけ下へずらす(縮んだ分の高さも足す)、広げた時は「手前の通知の高さの合計+隙間」だけ下へずらす。
* 高さは ResizeObserver で中身を測る。置き場は広い枠で右上・狭い枠で下端(上に置くと操作ボタンを覆う)で、向きの符号だけを反転する。畳んだ時は奥の通知の高さを手前に揃え、中身を opacity 0 にして縁だけ覗かせる。
* 3. **4件目が来たら最古を先に退場させる**。退場は opacity と translateX を 220ms 掛けてから配列から外す。
* 4. **読み上げは別の live 領域で1回だけ**。見えている通知の一覧は live にせず、sr-only の role="status" に
* 文面を書く(同じ文面が続いても読まれるよう、一度空にしてから次のフレームで入れる)。
* 5. **キーボード**: Alt+T(Mac は Option+T・e.code で判定)で最新の通知へ移り、Tab で中のボタン、Esc で閉じる。
* 閉じた通知にフォーカスがあったら、残っている最新の通知か、通知を出した元の場所へ戻す。
* 6. prefers-reduced-motion では遷移を切り、通知は常に広げて並べる(重ねないので静止状態でも全文が読める)。
* 進み具合の細線は消し、残り秒数の数字だけで伝える。
*
* 文言・項目・配色はすべて First CH のオリジナル。
*/
type FileRow = { id: string; name: string; size: string };
type Kind = "save" | "send" | "delete" | "restore";
type Toast = {
id: number;
kind: Kind;
title: string;
detail: string;
duration: number;
/** 削除の通知だけが持つ。元に戻すときの行と元の位置 */
undo?: { row: FileRow; index: number };
phase: "enter" | "shown" | "leave";
};
const MAX = 3;
const GAP = 10;
const PEEK = 12;
const LEAVE_MS = 220;
const FILES: FileRow[] = [
{ id: "f1", name: "見積書_1002.pdf", size: "182 KB" },
{ id: "f2", name: "現場写真_01.jpg", size: "2.4 MB" },
{ id: "f3", name: "現場写真_02.jpg", size: "2.1 MB" },
{ id: "f4", name: "現場写真_03.jpg", size: "1.9 MB" },
{ id: "f5", name: "要件メモ.txt", size: "4 KB" },
];
// 最初から1件消しておき、その「元に戻す」つきの通知を見本として出す
const SEED_DELETED = { row: FILES[3], index: 3 };
const subscribeMotion = (cb: () => void) => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
};
const getMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const getMotionServer = () => false;
export default function ToastUndoNotice() {
const uid = useId();
const headingId = `${uid}-h`;
const reduce = useSyncExternalStore(subscribeMotion, getMotion, getMotionServer);
const [rows, setRows] = useState<FileRow[]>(() => FILES.filter((f) => f.id !== SEED_DELETED.row.id));
const [toasts, setToasts] = useState<Toast[]>([]);
const [heights, setHeights] = useState<Record<number, number>>({});
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const [announce, setAnnounce] = useState("");
// 広い枠では右上、狭い枠では下端に出す(狭い枠で上に置くと操作ボタンを覆うため)
const [atTop, setAtTop] = useState(true);
const [saves, setSaves] = useState(0);
const [sends, setSends] = useState(0);
const rootRef = useRef<HTMLElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLOListElement>(null);
const nextId = useRef(1);
const remaining = useRef(new Map<number, number>());
const bars = useRef(new Map<number, HTMLElement>());
const secs = useRef(new Map<number, HTMLElement>());
const items = useRef(new Map<number, HTMLLIElement>());
const contents = useRef(new Map<Element, number>());
const observer = useRef<ResizeObserver | null>(null);
const toastsRef = useRef<Toast[]>([]);
const hoveredRef = useRef(false);
const focusedRef = useRef(false);
const inViewRef = useRef(true);
const reduceRef = useRef(false);
const returnRef = useRef<HTMLElement | null>(null);
const dismissRef = useRef<(id: number) => void>(() => {});
const expanded = reduce || hovered || focused;
useEffect(() => {
toastsRef.current = toasts;
hoveredRef.current = hovered;
focusedRef.current = focused;
reduceRef.current = reduce;
});
// 中身の高さを測る(重なりの計算に使う)
useEffect(() => {
const ro = new ResizeObserver((entries) => {
setHeights((prev) => {
let next = prev;
for (const en of entries) {
const id = contents.current.get(en.target);
if (id === undefined) continue;
const h = Math.round((en.target as HTMLElement).offsetHeight);
if (prev[id] !== h) {
if (next === prev) next = { ...prev };
next[id] = h;
}
}
return next;
});
});
observer.current = ro;
contents.current.forEach((_, el) => ro.observe(el));
return () => {
ro.disconnect();
observer.current = null;
};
}, []);
useEffect(() => {
const el = stageRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setAtTop(el.offsetWidth >= 512));
ro.observe(el);
return () => ro.disconnect();
}, []);
// 画面外では時計を止める
useEffect(() => {
const el = stageRef.current;
if (!el) return;
const io = new IntersectionObserver(([en]) => {
inViewRef.current = en.isIntersecting;
});
io.observe(el);
return () => io.disconnect();
}, []);
const isScaled = () => {
const el = rootRef.current;
if (!el || el.offsetWidth === 0) return true;
return el.getBoundingClientRect().width / el.offsetWidth < 0.75;
};
// 残り時間の時計。止める条件のどれかが立っている間は引かない
useEffect(() => {
let raf = 0;
let last = 0;
const tick = (t: number) => {
const dt = last ? Math.min(t - last, 100) : 0;
last = t;
// フォーカス中の要素が消える(元に戻す・閉じる)と blur が来ないので、毎フレーム実物で確かめる
const inList = !!listRef.current?.contains(document.activeElement);
if (inList !== focusedRef.current) {
focusedRef.current = inList;
setFocused(inList);
}
const held = hoveredRef.current || inList;
const stopped = held || document.hidden || !inViewRef.current || isScaled();
for (const toast of toastsRef.current) {
if (toast.phase === "leave") continue;
let rem = remaining.current.get(toast.id) ?? toast.duration;
if (!stopped) rem -= dt;
remaining.current.set(toast.id, rem);
if (rem <= 0) {
dismissRef.current(toast.id);
continue;
}
const bar = bars.current.get(toast.id);
if (bar) bar.style.transform = `scaleX(${Math.max(0, rem / toast.duration)})`;
const sec = secs.current.get(toast.id);
if (sec) {
const text = `${held ? "停止中 " : ""}${Math.ceil(rem / 1000)}s`;
if (sec.textContent !== text) sec.textContent = text;
}
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
const speak = (text: string) => {
setAnnounce("");
requestAnimationFrame(() => setAnnounce(text));
};
const focusAfter = (fallback?: HTMLElement | null) => {
requestAnimationFrame(() => {
const live = toastsRef.current.filter((t) => t.phase !== "leave");
const newest = live.length ? items.current.get(live[live.length - 1].id) : undefined;
const target = newest ?? (returnRef.current?.isConnected ? returnRef.current : fallback);
target?.focus();
});
};
const dismiss = (id: number) => {
const li = items.current.get(id);
const hadFocus = !!li && li.contains(document.activeElement);
setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, phase: "leave" } : t)));
// 退場中の通知はもう選べないので、閉じた時点で残りの toastsRef を先に更新しておく
toastsRef.current = toastsRef.current.map((t) => (t.id === id ? { ...t, phase: "leave" } : t));
window.setTimeout(
() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
remaining.current.delete(id);
},
reduceRef.current ? 0 : LEAVE_MS,
);
if (hadFocus) focusAfter(listRef.current);
};
useEffect(() => {
dismissRef.current = dismiss;
});
const push = (t: Omit<Toast, "id" | "phase">, opts: { quiet?: boolean } = {}) => {
const id = nextId.current++;
const live = toastsRef.current.filter((x) => x.phase !== "leave");
if (live.length >= MAX) dismiss(live[0].id);
remaining.current.set(id, t.duration);
const toast: Toast = { ...t, id, phase: reduceRef.current ? "shown" : "enter" };
toastsRef.current = [...toastsRef.current, toast];
setToasts((prev) => [...prev, toast]);
if (!reduceRef.current) {
requestAnimationFrame(() =>
requestAnimationFrame(() => setToasts((prev) => prev.map((x) => (x.id === id && x.phase === "enter" ? { ...x, phase: "shown" } : x)))),
);
}
if (!opts.quiet) speak(t.undo ? `${t.title}。元に戻すボタンは通知の中にあります。Alt+T で通知へ移動できます` : `${t.title}。${t.detail}`);
};
// 見本: 開いた直後に2件届いた状態から始める
useEffect(() => {
const a = window.setTimeout(() => push({ kind: "save", title: "下書きを自動保存しました", detail: "あとで続きから書けます", duration: 6000 }, { quiet: true }), 150);
const b = window.setTimeout(
() =>
push(
{ kind: "delete", title: `「${SEED_DELETED.row.name}」を削除しました`, detail: "添付ファイルから外しました", duration: 9000, undo: SEED_DELETED },
{ quiet: true },
),
500,
);
return () => {
window.clearTimeout(a);
window.clearTimeout(b);
};
// 見本の初期状態を1回だけ出す
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Alt+T で最新の通知へ移る(一覧の縮小カードでは反応しない)
useEffect(() => {
const onKey = (e: globalThis.KeyboardEvent) => {
if (e.code !== "KeyT" || !e.altKey || e.metaKey || e.ctrlKey || e.shiftKey) return;
if (isScaled()) return;
const live = toastsRef.current.filter((t) => t.phase !== "leave");
if (!live.length) return;
e.preventDefault();
const active = document.activeElement as HTMLElement | null;
if (active && !listRef.current?.contains(active)) returnRef.current = active;
items.current.get(live[live.length - 1].id)?.focus();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const save = (e: { currentTarget: HTMLElement }) => {
returnRef.current = e.currentTarget;
const n = saves + 1;
setSaves(n);
push({ kind: "save", title: "下書きを保存しました", detail: n === 1 ? "あとで続きから書けます" : `今日 ${n} 回目の保存です`, duration: 5000 });
};
const send = (e: { currentTarget: HTMLElement }) => {
returnRef.current = e.currentTarget;
const n = sends + 1;
setSends(n);
// 見本なので実際には送らない。受付済みと誤解させないよう、通知でもそう明示する
push({ kind: "send", title: "送信の見本です(実際には送信していません)", detail: n === 1 ? "本番ではここに受付番号と返信の目安を出します" : `見本の送信 ${n} 回目・どこにも届いていません`, duration: 6000 });
};
const remove = (row: FileRow, index: number) => {
setRows((prev) => prev.filter((r) => r.id !== row.id));
// 消した行のボタンは無くなるので、隣の行か「保存」ボタンを戻り先にする
const neighbor = rows[index + 1] ?? rows[index - 1];
const stage = stageRef.current;
requestAnimationFrame(() => {
const target =
(neighbor && stage?.querySelector<HTMLElement>(`[data-row="${neighbor.id}"] button`)) || stage?.querySelector<HTMLElement>("[data-save]") || null;
returnRef.current = target;
target?.focus();
});
push({ kind: "delete", title: `「${row.name}」を削除しました`, detail: "添付ファイルから外しました", duration: 8000, undo: { row, index } });
};
const undo = (toast: Toast) => {
if (!toast.undo) return;
const { row, index } = toast.undo;
setRows((prev) => {
if (prev.some((r) => r.id === row.id)) return prev;
const next = [...prev];
next.splice(Math.min(index, next.length), 0, row);
return next;
});
// 同じ通知をその場で「戻しました」に差し替え、時計も巻き直す
const restored: Toast = { ...toast, kind: "restore", title: `「${row.name}」を元に戻しました`, detail: "元の位置に戻っています", duration: 3500, undo: undefined };
remaining.current.set(toast.id, restored.duration);
toastsRef.current = toastsRef.current.map((t) => (t.id === toast.id ? restored : t));
setToasts((prev) => prev.map((t) => (t.id === toast.id ? restored : t)));
speak(restored.title);
const stage = stageRef.current;
requestAnimationFrame(() => {
const btn = stage?.querySelector<HTMLElement>(`[data-row="${row.id}"] button`);
returnRef.current = btn ?? returnRef.current;
btn?.focus();
});
};
const onToastKey = (e: KeyboardEvent<HTMLLIElement>, id: number) => {
if (e.key === "Escape") {
e.preventDefault();
dismiss(id);
}
};
// 新しい順の番号と、広げた時の下へのずらし量
const ordered = [...toasts].reverse();
const offsets: number[] = [];
let acc = 0;
for (const t of ordered) {
offsets.push(acc);
acc += (heights[t.id] ?? 72) + GAP;
}
const frontH = ordered.length ? (heights[ordered[0].id] ?? 72) : 0;
const shownCount = Math.min(ordered.filter((t) => t.phase !== "leave").length, MAX);
const listH = !ordered.length ? 0 : expanded ? Math.max(acc - GAP, 0) : frontH + Math.max(shownCount - 1, 0) * PEEK;
return (
<section ref={rootRef} aria-labelledby={headingId} className="@container w-full max-w-5xl bg-white text-gray-900">
{/* 冒頭: 英字の小見出しは置かず、和文の見出しとリードだけで開く */}
<div className="max-w-2xl">
<h2 id={headingId} className="text-[clamp(1.45rem,3.4vw,2.1rem)] leading-[1.35] font-bold tracking-tight">
結果は画面の端に短く、
<span className="whitespace-nowrap">消したものはすぐ戻せる</span>
</h2>
<p className="mt-4 text-[14px] leading-[1.9] font-medium text-gray-600">保存・送信・削除の結果を右上に数秒だけ出します。続けて届いたら積み重ね、古いものから消えます。ポインタを載せるか中にフォーカスがある間は時計が止まり、削除の知らせには「元に戻す」が付きます。</p>
</div>
<div className="mt-8 grid gap-8 border-t border-gray-200 pt-8 @2xl:grid-cols-[minmax(0,1fr)_15rem] @2xl:gap-9">
{/* 舞台: 見本の入力画面。通知はこの枠の右上に出す(本番では fixed で画面の右上)。広い枠では通知の列を空けておく */}
<div ref={stageRef} className="@container/stage relative h-[31rem] overflow-hidden border border-gray-200 bg-[linear-gradient(180deg,#ffffff_0%,#f8f6f2_100%)]">
<div className="flex items-center justify-between gap-3 border-b border-gray-200 bg-white px-4 py-3">
<span className={`${montserrat.className} text-[13px] font-semibold tracking-wide`}>First CH</span>
<span className="truncate text-[11.5px] text-gray-500">見積もり依頼の下書き</span>
</div>
<div className="px-4 py-5 @md/stage:px-6 @lg/stage:max-w-[calc(100%-18.5rem)]">
<div className="flex flex-wrap items-center gap-2">
<button
data-save
type="button"
onClick={save}
className="border border-gray-300 bg-white px-3.5 py-2 text-[12.5px] font-medium text-gray-800 transition-colors hover:border-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
>
下書きを保存
</button>
<button
type="button"
onClick={send}
className="border border-gray-900 bg-gray-900 px-3.5 py-2 text-[12.5px] font-medium text-white transition-colors hover:border-amber-700 hover:bg-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
>
送信する
</button>
</div>
<p className="mt-6 text-[11.5px] font-bold text-gray-500">
添付ファイル <span className={`${montserrat.className} font-medium tabular-nums`}>{rows.length}</span> 件
</p>
<ul className="mt-2 border-t border-gray-200">
{rows.map((row, i) => (
<li key={row.id} data-row={row.id} className="flex items-center justify-between gap-3 border-b border-gray-200 py-2.5">
<span className="min-w-0">
<span className="block truncate text-[13px] text-gray-800">{row.name}</span>
<span className={`${montserrat.className} block text-[10.5px] font-medium text-gray-400 tabular-nums`}>{row.size}</span>
</span>
<button
type="button"
onClick={() => remove(row, i)}
aria-label={`「${row.name}」を削除`}
className="flex-none border border-transparent px-2 py-1 text-[12px] text-gray-500 transition-colors hover:border-gray-300 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
>
削除
</button>
</li>
))}
{rows.length === 0 && <li className="border-b border-gray-200 py-3 text-[12.5px] text-gray-500">添付ファイルはありません。通知の「元に戻す」で戻せます。</li>}
</ul>
</div>
{/* 読み上げ専用。見えている通知の一覧は live にしない(二重に読まれるのを防ぐ) */}
<p role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{announce}
</p>
{/* 通知の積み上げ。新しいものが手前(枠の端の側)、古いものが奥 */}
<section aria-label="通知(Alt+T で移動)" className={`absolute right-3 w-[min(17rem,calc(100%-1.5rem))] ${atTop ? "top-[3.75rem]" : "bottom-3"}`}>
<ol
ref={listRef}
tabIndex={-1}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
style={{ height: listH }}
className="relative focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
>
{ordered.map((t, i) => {
const front = i === 0;
const hiddenBehind = !expanded && i >= MAX;
const s = expanded || t.phase === "enter" ? 1 : 1 - i * 0.05;
// 縮めた分だけ外側の端が寄るので、その差も足して縁を PEEK ずつ覗かせる
const dir = atTop ? 1 : -1;
const y = dir * (t.phase === "enter" ? -24 : expanded ? offsets[i] : i * PEEK + frontH * (1 - s));
const x = t.phase === "leave" ? 28 : 0;
const opacity = t.phase === "enter" || t.phase === "leave" || hiddenBehind ? 0 : 1;
return (
<li
key={t.id}
ref={(el) => {
if (el) items.current.set(t.id, el);
else items.current.delete(t.id);
}}
tabIndex={-1}
aria-label={t.title}
onKeyDown={(e) => onToastKey(e, t.id)}
inert={t.phase === "leave"}
style={{
transform: `translate(${x}px, ${y}px) scale(${s})`,
opacity,
zIndex: 10 - i,
height: expanded || front ? undefined : frontH,
}}
className={`absolute inset-x-0 overflow-hidden ${atTop ? "top-0 origin-top" : "bottom-0 origin-bottom"} border bg-white shadow-[0_14px_32px_-16px_rgba(28,25,23,0.35)] transition-[transform,opacity] duration-[220ms] ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none ${
t.kind === "delete" ? "border-gray-300 border-l-amber-600 border-l-2" : "border-gray-300"
}`}
>
<div
ref={(el) => {
const ro = observer.current;
if (!el) return;
contents.current.set(el, t.id);
ro?.observe(el);
return () => {
contents.current.delete(el);
observer.current?.unobserve(el);
};
}}
className={`transition-opacity duration-150 motion-reduce:transition-none ${expanded || front ? "opacity-100" : "opacity-0"}`}
>
<div className="flex items-start gap-2 pt-3 pr-1.5 pl-3.5">
<div className="min-w-0 flex-1">
<p className="text-[13px] leading-[1.6] font-bold text-gray-900">{t.title}</p>
<p className="mt-0.5 text-[11.5px] leading-[1.6] text-gray-500">{t.detail}</p>
</div>
<button
type="button"
onClick={() => dismiss(t.id)}
className="grid size-7 flex-none place-items-center text-[15px] leading-none text-gray-400 transition-colors hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
>
<span className="sr-only">通知を閉じる</span>
<span aria-hidden="true">×</span>
</button>
</div>
<div className="flex min-h-9 items-center gap-2 pt-1 pr-3 pb-2.5 pl-3.5">
<div aria-hidden="true" className="h-px flex-1 bg-gray-100">
<div
ref={(el) => {
if (el) bars.current.set(t.id, el);
else bars.current.delete(t.id);
}}
className="h-px origin-left bg-amber-600 motion-reduce:hidden"
/>
</div>
<span
aria-hidden="true"
ref={(el) => {
if (el) secs.current.set(t.id, el);
else secs.current.delete(t.id);
}}
className={`${montserrat.className} flex-none text-[10px] font-medium text-gray-400 tabular-nums`}
/>
{t.undo && (
<button
type="button"
onClick={() => undo(t)}
className="flex-none border border-amber-600 px-2.5 py-1 text-[12px] font-bold whitespace-nowrap text-amber-800 transition-colors hover:bg-amber-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
>
元に戻す
</button>
)}
</div>
</div>
</li>
);
})}
</ol>
</section>
</div>
{/* 通知のきまり(実装の挙動をそのまま文章にしたもの) */}
<div className="min-w-0">
<p className="text-[12px] font-bold tracking-widest text-gray-500">通知のきまり</p>
<ol className="mt-3 border-t border-gray-200">
{[
["新しいものが手前", "続けて届いた通知は重ねて置き、奥のものは縁だけ見せます。載せると広がります。"],
["同時に出すのは3件まで", "4件目が来たら一番古いものから消します。残り時間も古い順に尽きます。"],
["読んでいる間は止める", "ポインタが載っている間と、中にフォーカスがある間は時計が進みません。"],
["削除だけ8秒と取り消し", "元に戻すと、その通知が「戻しました」に変わり、行は元の位置へ戻ります。"],
].map(([term, body], i) => (
<li key={term} className="grid grid-cols-[1.5rem_minmax(0,1fr)] gap-x-2 border-b border-gray-200 py-3">
<span className={`${montserrat.className} text-[12px] font-semibold text-amber-700 tabular-nums`}>{i + 1}</span>
<span>
<span className="block text-[13px] leading-[1.6] font-bold">{term}</span>
<span className="mt-1 block text-[12px] leading-[1.75] text-gray-500">{body}</span>
</span>
</li>
))}
</ol>
<dl className="mt-5 grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-2 text-[12px] text-gray-600">
{[
["Alt+T", "最新の通知へ移る(Mac は Option+T)"],
["Tab", "通知の中のボタンを順に選ぶ"],
["Enter", "元に戻す・閉じるを押す"],
["Esc", "フォーカス中の通知を閉じる"],
].map(([k, v]) => (
<div key={k} className="contents">
<dt>
<kbd className={`${montserrat.className} inline-block min-w-[2.9rem] border border-gray-200 bg-gray-50 px-1.5 py-px text-center text-[10.5px] font-medium text-gray-700`}>
{k}
</kbd>
</dt>
<dd>{v}</dd>
</div>
))}
</dl>
</div>
</div>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/toast-undo-notice.json