デモ
デモを読み込めませんでした。
仕様
- 用途
- オーバーレイ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
行の「…」を押すと、ボタンの右下の角のすぐ下を起点に、メニューの面が幅 45%・高さ 18% から広がり、項目が上から順に少しずつ下りてきます。項目を選ぶ・メニューの外を押す・Esc を押すと、同じ動きを 2 倍の速さで逆にたどり、項目が下から消えてから面が角へ畳まれます。
面は 0.3 秒の power3.out で広げ、項目は 0.05 秒後から 0.035 秒ずつずらして出します。項目の不透明度は 0.12 秒で先に上げ、8px 上からの位置合わせは 0.3 秒かけて終えます。押した所から開くので、どの行のメニューかが一目でわかります。開いている行を白い地にして、つながりをさらにはっきりさせています。
広げるのは、文字を含まない面(地と縁)だけです。メニューごと scale で拡大すると、小さいあいだの文字がつぶれてにじみ、縁の 1px の線も細って見えます。面は疑似要素で描いて幅と高さを 2 つの CSS 変数で決め、項目の列を同じ変数の clip-path で切り抜くと、広がる途中でも文字が面の外にはみ出しません。GSAP は変数の数だけを補間します。
ボタンには aria-haspopup と aria-expanded を付け、項目は役割が menuitem のボタンにします。Enter・Space・下矢印で開いたら最初の項目へ、上矢印なら最後の項目へ焦点を移し、上下の矢印・Home・End で項目を移ります。Esc で閉じたら焦点をボタンへ戻し、Tab では閉じて次の要素へ進みます。開いたまま別の行の「…」を押すと、前のメニューを閉じてから新しいほうを開きます。
起点の大きさを 70% ほどに寄せると控えめな開き方に、20% ほどまで下げると角から伸びる勢いが強く出ます。画面の下にメニューが入らない行では、上に開いて面の右下の角を起点にし、面と切り抜きを下の縁から広げます。ファイルやメールの一覧、表の行ごとの操作、カードの右上の「…」に向いています。
コード
<!-- 行ごとに「…」のボタンとメニューを置く。メニューは hidden のまま置き、スクリプトが開く -->
<ul class="files">
<li class="files__row">
<span class="files__name">ブランドガイドライン.pdf</span>
<span class="files__date">9月12日</span>
<div class="context">
<button class="context__button" type="button" aria-label="ブランドガイドライン.pdfの操作"
aria-haspopup="menu" aria-expanded="false" aria-controls="menu-brand">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="5" cy="12" r="1.8" /><circle cx="12" cy="12" r="1.8" /><circle cx="19" cy="12" r="1.8" />
</svg>
</button>
<div class="context__menu" hidden>
<ul class="context__list" id="menu-brand" role="menu" aria-label="ブランドガイドライン.pdfの操作">
<li role="none"><button class="context__item" type="button" role="menuitem" tabindex="-1" data-action="open">開く</button></li>
<li role="none"><button class="context__item" type="button" role="menuitem" tabindex="-1" data-action="rename">名前を変更</button></li>
<li role="none"><button class="context__item" type="button" role="menuitem" tabindex="-1" data-action="duplicate">複製</button></li>
<li role="none"><button class="context__item" type="button" role="menuitem" tabindex="-1" data-action="copy-link">リンクをコピー</button></li>
<li role="separator"></li>
<li role="none"><button class="context__item context__item--danger" type="button" role="menuitem" tabindex="-1" data-action="delete">削除</button></li>
</ul>
</div>
</div>
</li>
<li class="files__row">
<span class="files__name">新サービス発表資料.key</span>
<span class="files__date">9月10日</span>
<div class="context">
<button class="context__button" type="button" aria-label="新サービス発表資料.keyの操作"
aria-haspopup="menu" aria-expanded="false" aria-controls="menu-launch">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="5" cy="12" r="1.8" /><circle cx="12" cy="12" r="1.8" /><circle cx="19" cy="12" r="1.8" />
</svg>
</button>
<div class="context__menu" hidden>
<ul class="context__list" id="menu-launch" role="menu" aria-label="新サービス発表資料.keyの操作">
<li role="none"><button class="context__item" type="button" role="menuitem" tabindex="-1" data-action="open">開く</button></li>
<li role="none"><button class="context__item" type="button" role="menuitem" tabindex="-1" data-action="rename">名前を変更</button></li>
<li role="none"><button class="context__item" type="button" role="menuitem" tabindex="-1" data-action="duplicate">複製</button></li>
<li role="none"><button class="context__item" type="button" role="menuitem" tabindex="-1" data-action="copy-link">リンクをコピー</button></li>
<li role="separator"></li>
<li role="none"><button class="context__item context__item--danger" type="button" role="menuitem" tabindex="-1" data-action="delete">削除</button></li>
</ul>
</div>
</div>
</li>
</ul>
.files {
--ink: #262626; /* 文字と、乗った項目の地 */
--surface: #ffffff; /* メニューの面と、開いている行の地 */
--mute: #6b6b6b; /* 補足の文字 */
--line: rgba(38, 38, 38, 0.12); /* 罫線とメニューの縁 */
--danger: #a8432d; /* 削除の項目 */
--radius: 4px;
margin: 0;
padding: 0;
color: var(--ink);
list-style: none;
}
.files__row { display: flex; align-items: center; gap: 16px; min-height: 64px; padding: 0 8px 0 16px; box-shadow: inset 0 -1px 0 var(--line); }
/* メニューを開いている行は、白い地にする */
.files__row:has([aria-expanded="true"]) { background-color: var(--surface); }
.files__name { flex: 1; min-width: 0; overflow: hidden; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.files__date { color: var(--mute); font-size: 14px; white-space: nowrap; }
.context { position: relative; flex: none; }
.context__button {
display: grid;
place-items: center;
width: 40px;
height: 40px;
padding: 0;
border: 0;
border-radius: var(--radius);
background: none;
color: var(--ink);
cursor: pointer;
}
.context__button svg { width: 20px; height: 20px; fill: currentColor; }
.context__button:hover { background-color: rgba(38, 38, 38, 0.06); }
.context__button[aria-expanded="true"] { background-color: rgba(38, 38, 38, 0.1); }
.context__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
/* メニュー。--w と --h(0〜100)が、面の大きさと項目の切り抜きを決める。起点は右上の角 */
.context__menu {
--w: 100; --h: 100;
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: 10;
width: 224px;
}
/* 面(地と縁)。文字を含まない板だけを広げるので、項目の文字はつぶれない */
.context__menu::before {
content: "";
position: absolute;
top: 0;
right: 0;
width: calc(var(--w) * 1%);
height: calc(var(--h) * 1%);
border-radius: var(--radius);
background-color: var(--surface);
box-shadow: inset 0 0 0 1px var(--line); /* 影は付けず、1px の罫線で縁を取る */
}
/* 項目の列は、面と同じ形で切り抜く */
.context__list {
position: relative;
margin: 0;
padding: 6px;
list-style: none;
clip-path: inset(0 0 calc(100% - var(--h) * 1%) calc(100% - var(--w) * 1%) round var(--radius));
}
.context__list [role="separator"] { height: 1px; margin: 6px; background-color: var(--line); }
/* 下に入らないときは上に開く。起点は右下の角になり、面も切り抜きも下の縁から広がる */
.context__menu.is-up { top: auto; bottom: calc(100% + 4px); }
.context__menu.is-up::before { top: auto; bottom: 0; }
.context__menu.is-up .context__list { clip-path: inset(calc(100% - var(--h) * 1%) 0 0 calc(100% - var(--w) * 1%) round var(--radius)); }
.context__item {
display: block;
width: 100%;
height: 40px;
padding: 0 12px;
border: 0;
border-radius: 2px;
background: none;
color: var(--ink);
font: inherit;
font-size: 14px; /* font: inherit の後に書く(先だと打ち消される) */
letter-spacing: 0.04em;
text-align: left;
cursor: pointer;
}
.context__item--danger { color: var(--danger); }
/* 乗った項目と、キーボードで選んでいる項目は、明暗を反転する */
.context__item:is(:hover, :focus-visible) { outline: none; background-color: var(--ink); color: var(--surface); }
.context__item--danger:is(:hover, :focus-visible) { background-color: var(--danger); }
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const CLOSE_SPEED = 2; // 閉じるときは、開く動きを 2 倍の速さで逆にたどる
let current = null; // 開いているメニュー(同時に開くのは 1 つだけ)
document.querySelectorAll(".context").forEach((context) => {
const button = context.querySelector(".context__button");
const menu = context.querySelector(".context__menu");
const items = [...menu.querySelectorAll("[role='menuitem']")];
const tl = gsap
.timeline({ paused: true, onReverseComplete: () => (menu.hidden = true) })
// 面は右上の角を止めて、幅 45%・高さ 18% から広がる
.fromTo(menu, { "--w": 45, "--h": 18 }, { "--w": 100, "--h": 100, duration: 0.3, ease: "power3.out" }, 0)
.fromTo(menu, { opacity: 0 }, { opacity: 1, duration: 0.08, ease: "none" }, 0)
// 項目は拡大せず、上から順に少し下りてくる。不透明度は先に 1 にする
.fromTo(items, { opacity: 0 }, { opacity: 1, duration: 0.12, ease: "power1.out", stagger: 0.035 }, 0.05)
.fromTo(items, { y: -8 }, { y: 0, duration: 0.3, ease: "power3.out", stagger: 0.035 }, 0.05);
const isOpen = () => button.getAttribute("aria-expanded") === "true";
const control = {
// focusAt を渡すと、その番号の項目へ焦点を移す(キーボードで開いたとき)
open(focusAt) {
if (current && current !== control) current.close(false);
current = control;
button.setAttribute("aria-expanded", "true");
menu.hidden = false;
// 画面の下に入らないときは上に開く(上にも入らなければ下のまま)
const r = button.getBoundingClientRect();
menu.classList.toggle("is-up", r.bottom + menu.offsetHeight + 8 > innerHeight && r.top > menu.offsetHeight + 8);
tl.timeScale(SPEED).play();
if (focusAt !== undefined) items.at(focusAt).focus();
},
close(returnFocus) {
if (!isOpen()) return;
if (current === control) current = null;
button.setAttribute("aria-expanded", "false");
tl.timeScale(SPEED * CLOSE_SPEED).reverse();
if (returnFocus) button.focus();
},
};
// Enter・Space で押したときは event.detail が 0 になる。そのときは最初の項目へ焦点を移す
button.addEventListener("click", (event) => (isOpen() ? control.close(false) : control.open(event.detail === 0 ? 0 : undefined)));
button.addEventListener("keydown", (event) => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault();
control.open(event.key === "ArrowDown" ? 0 : -1);
});
// メニューの中: 上下の矢印・Home・End で項目を移る。Tab では閉じるだけ(焦点は次の要素へ進む)
menu.addEventListener("keydown", (event) => {
const i = items.indexOf(document.activeElement);
const next = { ArrowDown: i + 1, ArrowUp: i - 1, Home: 0, End: -1 }[event.key];
if (next !== undefined) {
event.preventDefault();
items.at(next % items.length).focus();
} else if (event.key === "Tab") control.close(false);
});
items.forEach((item) => {
item.addEventListener("click", () => {
// 選んだ操作をページへ知らせる。例: files.addEventListener("contextaction", (e) => e.detail === "delete" && …)
context.dispatchEvent(new CustomEvent("contextaction", { bubbles: true, detail: item.dataset.action }));
control.close(true);
});
});
});
// 外を押したら閉じる(別の「…」を押したときは、開く側が前のメニューを閉じる)。Esc では閉じてボタンへ戻る
document.addEventListener("pointerdown", (event) => {
if (current && !event.target.closest(".context")) current.close(false);
});
document.addEventListener("keydown", (event) => event.key === "Escape" && current?.close(true));