デモ
デモを読み込めませんでした。
仕様
- 用途
- オーバーレイ
- 動作
- 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 つで決めます。深さの割合を下げると軽く閉じ、上げると閉じにくくなります。戻すときに弾ませると少し上へ行き過ぎるので、シートの面を画面の下へ延ばしておき、下に地がのぞかないようにします。共有・並べ替え・絞り込み、曲やファイルの操作メニューなど、スマートフォンの画面に向いています。
コード
<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>
.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; }
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();
});
});