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

ボトムシートが下から上がり、つまみを引き下げて離すと閉じる

デモ

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

仕様

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

しくみと調整

ボタンを押すと、後ろの画面が暗くなり、シートが画面の下から上がって、中の操作の一覧が上から順に浮かびます。上端のつまみをつかんで引き下げると、シートは指について下がり、暗さも引いた分だけ薄れます。放したとき、引きが浅ければ元の位置へ戻り、深ければそのまま下へ抜けて閉じます。

上がるのは 0.55 秒の power4.out で、勢いよく出てすっと止まります。暗くなるのは 0.35 秒です。引いているあいだは、引いた割合の半分だけ暗さを薄めます。薄めずにおくと引いている手応えが見えず、引いた割合と同じだけ薄めると、放す前から画面が明るくなって、閉じたように見えます。放したら、高さの 30% より深く引いていたか、速く払っていたときに、0.4 秒の power2.out で閉じます。

つかむ所の touch-action を none にします。そうしないと、スマートフォンでは指の動きがページのスクロールとして扱われ、引き始めたところで pointercancel が届いてシートが止まります。つかんだ瞬間には、開いたり戻ったりしている途中の動きを止めます。止めないと、指とトゥイーンがシートを取り合って震えます。

シートはダイアログの要素に入れて、showModal で開きます。焦点がシートの中へ移り、後ろの画面は操作できなくなり、閉じると焦点は押したボタンへ戻ります。Esc は既定だとその場で消えてしまうので、cancel を止めて同じ動きで閉じ、閉じる動きが終わってから閉じる処理を呼びます。

閉じる判定は、放したときの深さと払う速さの 2 つで決めます。深さの割合を下げると軽く閉じ、上げると閉じにくくなります。戻すときに弾ませると少し上へ行き過ぎるので、シートの面を画面の下へ延ばしておき、下に地がのぞかないようにします。共有・並べ替え・絞り込み、曲やファイルの操作メニューなど、スマートフォンの画面に向いています。

コード

HTML
<button class="sheet-open" type="button" aria-haspopup="dialog" aria-controls="track-sheet" aria-label="曲の操作">
  <span aria-hidden="true">•••</span>
</button>

<!-- showModal() で開く <dialog> には、焦点の閉じ込め・背面の締め出し・Esc・焦点の戻しが付いてくる。
     dialog は画面いっぱいに広げ、暗幕とシートを中に置く(暗幕を引いた量に合わせて薄めるため) -->
<dialog class="sheet" id="track-sheet" aria-labelledby="track-sheet-title">
  <div class="sheet__scrim"></div>
  <div class="sheet__panel">
    <!-- つまみ。指やマウスで引き下げる所。キーボードでは Esc か、操作を選んで閉じる -->
    <div class="sheet__handle" aria-hidden="true"></div>
    <h2 class="sheet__title" id="track-sheet-title">夜のドライブ</h2>
    <ul class="sheet__list">
      <li><button class="sheet__action" type="button">プレイリストに追加</button></li>
      <li><button class="sheet__action" type="button">ダウンロード</button></li>
      <li><button class="sheet__action" type="button">シェア</button></li>
      <li><button class="sheet__action" type="button">アーティストを見る</button></li>
      <li><button class="sheet__action" type="button">スリープタイマー</button></li>
    </ul>
  </div>
</dialog>

CSS
.sheet-open,
.sheet {
  --ink: #262626; /* 文字と線 */
  --surface: #ffffff; /* シートの面 */
  --line: rgba(38, 38, 38, 0.12); /* 罫線 */
  --grabber: #d4d3ce; /* つまみ */
  --scrim: rgba(17, 17, 17, 0.48); /* 後ろの暗幕 */
}

/* 「•••」はアイコンだけのボタンなので、線の丸 */
.sheet-open {
  width: 40px;
  height: 40px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  box-shadow: inset 0 0 0 1px var(--ink);
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: 12px;
  letter-spacing: 0.1em;
  cursor: pointer;
}

/* dialog を画面いっぱいに広げる(既定の余白・枠・最大の大きさを外す) */
.sheet {
  width: 100%;
  max-width: none;
  height: 100%;
  max-height: none;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  overflow: hidden;
}

.sheet::backdrop { background: none; }
.sheet__scrim { position: absolute; inset: 0; background-color: var(--scrim); }

.sheet__panel {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  max-width: 480px;
  margin: 0 auto;
  padding: 0 24px calc(16px + env(safe-area-inset-bottom));
  border-radius: 4px 4px 0 0;
  background-color: var(--surface);
  box-shadow: inset 0 1px 0 var(--line); /* 影は付けず、上端に罫線 */
  color: var(--ink);
}

/* 戻すときに少し上へ行き過ぎても、下に地がのぞかないよう、面を画面の下へ延ばしておく */
.sheet__panel::after {
  content: "";
  position: absolute;
  inset: 100% 0 auto;
  height: 48px;
  background-color: inherit;
}

