本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100

オーバーレイ

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

デモ

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

仕様

動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

押した「…」ボタンの右下の角のすぐ下に小さな四角が現れ、それがメニューの大きさまで広がるのを追いかけるように、項目が上から順に少しずつ下りてきます。開いている行は背景を白くしてあるので、どの行のメニューなのかが、開いた位置と合わせてひと目で分かります。閉じるときは、項目を選んでも、外を押しても、Esc でも、同じ動きを2倍の速さで逆にたどって角へ畳まれます。画面の下のほうでメニューが収まらない行では上に向かって開き、起点もメニューの右下の角に移ります。

広げるのは、文字を含まない背景(塗りと縁の線)だけにしました。メニューごと scale で拡大すると、小さいあいだの文字がつぶれてにじみ、1pxの縁の線も細って見えます。背景は疑似要素で描いて、幅と高さをふたつの CSS 変数で決め、項目の並びは同じ変数を使った clip-path で切り抜いています。GSAP が補間するのはこの変数の数値だけなので、広がる途中でも文字が背景の外へはみ出しません。

背景は0.3秒の power3.out で広がり、そのすぐあとから項目が追いかけます。起点の大きさ(最初の幅と高さの割合)を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 サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。