本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
オーバーレイ

「…」のボタンを押すと、メニューがボタンの角から広がって開く

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

動作
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% ほどまで下げると角から伸びる勢いが強く出ます。画面の下にメニューが入らない行では、上に開いて面の右下の角を起点にし、面と切り抜きを下の縁から広げます。ファイルやメールの一覧、表の行ごとの操作、カードの右上の「…」に向いています。

コード

HTML
<!-- 行ごとに「…」のボタンとメニューを置く。メニューは 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>

CSS
.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); }

JavaScript
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));

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。