デモ
デモを読み込めませんでした。
仕様
- 用途
- オーバーレイ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
「設定を開く」を押すと、後ろの画面が暗くなり、シートが下から上がります。シートの中の項目を押すと、次の階層のシートが下から重なって滑り込み、前のシートは少し上へずれて陰が濃くなります。「戻る」を押すと手前のシートだけが下へ滑って抜け、奥のシートが元の位置へ戻ります。1枚を出し入れするだけのシートと違い、たどってきた画面が段として残るので、いま何段目にいるかが見えます。
上がるのは0.5秒の power4.out で、出だしが速く、残りの時間をかけて止まります。抜けるのは0.4秒の power2.in で、逆に加速しながら下へ消えます。段ごとに上へずらす量は14pxです。これより小さいと重なりが読めず、大きくすると奥のシートの中身が手前の上にのぞきます。陰は1段ごとに0.3ずつ濃くし、奥の文字が読めない濃さまで落とします。
段の位置は「自分より手前にあるシートの数」で決めますが、数え方を整数にすると、次のシートが動き出した瞬間に奥の段がかくんと飛びます。手前のシートの進み具合(0〜1)をそのまま足して数えると、滑り込みの途中でも段が連続に動きます。シートは画面の下端の外へ40px延ばしておきます。延ばさないと、上へずれた分だけ下に地がのぞきます。
シートはダイアログの要素に入れて showModal で開きます。焦点の閉じ込めと、閉じたあとの焦点の戻しを任せられます。覆われた段には inert を付けて、タブ移動が奥のシートへ抜けないようにします。Esc は既定だとその場で消えてしまうので、cancel を止めて1段ずつ戻します。開いた段の先頭のボタンへ焦点を移すと、キーボードだけでも段をたどれます。
ずらす量と陰の濃さで、段の厚みが変わります。動きを減らす設定では、出入りをほぼ一瞬にします。スマートフォンの設定画面、絞り込みの条件を選ぶ手順、注文の途中で開く住所や支払い方法の選択のように、階層をたどりながら前の画面を見失いたくない場面に向いています。
コード
<!-- ページ側のボタン。data-sheet-open に、開くシートの id を持たせる -->
<button class="sheet-open" type="button" data-sheet-open="sheet-settings">設定を開く</button>
<!-- showModal() で開く <dialog> には、焦点の閉じ込め・背面の締め出し・Esc・焦点の戻しが付いてくる -->
<dialog class="sheets" aria-label="設定">
<div class="sheets__scrim" data-sheet-scrim></div>
<section class="sheet" id="sheet-settings" aria-labelledby="sheet-settings-title" hidden>
<div class="sheet__head">
<button class="sheet__icon" type="button" aria-label="設定を閉じる" data-sheet-back>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18" /></svg>
</button>
<h2 class="sheet__title" id="sheet-settings-title">設定</h2>
</div>
<ul class="sheet__rows">
<li class="sheet__row"><span>アカウント</span><span class="sheet__value">sample@higashiyuki.com</span></li>
<li>
<button class="sheet__row sheet__row--link" type="button" data-sheet-open="sheet-notify">
<span>通知</span><span class="sheet__value">オン</span>
<svg class="sheet__chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6" /></svg>
</button>
</li>
<li class="sheet__row"><span>テーマ</span><span class="sheet__value">ダーク</span></li>
<li class="sheet__row"><span>プライバシー</span><span class="sheet__value">標準</span></li>
</ul>
<div class="sheet__shade"></div>
</section>
<section class="sheet" id="sheet-notify" aria-labelledby="sheet-notify-title" hidden>
<div class="sheet__head">
<button class="sheet__icon" type="button" aria-label="前の画面に戻る" data-sheet-back>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 6l-6 6 6 6" /></svg>
</button>
<h2 class="sheet__title" id="sheet-notify-title">通知</h2>
</div>
<ul class="sheet__rows">
<li>
<button class="sheet__row sheet__row--link" type="button" data-sheet-open="sheet-mail">
<span>メール通知</span><span class="sheet__value">1日1回</span>
<svg class="sheet__chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6" /></svg>
</button>
</li>
<li class="sheet__row"><span>プッシュ通知</span><span class="sheet__value">オン</span></li>
<li class="sheet__row"><span>おすすめのお知らせ</span><span class="sheet__value">オフ</span></li>
<li class="sheet__row"><span>サウンド</span><span class="sheet__value">オン</span></li>
</ul>
<div class="sheet__shade"></div>
</section>
<section class="sheet" id="sheet-mail" aria-labelledby="sheet-mail-title" hidden>
<div class="sheet__head">
<button class="sheet__icon" type="button" aria-label="前の画面に戻る" data-sheet-back>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 6l-6 6 6 6" /></svg>
</button>
<h2 class="sheet__title" id="sheet-mail-title">メール通知</h2>
</div>
<!-- 選択はラジオボタン。矢印キーで選べ、選んだ札の見た目は :checked で切り替わる -->
<fieldset class="sheet__opts">
<legend>メールを送るタイミング</legend>
<label class="sheet__opt"><input type="radio" name="digest" value="now" checked><span>すぐに送る</span>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 13l4 4L19 7" /></svg>
</label>
<label class="sheet__opt"><input type="radio" name="digest" value="daily"><span>1日1回まとめる</span>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 13l4 4L19 7" /></svg>
</label>
<label class="sheet__opt"><input type="radio" name="digest" value="off"><span>送らない</span>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 13l4 4L19 7" /></svg>
</label>
</fieldset>
<p class="sheet__note">まとめて送る設定にすると、その日のお知らせが1通にまとまります。</p>
<div class="sheet__shade"></div>
</section>
</dialog>
.sheets {
--ink: #eeece6; /* 文字 */
--mute: #a8a6a0; /* 小さい補足の文字 */
--surface: #262626; /* シートの地 */
--line: rgba(238, 236, 230, 0.14); /* 区切りの罫線 */
--scrim: rgba(0, 0, 0, 0.62); /* 後ろの画面を暗くする幕 */
/* dialog を画面いっぱいに広げる(既定の余白・枠・最大の大きさを外す) */
width: 100%; max-width: none; height: 100%; max-height: none;
margin: 0; padding: 0; border: 0; background: none;
color: var(--ink); overflow: hidden;
}
.sheets::backdrop { background: none; }
.sheets__scrim { position: absolute; inset: 0; background-color: var(--scrim); }
/* 開いているあいだは後ろの画面を止め、スクロールバーの幅を先に空けておく(ページが横にずれない) */
html:has(.sheets[open]) { overflow: hidden; scrollbar-gutter: stable; }
[hidden] { display: none; }
/* シートは下端の外へ 40px 延ばしておく(段が上へずれても、下に地がのぞかない) */
.sheet {
position: absolute;
inset: auto 0 -40px;
width: min(560px, 100%);
height: 424px;
margin: 0 auto;
padding-bottom: 40px; border-radius: 16px 16px 0 0;
background-color: var(--surface);
box-shadow: inset 0 1px 0 var(--line);
overflow: hidden;
}
/* 奥の段に重ねる陰。GSAP が濃さを 0〜0.6 のあいだで動かす */
.sheet__shade { position: absolute; inset: 0; background-color: #111111; opacity: 0; pointer-events: none; }
.sheet__head { display: flex; align-items: center; gap: 16px; height: 48px; padding: 28px 24px; box-shadow: inset 0 -1px 0 var(--line); }
.sheet__title { margin: 0; font-size: 20px; line-height: 1; letter-spacing: -0.01em; }
/* アイコンだけのボタンは線の丸 */
.sheet__icon {
display: grid;
flex: none;
place-items: center;
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);
cursor: pointer;
}
.sheet__icon svg { width: 16px; height: 16px; }
.sheet__icon svg, .sheet__chevron, .sheet__opt svg { fill: none; stroke: currentColor; stroke-width: 2.8; stroke-linecap: round; stroke-linejoin: round; }
.sheet__rows { margin: 0; padding: 0; list-style: none; }
/* 行は左に名前、右に今の値。次の段を開く行だけがボタンで、右に > が付く */
.sheet__row {
display: flex;
align-items: center;
gap: 16px;
box-sizing: border-box; /* li は既定が content-box。付けないと左右の余白の分だけ横へはみ出す */
width: 100%; height: 64px;
padding: 0 30px 0 32px; border: 0; background: none;
color: var(--ink); font: inherit; font-size: 16px; font-weight: 600;
text-align: left;
}
.sheet__row--link { cursor: pointer; }
.sheet__value { margin-left: auto; color: var(--mute); font-size: 13px; font-weight: 400; letter-spacing: 0.04em; }
.sheet__chevron { flex: none; width: 18px; height: 18px; color: #898989; }
.sheet__opts { margin: 4px 16px; padding: 0; border: 0; }
.sheet__opts legend { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
/* 選んだ札は明暗を反転する。切り替えは CSS の transition に任せる */
.sheet__opt {
display: flex;
align-items: center;
height: 56px;
padding: 0 28px 0 32px; border-radius: 12px;
font-size: 16px; font-weight: 600;
cursor: pointer;
transition: background-color 0.22s, color 0.22s;
}
.sheet__opt input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.sheet__opt svg { width: 18px; height: 18px; margin-left: auto; opacity: 0; transition: opacity 0.22s; }
.sheet__opt:has(:checked) { background-color: var(--ink); color: #111111; }
.sheet__opt:has(:checked) svg { opacity: 1; }
.sheet__note { margin: 12px 32px; color: var(--mute); font-size: 13px; line-height: 1.7; letter-spacing: 0.04em; word-break: auto-phrase; }
.sheets :focus-visible, .sheet__opt:has(:focus-visible) { outline: 2px solid var(--ink); outline-offset: -4px; }
.sheet-open { height: 48px; padding: 0 32px; border: 0; border-radius: 999px; background-color: #eeece6; color: #111111; font: inherit; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; cursor: pointer; }
@media (prefers-reduced-motion: reduce) { .sheet__opt, .sheet__opt svg { transition-duration: 0.01s; } }
import { gsap } from "gsap";
const PEEK = 14; // 1 段奥へ下がるごとに上へずらす量(style.css の --peek と同じ)
const SHADE = 0.3; // 1 段ごとに濃くする陰
const TAIL = 40; // シートを下端の外へ延ばしてある分
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduce ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const stack = document.querySelector(".sheets");
const scrim = stack.querySelector(".sheets__scrim");
const open = []; // 今開いているシート(最後が手前)
let motion = null;
const run = (timeline) => {
motion?.progress(1).kill(); // 前の動きは終わった形にしてから次へ
motion = timeline.timeScale(SPEED);
return motion;
};
const below = (sheet) => sheet.offsetHeight + TAIL; // 下端の外まで抜ける位置
const shadeOf = (sheet) => sheet.querySelector(".sheet__shade");
// preventScroll を付ける。付けないと、下へ延ばしたシートを見せようと入れ物ごとスクロールする
const focusIn = (sheet) => sheet.querySelector("button, input")?.focus({ preventScroll: true });
// 段ごとの位置と陰。奥ほど上へずらして濃くし、焦点は手前のシートの中だけに留める
function layout(tl) {
open.forEach((sheet, i) => {
const depth = open.length - 1 - i;
tl.to(sheet, { y: -PEEK * depth, duration: 0.5, ease: "power4.out", force3D: false }, 0)
.to(shadeOf(sheet), { opacity: SHADE * depth, duration: 0.5, ease: "power2.out" }, 0);
sheet.inert = depth > 0;
});
}
function push(sheet) {
if (!sheet || open.includes(sheet)) return;
const tl = run(gsap.timeline());
if (!stack.open) {
stack.showModal(); // 焦点の閉じ込め・背面の締め出し・閉じたあとの焦点の戻しは <dialog> に任せる
tl.fromTo(scrim, { opacity: 0 }, { opacity: 1, duration: 0.35, ease: "power1.out" }, 0);
}
sheet.hidden = false; // hidden のままでは高さが 0 になり、抜ける位置を測れない
open.push(sheet);
tl.fromTo(sheet, { y: below(sheet) }, { y: 0, duration: 0.5, ease: "power4.out", force3D: false }, 0);
layout(tl);
focusIn(sheet);
}
// all が真なら全部を、そうでなければ手前の 1 段だけを下へ抜く
function pop(all) {
if (!open.length) return;
const tl = run(gsap.timeline());
const going = all ? open.splice(0).reverse() : [open.pop()];
going.forEach((sheet, i) => {
tl.to(sheet, { y: below(sheet), duration: 0.4, ease: "power2.in", force3D: false, onComplete: () => hide(sheet) }, i * 0.06)
.to(shadeOf(sheet), { opacity: 0, duration: 0.3, ease: "none" }, i * 0.06);
});
layout(tl);
if (open.length) focusIn(open[open.length - 1]);
else tl.to(scrim, { opacity: 0, duration: 0.35, ease: "none" }, 0).add(() => stack.close());
}
function hide(sheet) {
sheet.hidden = true;
sheet.inert = false;
}
document.querySelectorAll("[data-sheet-open]").forEach((button) => {
button.addEventListener("click", () => push(document.getElementById(button.dataset.sheetOpen)));
});
stack.querySelectorAll("[data-sheet-back]").forEach((button) => button.addEventListener("click", () => pop()));
scrim.addEventListener("click", () => pop(true));
// Esc は既定だとその場で閉じるので、いったん止めて 1 段ずつ戻す
stack.addEventListener("cancel", (event) => {
event.preventDefault();
pop();
});
// Esc を続けて押すと、動きを待たずに閉じることがある。残った段と幕はここで戻す
stack.addEventListener("close", () => {
open.splice(0).forEach(hide);
gsap.set(scrim, { clearProps: "opacity" });
});