デモ
デモを読み込めませんでした。
仕様
- 用途
- ボタン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
「下書き保存|▾」の分割ボタンです。▾ を押すと、▾ の丸い端がそのまま下へ流れ出すように形を変え、白いパネルになって操作の候補が並びます。候補を選ぶとパネルは ▾ へ吸い込まれるように戻り、左の文言が上へ送られて選んだ操作に替わり、ボタンの幅も新しい文言に合わせて変わります。右端は止めたままなので、▾ の位置は動きません。
形の変化は、▾ の上端からパネルの下端までを覆う 1 枚の箱に clip-path の inset をかけ、上・下・左の 3 辺と角の丸み(24px → 4px)を補間して作ります。開くのは 0.45 秒の power2.inOut、閉じるのは 0.3 秒の power2.in です。地は進みの 35% までは ▾ と同じ墨色のまま流れ出し、85% までに白へ移ります。候補の文字は動かさず、広がる形の窓から見えてくるだけにし、ほぼ開ききってから 0.18 秒で濃くします。文言は 2 つを高さの 3/4 だけ同時に送るので、途中では前後の文言が半分ずつ見え、途切れません。
clip-path の値を GSAP で直接補間すると、ブラウザが計算した値を縮めて返すため、角の丸みの数を取り違えます。4 辺と丸みは CSS 変数 --open 1 つから calc() で組み、GSAP はその変数だけを 0 → 1 と動かします。パネルの箱は ▾ の上端から始まるので、ボタンより下に重ねます。ボタンの後ろから出てくるように見え、ボタンのクリックも奪いません。閉じたら hidden にして、タブ移動と読み上げから外します。
▾ は aria-haspopup と aria-expanded を持つボタンで、候補は role が menuitemradio のボタンです(選んでいるものは aria-checked)。開くと選んでいる候補に焦点を移し、↑↓・Home・End で移り、Enter で選び、Esc で ▾ へ戻ります。選んだら、見えない role="status" の領域に切り替えたことを書きます。左のボタンは、選んでいる操作を split-action の出来事で知らせるので、そこで保存や公開を行います。動きを減らす設定では、形を変えずに一瞬で開け閉めします。
並んだ選択肢が上から順に降りてくるセレクトボックスや、押すたびに状態が入れ替わって幅が変わるボタンとは違い、ボタンの一部がそのまま候補のパネルに変わる形の変化が見どころです。選ぶとボタンの既定の操作そのものが替わるので、よく使う操作を次から 1 回で押せます。記事や商品の保存と公開、メールの送信と予約送信、書き出しの形式の選択などに向いています。
コード
<!-- 左は選んだ操作を行うボタン、右の ▾ は操作の候補を開くボタン。候補の文言と説明は HTML で書き替える -->
<div class="editor-bar">
<p class="editor-bar__title">記事の編集</p>
<div class="split">
<button class="split__main" type="button" data-action="draft"><span class="split__label">下書き保存</span></button>
<button class="split__toggle" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="split-menu" aria-label="ほかの操作を選ぶ">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>
</button>
<div class="split__panel" hidden>
<div class="split__menu" id="split-menu" role="menu" aria-label="操作の候補">
<button class="split__item" type="button" role="menuitemradio" aria-checked="false" data-action="publish" tabindex="-1">
<span class="split__title">公開する</span><span class="split__note">すぐにサイトに公開します</span>
<svg class="split__check" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>
</button>
<button class="split__item" type="button" role="menuitemradio" aria-checked="false" data-action="schedule" tabindex="-1">
<span class="split__title">予約投稿</span><span class="split__note">日時を決めて公開します</span>
<svg class="split__check" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>
</button>
<button class="split__item" type="button" role="menuitemradio" aria-checked="true" data-action="draft" tabindex="-1">
<span class="split__title">下書き保存</span><span class="split__note">公開せずに保存します</span>
<svg class="split__check" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>
</button>
</div>
</div>
<span class="split__status" role="status" data-template="ボタンの操作を「{label}」に切り替えました"></span>
</div>
</div>
.editor-bar { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
.editor-bar__title { margin: 0; font-size: 20px; font-weight: 700; }
.split {
--ink: #262626; /* ボタンの地 */
--surface: #ffffff; /* パネルの地 */
--line: rgba(38, 38, 38, 0.12);
--hover: #f2f1ed; /* 候補に乗ったときの地 */
--note: #6b6b6b; /* 候補の説明 */
--height: 48px;
--toggle: 48px; /* ▾ の幅 */
--gap: 8px; /* ボタンとパネルの間 */
--menu-width: 280px;
--open: 0; /* 0 = ▾ の形、1 = パネルの形。JS が動かす */
position: relative;
display: inline-flex;
flex: none;
height: var(--height);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
}
.split__main,
.split__toggle {
position: relative;
z-index: 1; /* パネルはボタンの下から流れ出る */
height: 100%;
border: 0;
background: var(--ink);
color: #ffffff;
font: inherit;
cursor: pointer;
}
/* 文言を重ねて縦に送る窓。幅は JS が文言に合わせて動かす */
.split__main { display: grid; padding: 0 24px; border-radius: 999px 0 0 999px; overflow: hidden; white-space: nowrap; }
.split__label { grid-area: 1 / 1; justify-self: center; line-height: var(--height); }
.split__toggle {
display: grid;
place-items: center;
width: var(--toggle);
border-radius: 0 999px 999px 0;
}
.split__toggle::before { content: ""; position: absolute; left: 0; top: 25%; bottom: 25%; width: 1px; background: rgb(255 255 255 / 0.24); }
.split__toggle svg { rotate: calc(var(--open) * 180deg); }
.split svg { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.split__main:focus-visible, .split__toggle:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
/* ▾ の上端からパネルの下端までを覆う箱。4 辺と角の丸みを --open 1 つで決め、▾ の形からパネルの形へ変える */
.split__panel {
position: absolute;
top: 0;
right: 0;
width: var(--menu-width);
padding-top: calc(var(--height) + var(--gap));
background: color-mix(in srgb, var(--surface) calc(clamp(0, (var(--open) - 0.35) * 2, 1) * 100%), var(--ink));
clip-path: inset(calc(var(--open) * (var(--height) + var(--gap))) 0 calc((1 - var(--open)) * (100% - var(--height))) calc((1 - var(--open)) * (100% - var(--toggle))) round calc(var(--height) / 2 + var(--open) * (4px - var(--height) / 2)));
}
.split__menu { display: grid; padding: 8px; border-radius: 4px; box-shadow: inset 0 0 0 1px var(--line); opacity: 0; }
.split__item {
display: grid;
grid-template-columns: 1fr 20px;
column-gap: 16px;
align-items: center;
padding: 10px 16px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--ink);
font: inherit;
text-align: left;
cursor: pointer;
}
.split__item:hover, .split__item:focus-visible { background: var(--hover); outline: none; }
.split__item:focus-visible { box-shadow: inset 0 0 0 2px var(--ink); }
.split__note { grid-column: 1; font-size: 13px; font-weight: 400; color: var(--note); }
.split__check { grid-column: 2; grid-row: 1 / 3; }
.split__item[aria-checked="false"] .split__check { visibility: hidden; }
.split__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 0 : 1; // 動きを減らす設定では、形を変えずに一瞬で開け閉めする
document.querySelectorAll(".split").forEach((split) => {
const main = split.querySelector(".split__main");
const toggle = split.querySelector(".split__toggle");
const panel = split.querySelector(".split__panel");
const menu = split.querySelector(".split__menu");
const items = [...split.querySelectorAll(".split__item")];
const status = split.querySelector(".split__status");
let shape = null;
let swap = null;
const isOpen = () => toggle.getAttribute("aria-expanded") === "true";
// 開くと、選んでいる候補(↑ で開いたときは最後の候補)に焦点を移す
const show = (index) => {
toggle.setAttribute("aria-expanded", "true");
panel.hidden = false;
shape?.kill();
shape = gsap.timeline()
.to(split, { "--open": 1, duration: 0.45 * SPEED, ease: "power2.inOut" }, 0)
.to(menu, { opacity: 1, duration: 0.18 * SPEED, ease: "none" }, 0.26 * SPEED);
const checked = items.findIndex((item) => item.getAttribute("aria-checked") === "true");
items[index ?? Math.max(0, checked)].focus({ preventScroll: true });
};
const close = (returnFocus) => {
if (!isOpen()) return;
toggle.setAttribute("aria-expanded", "false");
if (returnFocus) toggle.focus({ preventScroll: true });
shape?.kill();
shape = gsap.timeline({ onComplete: () => (panel.hidden = true) })
.to(menu, { opacity: 0, duration: 0.1 * SPEED, ease: "none" }, 0)
.to(split, { "--open": 0, duration: 0.3 * SPEED, ease: "power2.in" }, 0.03 * SPEED);
};
// 今の文言を上へ、新しい文言を下から、1 本の帯のように同時に送る。幅も新しい文言に合わせる
const push = (text) => {
swap?.progress(1);
const old = main.lastElementChild;
if (old.textContent === text) return;
const from = main.offsetWidth;
const next = Object.assign(document.createElement("span"), { className: "split__label", textContent: text });
old.setAttribute("aria-hidden", "true");
main.append(next);
const pad = parseFloat(getComputedStyle(main).paddingLeft) * 2;
const to = next.getBoundingClientRect().width + pad;
swap = gsap.timeline({ delay: 0.3 * SPEED, onComplete: () => { old.remove(); gsap.set(main, { clearProps: "width" }); } })
.fromTo(old, { yPercent: 0 }, { yPercent: -75, duration: 0.45 * SPEED, ease: "power3.inOut" }, 0)
.fromTo(next, { yPercent: 75 }, { yPercent: 0, duration: 0.45 * SPEED, ease: "power3.inOut" }, 0)
.fromTo(main, { width: from }, { width: to, duration: 0.45 * SPEED, ease: "power3.inOut" }, 0);
};
const choose = (item) => {
const label = item.querySelector(".split__title").textContent;
const changed = item.getAttribute("aria-checked") !== "true";
items.forEach((other) => other.setAttribute("aria-checked", String(other === item)));
main.dataset.action = item.dataset.action;
close(true);
if (!changed) return;
push(label);
status.textContent = status.dataset.template.replace("{label}", label);
};
toggle.addEventListener("click", () => (isOpen() ? close(true) : show()));
toggle.addEventListener("keydown", (event) => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault();
show(event.key === "ArrowUp" ? items.length - 1 : 0);
});
items.forEach((item) => item.addEventListener("click", () => choose(item)));
// 候補の中は ↑↓・Home・End で移り、Esc で ▾ へ戻る。Tab で外へ出たら閉じる
menu.addEventListener("keydown", (event) => {
const i = items.indexOf(document.activeElement);
const step = { ArrowDown: 1, ArrowUp: -1 }[event.key];
const jump = { Home: 0, End: items.length - 1 }[event.key];
if (step) items[(i + step + items.length) % items.length].focus();
else if (jump !== undefined) items[jump].focus();
else if (event.key === "Escape") close(true);
else if (event.key === "Tab") return close(false);
else return;
event.preventDefault();
});
document.addEventListener("pointerdown", (event) => {
if (isOpen() && !split.contains(event.target)) close(false);
});
// 左のボタンは、選んでいる操作を知らせる。ここで受けて、保存や公開を行う
main.addEventListener("click", () => {
split.dispatchEvent(new CustomEvent("split-action", { bubbles: true, detail: main.dataset.action }));
});
});