デモ
デモを読み込めませんでした。
仕様
- 用途
- オーバーレイ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
問い合わせの一覧で行を押すと、右から詳細のパネルが一覧に重なって開き、押した行の地が少し濃くなって左に線が付きます。開いたまま別の行を押すと、パネルは動かず、中身だけが上下に入れ替わります。下の行を押せば次の中身が下から、上の行なら上から入るので、一覧の中でどちらへ移ったのかが目で追えます。× か Esc を押すと、パネルは右へ抜けて閉じます。
開くのは 0.45 秒の power3.out、閉じるのは 0.35 秒の power2.in です。後ろを暗くしないので一覧は見えたまま押せ、行を替えるたびにパネルを閉じて出し直す形と違って、読んでいる所から目が離れません。入れ替えは、前の中身を 0.15 秒で 28px 逆の向きへ抜き、抜けきるころから次の中身を 0.29 秒で 28px 先から寄せます。
2 つの中身を同じ場所で重ねたままフェードすると、途中で文字が重なって読めません。前の中身が消えきる直前まで次を出さず、重なる時間を 0.02 秒ほどにとどめます。2 つの中身はグリッドの同じ升(grid-area)に重ね、入れ物のはみ出しを切ります。絶対配置で重ねると入れ物の高さが決まらず、送る 28px の分だけ下の返信のボタンの列にかかります。
行はボタンにし、選んでいる行に aria-expanded を付けます。一覧を押せたまま中身を替えるので、パネルはモーダルにせず、焦点も閉じ込めません。開いたときと入れ替えたときは件名へ焦点を移し、読み上げが新しい中身から読むようにします。Esc は一覧にいてもパネルの中にいても効き、閉じると開いた行へ焦点が戻ります。狭い画面でパネルが一覧を覆うときだけは、一覧に inert を付けて、隠れた行へ焦点が抜けないようにします。
送る量を大きくすると移った向きがはっきりし、0 にするとただのフェードになります。パネルの幅は CSS 変数で変えられます。動きを減らす設定では、開閉も入れ替えもほぼ一瞬にします。問い合わせや案件・タスクの一覧、受信箱、管理画面の注文一覧など、一覧を見比べながら 1 件ずつ中身を確かめる画面に向いています。
コード
<!-- 一覧の行はボタン。data-peek で、開く詳細の id を指す -->
<ul class="inbox">
<li><button class="inbox__row" type="button" aria-controls="peek" aria-expanded="false" data-peek="peek-1"><span class="chip" data-status="new">未対応</span><span class="inbox__subject">店舗サイトの見積もりについて</span><span class="inbox__from">山本 由紀</span><span class="inbox__date">9月29日</span></button></li>
<li><button class="inbox__row" type="button" aria-controls="peek" aria-expanded="false" data-peek="peek-2"><span class="chip" data-status="doing">対応中</span><span class="inbox__subject">採用ページの掲載内容の変更</span><span class="inbox__from">佐藤 健</span><span class="inbox__date">9月28日</span></button></li>
<li><button class="inbox__row" type="button" aria-controls="peek" aria-expanded="false" data-peek="peek-3"><span class="chip" data-status="new">未対応</span><span class="inbox__subject">請求書の再発行のお願い</span><span class="inbox__from">中村 綾</span><span class="inbox__date">9月27日</span></button></li>
<li><button class="inbox__row" type="button" aria-controls="peek" aria-expanded="false" data-peek="peek-4"><span class="chip" data-status="done">完了</span><span class="inbox__subject">打ち合わせ日程のご相談</span><span class="inbox__from">小林 大輔</span><span class="inbox__date">9月26日</span></button></li>
<li><button class="inbox__row" type="button" aria-controls="peek" aria-expanded="false" data-peek="peek-5"><span class="chip" data-status="doing">対応中</span><span class="inbox__subject">ロゴデータの受け渡し</span><span class="inbox__from">加藤 美咲</span><span class="inbox__date">9月25日</span></button></li>
</ul>
<!-- 一覧に重なる詳細。一覧を押せたまま中身を替えるので、モーダルにはしない(Esc と × で閉じる) -->
<aside class="peek" id="peek" aria-labelledby="peek-title" hidden>
<div class="peek__head">
<h2 class="peek__title" id="peek-title">問い合わせの詳細</h2>
<button class="peek__close" type="button" aria-label="詳細を閉じる">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18" /></svg>
</button>
</div>
<div class="peek__body">
<article class="peek__item" id="peek-1" hidden>
<span class="chip" data-status="new">未対応</span>
<h3 class="peek__subject" tabindex="-1">店舗サイトの見積もりについて</h3>
<p class="peek__from"><span class="peek__avatar" data-tone="slate" aria-hidden="true">山</span>山本 由紀<small>sample@higashiyuki.com・9月29日 10:24</small></p>
<p class="peek__text">来春に2店舗目を出すため、店舗サイトの作り直しを考えています。予約の機能を付けた場合の費用と、公開までの期間を教えてください。</p>
</article>
<article class="peek__item" id="peek-2" hidden>
<span class="chip" data-status="doing">対応中</span>
<h3 class="peek__subject" tabindex="-1">採用ページの掲載内容の変更</h3>
<p class="peek__from"><span class="peek__avatar" data-tone="sage" aria-hidden="true">佐</span>佐藤 健<small>sample@higashiyuki.com・9月28日 16:05</small></p>
<p class="peek__text">10月から募集する職種が増えるため、採用ページに求人を2つ足したいです。写真はこちらで用意します。</p>
</article>
<article class="peek__item" id="peek-3" hidden>
<span class="chip" data-status="new">未対応</span>
<h3 class="peek__subject" tabindex="-1">請求書の再発行のお願い</h3>
<p class="peek__from"><span class="peek__avatar" data-tone="greige" aria-hidden="true">中</span>中村 綾<small>sample@higashiyuki.com・9月27日 9:12</small></p>
<p class="peek__text">9月分の請求書の宛名を、部署名まで入れた形で出し直していただけますか。お手数をおかけします。</p>
</article>
<article class="peek__item" id="peek-4" hidden>
<span class="chip" data-status="done">完了</span>
<h3 class="peek__subject" tabindex="-1">打ち合わせ日程のご相談</h3>
<p class="peek__from"><span class="peek__avatar" data-tone="lavender" aria-hidden="true">小</span>小林 大輔<small>sample@higashiyuki.com・9月26日 13:40</small></p>
<p class="peek__text">次回の打ち合わせは、10月3日の午後か6日の午前でお願いできますか。オンラインでも構いません。</p>
</article>
<article class="peek__item" id="peek-5" hidden>
<span class="chip" data-status="doing">対応中</span>
<h3 class="peek__subject" tabindex="-1">ロゴデータの受け渡し</h3>
<p class="peek__from"><span class="peek__avatar" data-tone="rose" aria-hidden="true">加</span>加藤 美咲<small>sample@higashiyuki.com・9月25日 11:58</small></p>
<p class="peek__text">新しいロゴのデータを、PDFとPNGの両方でお送りします。名刺の刷り直しにも使う予定です。</p>
</article>
</div>
<div class="peek__foot">
<button class="peek__button peek__button--primary" type="button">返信する</button>
<button class="peek__button" type="button">担当を決める</button>
</div>
</aside>
.inbox, .peek {
--ink: #262626; /* 文字 */
--mute: #6b6b6b; /* 小さい補足の文字 */
--surface: #ffffff; /* パネルの地 */
--line: rgba(38, 38, 38, 0.12); /* 罫線 */
--mark: rgba(38, 38, 38, 0.06); /* 選んでいる行の地 */
--peek-width: 496px;
color: var(--ink);
}
.inbox { margin: 0; padding: 0; list-style: none; box-shadow: inset 0 1px 0 var(--line); }
/* 行は 1 つのボタン。選んでいる行は、地を少し濃くして左に線を引く */
.inbox__row {
display: grid;
grid-template-columns: 104px minmax(0, 1fr) 160px 96px;
align-items: center;
width: 100%; height: 72px;
padding: 0 16px; border: 0;
background: none;
box-shadow: inset 0 -1px 0 var(--line);
color: inherit; font: inherit; text-align: left;
cursor: pointer;
transition: background-color 0.2s, box-shadow 0.2s;
}
.inbox__row[aria-expanded="true"] { background: var(--mark); box-shadow: inset 3px 0 0 var(--ink), inset 0 -1px 0 var(--line); }
.inbox__row:focus-visible { outline: 2px solid var(--ink); outline-offset: -2px; }
.inbox__subject { overflow: hidden; font-size: 16px; font-weight: 600; letter-spacing: 0.02em; text-overflow: ellipsis; white-space: nowrap; }
.inbox__from, .inbox__date { font-size: 14px; font-variant-numeric: tabular-nums; }
/* 状態の札(中身の色を地に 20% 混ぜた色) */
.chip { display: inline-block; justify-self: start; height: 24px; padding: 0 10px; border-radius: 999px; font-size: 12px; font-weight: 700; line-height: 24px; letter-spacing: 0.06em; white-space: nowrap; }
.chip[data-status="new"] { background: #eee5e4; }
.chip[data-status="doing"] { background: #e2e6e9; }
.chip[data-status="done"] { background: #e4e8e4; }
/* 詳細のパネルは右に固定して一覧に重ねる。影は付けず、左の縁に罫線を引く */
.peek {
position: fixed;
top: 0; right: 0; z-index: 10;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
width: min(var(--peek-width), 100%); height: 100%;
background: var(--surface);
box-shadow: inset 1px 0 0 var(--line);
}
.peek[hidden], .peek__item[hidden] { display: none; }
.peek__head { display: flex; align-items: center; justify-content: space-between; height: 96px; padding: 0 32px; box-shadow: inset 0 -1px 0 var(--line); }
.peek__title { margin: 0; font-size: 14px; letter-spacing: 0.04em; }
/* アイコンだけのボタンは線の丸 */
.peek__close { display: grid; 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; }
.peek__close svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 3; stroke-linecap: round; }
/* 入れ替わる 2 つの中身を同じ場所に重ね、上下のはみ出しは切る */
.peek__body { display: grid; overflow: clip; }
.peek__item { grid-area: 1 / 1; padding: 32px; overflow-y: auto; }
.peek__subject { margin: 16px 0 24px; font-size: 22px; line-height: 1.3; letter-spacing: -0.01em; }
.peek__subject:focus { outline: none; }
.peek__from {
display: grid;
grid-template-columns: 48px 1fr;
column-gap: 16px;
margin: 0;
padding-bottom: 32px;
box-shadow: inset 0 -1px 0 var(--line);
font-size: 16px; font-weight: 600; letter-spacing: 0.02em;
}
.peek__from small { grid-column: 2; margin-top: 4px; color: var(--mute); font-size: 13px; font-weight: 400; letter-spacing: 0.04em; }
.peek__avatar { display: grid; grid-row: span 2; place-items: center; width: 48px; height: 48px; border-radius: 50%; color: #ffffff; font-size: 18px; font-weight: 700; }
.peek__avatar[data-tone="slate"] { background: #688297; }
.peek__avatar[data-tone="sage"] { background: #739374; }
.peek__avatar[data-tone="greige"] { background: #A28767; }
.peek__avatar[data-tone="lavender"] { background: #877fa1; }
.peek__avatar[data-tone="rose"] { background: #b27771; }
.peek__text { margin: 24px 0 0; font-size: 15px; line-height: 1.9; letter-spacing: 0.04em; word-break: auto-phrase; }
.peek__foot { display: flex; gap: 12px; padding: 24px 32px; box-shadow: inset 0 1px 0 var(--line); }
.peek__button { height: 48px; padding: 0 28px; border: 0; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--ink); background: none; color: var(--ink); font: inherit; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; cursor: pointer; }
.peek__button--primary { box-shadow: none; background: var(--ink); color: #ffffff; }
.peek button:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
@media (max-width: 640px) {
.inbox__row { grid-template-columns: 88px minmax(0, 1fr); }
.inbox__from, .inbox__date { display: none; }
}
@media (prefers-reduced-motion: reduce) { .inbox__row { transition-duration: 0.01s; } }
import { gsap } from "gsap";
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduce ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const SHIFT = 28; // 入れ替えで中身を上下へ送る量(px)
const panel = document.querySelector(".peek");
const list = document.querySelector(".inbox");
const rows = [...list.querySelectorAll(".inbox__row")];
let active = -1; // 選んでいる行の番号(閉じているあいだは -1)
let current = null; // 見せている詳細
let slide, swapping;
const itemOf = (i) => document.getElementById(rows[i].dataset.peek);
// 読み上げが新しい中身から読むよう、件名へ焦点を移す(下へ延びた一覧がスクロールしないように)
const focusItem = (item) => item.querySelector(".peek__subject").focus({ preventScroll: true });
function mark(i) {
rows.forEach((row, k) => row.setAttribute("aria-expanded", String(k === i)));
active = i;
}
function open(i) {
swapping?.progress(1);
current?.setAttribute("hidden", "");
current = itemOf(i);
current.hidden = false;
panel.hidden = false;
list.inert = panel.offsetWidth >= window.innerWidth; // 狭い画面で一覧を覆うときは、隠れた行へ焦点を抜けさせない
slide?.kill();
slide = gsap.fromTo(panel, { xPercent: 100 }, { xPercent: 0, duration: 0.45, ease: "power3.out" }).timeScale(SPEED);
mark(i);
focusItem(current);
}
// 前の中身を逆の向きへ抜き、抜けきるころに次の中身を入れる。パネルそのものは動かさない
function swap(i) {
const dir = Math.sign(i - active); // 下の行なら 1(下から入る)、上の行なら -1
const prev = current;
const next = itemOf(i);
swapping?.progress(1).kill();
current = next;
next.hidden = false;
swapping = gsap.timeline({ onComplete: () => { prev.hidden = true; gsap.set(prev, { clearProps: "opacity,transform" }); } })
.to(prev, { y: -SHIFT * dir, opacity: 0, duration: 0.15, ease: "power2.in" }, 0)
.fromTo(next, { y: SHIFT * dir, opacity: 0 }, { y: 0, opacity: 1, duration: 0.29, ease: "power3.out" }, 0.13)
.timeScale(SPEED);
mark(i);
focusItem(next);
}
function close() {
if (active < 0) return;
const row = rows[active];
mark(-1);
slide?.kill();
slide = gsap.to(panel, { xPercent: 100, duration: 0.35, ease: "power2.in", onComplete: () => (panel.hidden = true) }).timeScale(SPEED);
list.inert = false;
row.focus(); // 閉じたら、開いた行へ焦点を戻す
}
rows.forEach((row, i) => {
row.addEventListener("click", () => {
if (active < 0) open(i);
else if (i !== active) swap(i);
});
});
panel.querySelector(".peek__close").addEventListener("click", close);
// 一覧に焦点があっても、パネルの中にあっても、Esc で閉じる
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && active >= 0) close();
});