デモ
デモを読み込めませんでした。
仕様
- 用途
- ナビゲーション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
折りたたみボタンを押すと、サイドバーの幅が縮んでアイコンだけが残り、右の本文がその分だけ広がります。サイドバーと本文を横に並べ、本文には残りの幅を与えてあるので、サイドバーの width を補間するだけで、カードやグラフも自然に伸び縮みします。ラベルや見出しを先に消し、細くなってから、見出しは短い線に、件数はアイコンの右上の点に置き換えます。
幅は 256px から 80px へ、0.55 秒の power3.inOut で縮めます。ラベルは幅より 0.08 秒早く、0.2 秒で左へ 8px 抜きながら上から順に消すので、縮む途中で文字が切れて見えません。開くのは 1.2 倍速の逆再生で、幅が戻ってからラベルが出ます。ボタンの矢印は 180° 回して向きを変えます。
縮めるのに scale を使うと、文字とアイコンまで潰れます。幅を補間し、中身は overflow で切った枠に入れて、折り返さないようにします。項目の左の余白を「畳んだ幅の半分 − 余白 − アイコンの半分」にしておくと、アイコンは開いても閉じても同じ位置にあり、動かさずに済みます。ボタンだけは枠の外に出し、サイドバーの右の縁にまたがらせます。
ラベルは opacity だけで消します。見えなくなっても要素は残るので、畳んだあともリンクの名前として読み上げに届きます。畳んでいる間はアイコンに乗せると名前が出るようにし、ボタンは名前を変えずに aria-expanded だけを切り替えます。狭い画面では畳んだ状態から始め、広げたサイドバーは本文に重ねます。
開いた幅と畳んだ幅は、CSS の変数で変えられます。開閉の状態を localStorage に残しておくと、次に開いたときもそろいます。管理画面やダッシュボード、メールやチャットのアプリのように、項目が多く、本文を広く使いたい画面に向いています。
コード
<!-- サイドバーと本文を横に並べる。本文は残りの幅なので、サイドバーが縮むと同じだけ広がる -->
<div class="app">
<div class="sidebar" id="app-sidebar">
<!-- 中身はこの枠で切る。畳んでもラベルは opacity だけで消すので、リンクの名前として読み上げに残る -->
<div class="sidebar__inner">
<p class="sidebar__logo sidebar__fade">Hi! I'm HIGASHIYUKI®</p>
<button class="sidebar__search" type="button" aria-keyshortcuts="Meta+K">
<svg class="sidebar__icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="6.5" /><path d="M16 16l4 4" /></svg>
<span class="sidebar__fade">検索</span>
<kbd class="sidebar__kbd sidebar__fade" aria-hidden="true">⌘K</kbd>
</button>
<nav aria-label="メインメニュー">
<!-- 見出しは、畳むと短い線に置き換わる -->
<h2 class="sidebar__heading"><span class="sidebar__fade">メニュー</span><span class="sidebar__rule" aria-hidden="true"></span></h2>
<ul class="sidebar__list">
<li><a class="sidebar__link" href="/dashboard/" aria-current="page">
<svg class="sidebar__icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="3.5" width="7" height="7" rx="1.8" /><rect x="13.5" y="3.5" width="7" height="7" rx="1.8" /><rect x="3.5" y="13.5" width="7" height="7" rx="1.8" /><rect x="13.5" y="13.5" width="7" height="7" rx="1.8" /></svg>
<span class="sidebar__fade">ダッシュボード</span></a></li>
<li><a class="sidebar__link" href="/analytics/">
<svg class="sidebar__icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h16M7.5 16v-5M12 16V7M16.5 16V9.5" /></svg>
<span class="sidebar__fade">アクセス解析</span></a></li>
<li><a class="sidebar__link" href="/projects/">
<svg class="sidebar__icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3.5 7.5a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2v-10z" /></svg>
<span class="sidebar__fade">プロジェクト</span></a></li>
<li><a class="sidebar__link" href="/messages/">
<!-- 畳むと、件数の代わりにアイコンの右上へ点を出す -->
<span class="sidebar__glyph"><svg class="sidebar__icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 13l2.5-7.5h11L20 13v5a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 18v-5z" /><path d="M4 13h4.5l1.5 2.5h4l1.5-2.5H20" /></svg><span class="sidebar__dot" aria-hidden="true"></span></span>
<span class="sidebar__fade">メッセージ</span><span class="sidebar__badge sidebar__fade">12</span></a></li>
<li><a class="sidebar__link" href="/calendar/">
<svg class="sidebar__icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5" width="17" height="15" rx="2.5" /><path d="M3.5 10h17M8 3v4M16 3v4" /></svg>
<span class="sidebar__fade">カレンダー</span></a></li>
</ul>
<h2 class="sidebar__heading"><span class="sidebar__fade">チーム</span><span class="sidebar__rule" aria-hidden="true"></span></h2>
<ul class="sidebar__list">
<li><a class="sidebar__link" href="/members/">
<svg class="sidebar__icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8.5" r="3.5" /><path d="M2.5 19.5c.8-3.3 3.4-5 6.5-5s5.7 1.7 6.5 5M16 5.2a3.5 3.5 0 0 1 0 6.6M18 14.8c1.9.7 3.1 2.3 3.5 4.7" /></svg>
<span class="sidebar__fade">メンバー</span></a></li>
<li><a class="sidebar__link" href="/files/">
<svg class="sidebar__icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3.5H7a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8.5l-5-5z" /><path d="M14 3.5v5h5M9 13h6M9 16.5h4" /></svg>
<span class="sidebar__fade">ファイル</span></a></li>
</ul>
</nav>
<!-- 畳んでも顔の丸は残るので、アカウントのページへのリンクにしておく -->
<a class="sidebar__user" href="/account/">
<span class="sidebar__avatar" aria-hidden="true">佐</span>
<span class="sidebar__who sidebar__fade"><b>佐藤 美咲</b><small>sample@higashiyuki.com</small></span>
<svg class="sidebar__more sidebar__fade" viewBox="0 0 24 24" aria-hidden="true"><circle cx="5.5" cy="12" r="1.2" /><circle cx="12" cy="12" r="1.2" /><circle cx="18.5" cy="12" r="1.2" /></svg>
</a>
</div>
<!-- 名前は変えず、開いているかどうかは aria-expanded だけで伝える -->
<button class="sidebar__toggle" type="button" aria-expanded="true" aria-controls="app-sidebar" aria-label="サイドバー">
<svg class="sidebar__chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M14.5 6l-6 6 6 6" /></svg>
</button>
</div>
<main class="app__main">
<p class="app__crumb">ホーム / ダッシュボード</p>
<h1 class="app__title">おはようございます、美咲さん</h1>
<ul class="app__cards">
<li><span>売上</span><strong>¥4,829,000</strong></li>
<li><span>アクティブユーザー</span><strong>2,841人</strong></li>
<li><span>解約率</span><strong>1.9%</strong></li>
</ul>
</main>
</div>
.app {
--ink: #262626; /* 文字・選んだ項目・件数の地 */
--idle: #6b6b6b; /* 選んでいない項目と補足 */
--ground: #f2f1ed; /* ページの地・検索欄・選んだ項目の地 */
--surface: #ffffff; /* サイドバーとカードの面 */
--line: rgba(38, 38, 38, 0.12); /* 罫線 */
--avatar: linear-gradient(135deg, #d2b999, #b27771); /* 顔写真の代わり(グレージュ → くすみローズ) */
--expanded: 256px; /* サイドバーの幅。JS がこの 2 つの間で補間する(JS が数として読むので px で書く) */
--collapsed: 80px;
display: flex;
min-height: 100vh;
background-color: var(--ground);
color: var(--ink);
}
.sidebar {
--center: calc(var(--collapsed) / 2 - 16px); /* 中身の左端からアイコンの中心まで。どちらの幅でもアイコンは動かない */
position: sticky;
top: 0;
z-index: 1;
flex: none;
width: var(--expanded);
height: 100vh;
box-sizing: border-box;
border-right: 1px solid var(--line);
background-color: var(--surface);
}
/* 中身はこの枠で切る。折りたたみボタンだけを枠の外(右の縁)に置く */
.sidebar__inner { position: absolute; inset: 0; display: flex; flex-direction: column; box-sizing: border-box; padding: 0 16px 16px; overflow: hidden; white-space: nowrap; }
/* ロゴは狭い所に収まるよう 15px にする(18px だと折りたたみボタンにかかる) */
.sidebar__logo { display: flex; flex: none; align-items: center; height: 80px; margin: 0; padding-left: calc(var(--center) - 12px); font-size: 15px; font-weight: 700; line-height: 1; letter-spacing: 0.12em; }
.sidebar__search {
display: flex;
flex: none;
align-items: center;
gap: 8px;
height: 40px;
margin-bottom: 8px;
padding: 0 8px 0 calc(var(--center) - 9px);
border: 0;
border-radius: 999px;
background-color: var(--ground);
color: var(--idle);
font: inherit;
font-size: 14px; /* font: inherit の後に書く(先だと打ち消される) */
letter-spacing: 0.04em;
cursor: pointer;
}
.sidebar__search .sidebar__icon { width: 18px; height: 18px; }
.sidebar__kbd { margin-left: auto; height: 24px; box-sizing: border-box; padding: 0 8px; border: 1px solid var(--line); border-radius: 999px; background-color: var(--surface); font: inherit; font-size: 12px; font-weight: 600; line-height: 22px; letter-spacing: 0; }
.sidebar__heading { position: relative; display: flex; align-items: center; height: 32px; margin: 8px 0 0; padding-left: 12px; color: var(--idle); font-size: 12px; font-weight: 700; line-height: 1; letter-spacing: 0.06em; }
.sidebar__rule { position: absolute; left: calc(var(--center) - 12px); top: 15px; width: 24px; height: 2px; background-color: var(--line); opacity: 0; }
.sidebar__list { margin: 0; padding: 0; list-style: none; }
.sidebar__link { display: flex; align-items: center; gap: 16px; height: 40px; margin-bottom: 4px; padding: 0 8px 0 calc(var(--center) - 12px); border-radius: 4px; color: var(--idle); font-size: 15px; font-weight: 600; line-height: 1; letter-spacing: 0.04em; text-decoration: none; }
.sidebar__link:hover { color: var(--ink); }
.sidebar__link[aria-current="page"] { background-color: var(--ground); color: var(--ink); } /* 強調は色ではなく明暗で */
.sidebar__icon { flex: none; width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
.sidebar__glyph { position: relative; display: flex; flex: none; }
.sidebar__dot { position: absolute; top: -2px; right: -2px; width: 10px; height: 10px; box-sizing: border-box; border: 2px solid var(--surface); border-radius: 50%; background-color: var(--ink); opacity: 0; }
.sidebar__badge { margin-left: auto; min-width: 24px; height: 24px; box-sizing: border-box; padding: 0 8px; border-radius: 999px; background-color: var(--ink); color: var(--surface); font-size: 12px; font-weight: 700; line-height: 24px; text-align: center; font-variant-numeric: tabular-nums; }
.sidebar__user { display: flex; flex: none; align-items: center; gap: 12px; height: 64px; margin-top: auto; padding-left: calc(var(--center) - 18px); border-top: 1px solid var(--line); color: inherit; text-decoration: none; }
.sidebar__avatar { display: grid; flex: none; place-items: center; width: 36px; height: 36px; border-radius: 50%; background: var(--avatar); color: #fff; font-size: 14px; font-weight: 700; }
.sidebar__who { display: grid; font-size: 14px; line-height: 1.4; }
.sidebar__who b { font-weight: 600; letter-spacing: 0.04em; }
.sidebar__who small { color: var(--idle); font-size: 11px; }
.sidebar__more { width: 20px; height: 20px; margin: 0 4px 0 auto; fill: var(--idle); }
/* アイコンだけのボタンなので線の丸。サイドバーの右の縁にまたがる */
.sidebar__toggle { position: absolute; z-index: 1; top: 24px; right: -16px; display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 1px solid var(--ink); border-radius: 50%; background-color: var(--surface); color: var(--ink); cursor: pointer; }
.sidebar__chevron { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
/* 焦点の枠は内側に描く(外側だと、中身を切る枠に切られる) */
.sidebar :focus-visible { outline: 2px solid var(--ink); outline-offset: -2px; }
.sidebar .sidebar__toggle:focus-visible { outline-offset: 2px; }
.app__main { flex: 1; min-width: 0; padding: 32px; }
.app__crumb { margin: 0; color: var(--idle); font-size: 13px; letter-spacing: 0.04em; }
.app__title { margin: 16px 0 24px; font-size: clamp(28px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; word-break: auto-phrase; }
.app__cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr)); gap: 24px; margin: 0; padding: 0; list-style: none; }
.app__cards li { display: grid; gap: 16px; padding: 24px; border: 1px solid var(--line); border-radius: 4px; background-color: var(--surface); font-size: 14px; font-weight: 600; letter-spacing: 0.04em; }
.app__cards strong { font-size: 24px; font-weight: 700; letter-spacing: -0.01em; font-variant-numeric: tabular-nums; }
/* 狭い画面では、広げたサイドバーを本文に重ねる(押し広げると本文が細くなりすぎる)。幅の境目は JS とそろえる */
@media (max-width: 640px) {
.sidebar { position: fixed; }
.app__main { margin-left: var(--collapsed); }
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const OPEN_SPEED = 1.2; // 開くほうは少し速く
const narrow = window.matchMedia("(max-width: 640px)"); // 狭い画面では、畳んだ状態から始める
document.querySelectorAll(".sidebar").forEach((sidebar) => {
const toggle = sidebar.querySelector(".sidebar__toggle");
const named = sidebar.querySelectorAll(".sidebar__link, .sidebar__search");
// 幅は CSS が正。GSAP は var(--name) のままでは読めないので、値にして渡す
const styles = getComputedStyle(sidebar);
const expanded = parseFloat(styles.getPropertyValue("--expanded"));
const collapsed = parseFloat(styles.getPropertyValue("--collapsed"));
const collapse = gsap
.timeline({ paused: true })
// 1. ラベルを上から順に、左へ抜きながら消す(opacity だけにして、リンクの名前は読み上げに残す)
.fromTo(sidebar.querySelectorAll(".sidebar__fade"), { opacity: 1, x: 0 }, { opacity: 0, x: -8, duration: 0.2, ease: "power2.in", stagger: 0.012 }, 0)
// 2. 少し遅れて幅を縮める。本文は残りの幅なので、同じだけ広がる(scale は文字とアイコンが潰れるので使わない)
.fromTo(sidebar, { width: expanded }, { width: collapsed, duration: 0.55, ease: "power3.inOut" }, 0.08)
.fromTo(toggle.firstElementChild, { rotation: 0 }, { rotation: 180, duration: 0.5, ease: "power3.inOut" }, 0.08)
// 3. 細くなってから、見出しの代わりの線と、件数の代わりの点を出す
.fromTo(sidebar.querySelectorAll(".sidebar__rule"), { opacity: 0 }, { opacity: 1, duration: 0.2, ease: "power1.out" }, 0.42)
.fromTo(sidebar.querySelectorAll(".sidebar__dot"), { opacity: 0, scale: 0 }, { opacity: 1, scale: 1, duration: 0.3, ease: "back.out(2.5)" }, 0.45);
let isCollapsed = false;
function setCollapsed(next, instant = false) {
isCollapsed = next;
// ボタンの名前(サイドバー)は変えず、開いているかどうかだけを伝える
toggle.setAttribute("aria-expanded", String(!next));
// 畳んでいる間は、アイコンに乗せると項目の名前が出るようにする
named.forEach((el) => (el.title = next ? el.querySelector(".sidebar__fade").textContent : ""));
if (instant) collapse.progress(next ? 1 : 0);
else if (next) collapse.timeScale(SPEED).play();
else collapse.timeScale(SPEED * OPEN_SPEED).reverse();
}
toggle.addEventListener("click", () => setCollapsed(!isCollapsed));
if (narrow.matches) setCollapsed(true, true);
});