/* 引く所は上端の帯ごと(棒だけでは小さすぎる)。touch-action を切らないと、指で引いたときにページがスクロールする */
.sheet__handle { height: 28px; margin: 0 -24px; cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none; }

.sheet__handle::before {
  content: "";
  display: block;
  width: 40px;
  height: 5px;
  margin: 9px auto 0;
  border-radius: 99px;
  background-color: var(--grabber);
}

.sheet__title { margin: 0; padding-bottom: 14px; border-bottom: 1px solid var(--line); font-size: 15px; }
.sheet__list { margin: 0; padding: 8px 0 0; list-style: none; }

.sheet__action {
  width: 100%;
  height: 48px;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  font-size: 15px;
  letter-spacing: 0.04em;
  text-align: left;
  cursor: pointer;
}

.sheet-open:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
.sheet__action:focus-visible { outline: 2px solid var(--ink); outline-offset: -2px; }

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const CLOSE_AT = 0.3; // 高さの 30% より深く引いて放したら閉じる。浅ければ戻す
const FLICK = 0.6; // これより速く下へ払って放したら、浅くても閉じる(px/ms)
const DIM_FOLLOW = 0.5; // 引いた割合の半分だけ、暗さを薄める

document.querySelectorAll(".sheet-open").forEach((opener) => {
  const dialog = document.getElementById(opener.getAttribute("aria-controls"));
  const scrim = dialog.querySelector(".sheet__scrim");
  const panel = dialog.querySelector(".sheet__panel");
  const handle = dialog.querySelector(".sheet__handle");
  const parts = dialog.querySelectorAll(".sheet__title, .sheet__list li");
  let motion; // シートと暗幕を動かしているもの。つかんだら止める

  const hiddenY = () => panel.offsetHeight + 24; // 上端の罫線まで見えなくなる位置
  const fade = (y) => gsap.set(scrim, { opacity: 1 - (DIM_FOLLOW * y) / panel.offsetHeight });
  const run = (timeline) => {
    motion?.kill();
    motion = timeline.timeScale(SPEED);
  };

  function open() {
    dialog.showModal(); // 焦点が中へ移り、背面は操作できなくなる。閉じると焦点は opener へ戻る
    run(
      gsap
        .timeline()
        .fromTo(scrim, { opacity: 0 }, { opacity: 1, duration: 0.35, ease: "power1.out" }, 0)
        .fromTo(panel, { y: hiddenY() }, { y: 0, duration: 0.55, ease: "power4.out" }, 0)
    );
    // 一覧は上から順に浮かべる。つかんでも止めないよう、シートの動きとは別にしておく
    gsap
      .timeline({ delay: 0.12 / SPEED })
      .fromTo(parts, { opacity: 0, y: 14 }, { opacity: 1, y: 0, duration: 0.45, ease: "power3.out", stagger: 0.045, overwrite: true })
      .timeScale(SPEED);
  }

  // 閉じる動きが終わってから close() を呼ぶ(先に呼ぶと、その場で消える)
  function close() {
    run(
      gsap
        .timeline({ onComplete: () => dialog.close() })
        .to(panel, { y: hiddenY(), duration: 0.4, ease: "power2.out" }, 0)
        .to(scrim, { opacity: 0, duration: 0.4, ease: "power2.out" }, 0)
    );
  }

  // つまみを引く。シートは指の動いた量だけ下がり、上へは引けない
  let drag = null;
  handle.addEventListener("pointerdown", (event) => {
    handle.setPointerCapture(event.pointerId); // 指がつまみの外へ出ても追える
    motion?.kill(); // 開いている途中でも、今の位置からつかむ
    drag = { startY: event.clientY - gsap.getProperty(panel, "y"), last: event, speed: 0 };
  });
  handle.addEventListener("pointermove", (event) => {
    if (!drag) return;
    const y = Math.max(0, event.clientY - drag.startY);
    drag.speed = (event.clientY - drag.last.clientY) / Math.max(1, event.timeStamp - drag.last.timeStamp);
    drag.last = event;
    gsap.set(panel, { y });
    fade(y);
  });
  const release = (event) => {
    if (!drag) return;
    const y = gsap.getProperty(panel, "y");
    // 止まってから放したときは、払ったことにしない
    const flicked = drag.speed > FLICK && event.timeStamp - drag.last.timeStamp < 100;
    drag = null;
    if (y > panel.offsetHeight * CLOSE_AT || flicked) return close();
    run(gsap.timeline().to(panel, { y: 0, duration: 0.45, ease: "back.out(1.4)", onUpdate: () => fade(gsap.getProperty(panel, "y")) }));
  };
  handle.addEventListener("pointerup", release);
  handle.addEventListener("pointercancel", release);

  opener.addEventListener("click", open);
  scrim.addEventListener("click", close);
  // 操作を選んだら閉じる(案件では、ここで操作を実行する)
  dialog.querySelectorAll(".sheet__action").forEach((button) => button.addEventListener("click", close));
  // Esc は既定だとその場で閉じるので、いったん止めて同じ動きで閉じる
  dialog.addEventListener("cancel", (event) => {
    event.preventDefault();
    close();
  });
});

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

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