デモ
デモを読み込めませんでした。
仕様
- 用途
- セクション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
左に機能のタブを縦に並べ、選んでいるタブの説明を開いて、上の線を左から満たしていきます。線が満ちきると次のタブへ進み、右の図が切り替わります。次の図は前の図の上に重ね、下の縁から上へ開いて覆います(clip-path の inset)。タブを押すと、そのタブへ切り替えて、線を 0 から満たし直します。
1 つのタブは 4 秒見せ、線はその間、一定の速さで満ちるので、次に替わるまでの残り時間が読めます。図は 0.7 秒の power3.inOut で開き、中身を 24px 下から同時に上げて、画面が差し込まれるように見せます。前のタブの線は満ちたまま 0.3 秒で薄れ、説明は 0.5 秒で閉じます。線を満たすトゥイーンそのものを時計にし、終わったら次へ進むので、止めるのも pause と resume を呼ぶだけです。
自動で進むものは、読んでいる途中に中身が替わると困ります。ポインタを重ねている間と、キーボードの焦点がタブにある間は止め、画面の外にある間も進めません。焦点は :focus-visible のときだけ数えます。マウスで押しても焦点は入るので、数えてしまうと、押したあとにポインタを離しても止まったままになります。
タブの列は tablist で、縦に並ぶので ↑ ↓ キーで隣へ移ります。タブの名前は機能名だけにし、開く説明は aria-describedby で添えます。ボタンの中の文がすべて名前になって、長く読み上げられるのを避けるためです。説明の開閉は CSS で行の高さを 0fr から 1fr へ動かすので、高さを測らずに済みます。動きを減らす設定では自動で進めず、線も出さずに、押したときだけ一瞬で切り替えます。
つまみは、見せる時間と、図が開く時間、中身が上がる量です。見せる時間は、説明と図を読み取れる 4〜6 秒が目安で、短くすると読み終える前に替わります。押して切り替えるだけのタブと違い、放っておいても全部の機能が順に見えるので、SaaS やアプリの機能紹介、サービスの手順、導入の流れを見せる節に向いています。
コード
<section class="features" aria-labelledby="features-title">
<p class="features__eyebrow">Features</p>
<h2 class="features__title" id="features-title">チームの仕事に、<wbr>ちょうどいい。</h2>
<div class="features__body">
<!-- 縦に並ぶタブ。Tab キーで入るのは選んでいるタブだけで、ほかへは ↑ ↓ キーで移る。
名前は機能名だけにし、開く説明は aria-describedby で添える -->
<div class="features__tabs" role="tablist" aria-label="機能" aria-orientation="vertical">
<button class="features__tab" type="button" role="tab" id="feature-tab-1" aria-controls="feature-1"
aria-selected="true" aria-labelledby="feature-name-1" aria-describedby="feature-text-1">
<span class="features__fill" aria-hidden="true"></span>
<span class="features__name" id="feature-name-1">みんなで計画する</span>
<span class="features__more"><span class="features__text" id="feature-text-1">ここに説明文が入ります。</span></span>
</button>
<button class="features__tab" type="button" role="tab" id="feature-tab-2" aria-controls="feature-2"
aria-selected="false" aria-labelledby="feature-name-2" aria-describedby="feature-text-2" tabindex="-1">
<span class="features__fill" aria-hidden="true"></span>
<span class="features__name" id="feature-name-2">進み具合を確認する</span>
<span class="features__more"><span class="features__text" id="feature-text-2">ここに説明文が入ります。</span></span>
</button>
<button class="features__tab" type="button" role="tab" id="feature-tab-3" aria-controls="feature-3"
aria-selected="false" aria-labelledby="feature-name-3" aria-describedby="feature-text-3" tabindex="-1">
<span class="features__fill" aria-hidden="true"></span>
<span class="features__name" id="feature-name-3">手作業を自動化する</span>
<span class="features__more"><span class="features__text" id="feature-text-3">ここに説明文が入ります。</span></span>
</button>
<button class="features__tab" type="button" role="tab" id="feature-tab-4" aria-controls="feature-4"
aria-selected="false" aria-labelledby="feature-name-4" aria-describedby="feature-text-4" tabindex="-1">
<span class="features__fill" aria-hidden="true"></span>
<span class="features__name" id="feature-name-4">成果を共有する</span>
<span class="features__more"><span class="features__text" id="feature-text-4">ここに説明文が入ります。</span></span>
</button>
</div>
<!-- 図は同じ升に重ねる。切り替えるときは、次の図を上に重ねて下から開く -->
<div class="features__panels">
<div class="features__panel" id="feature-1" role="tabpanel" aria-labelledby="feature-tab-1">
<img src="/img/feature-plan.png" alt="タスクをボードに並べた画面" width="1216" height="848">
</div>
<div class="features__panel" id="feature-2" role="tabpanel" aria-labelledby="feature-tab-2">
<img src="/img/feature-track.png" alt="今月の進み具合を折れ線で見せる画面" width="1216" height="848">
</div>
<div class="features__panel" id="feature-3" role="tabpanel" aria-labelledby="feature-tab-3">
<img src="/img/feature-automate.png" alt="自動で進める手順を並べた画面" width="1216" height="848">
</div>
<div class="features__panel" id="feature-4" role="tabpanel" aria-labelledby="feature-tab-4">
<img src="/img/feature-share.png" alt="成果をまとめた報告の画面" width="1216" height="848">
</div>
</div>
</div>
</section>
.features {
--bg: #f2f1ed;
--ink: #262626;
--mute: #898989;
--mute-small: #6b6b6b;
--line: rgb(38 38 38 / 0.12);
--ease: cubic-bezier(0.65, 0, 0.35, 1);
padding: 96px 24px;
background: var(--bg);
color: var(--ink);
}
.features__eyebrow,
.features__title,
.features__body {
max-width: 1088px;
margin-inline: auto;
}
.features__eyebrow {
margin-block: 0 16px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--mute-small);
}
.features__title {
margin-block: 0 48px;
font-size: clamp(28px, 4vw, 44px);
font-weight: 800;
line-height: 1.1;
letter-spacing: -0.02em;
word-break: keep-all; /* 折り返すのは HTML の <wbr> の所だけ */
}
.features__body {
display: grid;
grid-template-columns: minmax(0, 400px) minmax(0, 1fr);
gap: 80px;
align-items: start;
}
.features__tabs { display: flex; flex-direction: column; }
.features__tab {
position: relative;
display: block;
width: 100%;
padding: 20px 0;
border: 0;
border-top: 1px solid var(--line);
background: none;
color: var(--ink);
font: inherit;
text-align: left;
cursor: pointer;
}
/* 進み具合の線。上の罫線に重ね、JS が scaleX を 0 から 1 へ動かす */
.features__fill {
position: absolute;
left: 0;
top: -1px;
width: 100%;
height: 2px;
background: var(--ink);
transform: scaleX(0);
transform-origin: left;
}
.features__name { display: block; font-size: 22px; font-weight: 700; line-height: 1.3; color: var(--mute); transition: color 0.3s; }
.features__tab[aria-selected="true"] .features__name { color: var(--ink); }
.features__tab:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
/* 説明は行の高さを 0fr から 1fr へ動かして開く(高さを測らずに済む) */
.features__more { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.5s var(--ease); }
.features__tab[aria-selected="true"] .features__more { grid-template-rows: 1fr; }
.features__text { min-height: 0; overflow: hidden; word-break: auto-phrase; font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; opacity: 0; transition: opacity 0.3s; }
.features__text::before { content: ""; display: block; height: 8px; }
.features__tab[aria-selected="true"] .features__text { opacity: 1; transition-delay: 0.2s; }
/* 図は同じ升に重ねる。JS が動く前は、最初の図だけを見せる */
.features__panels { display: grid; }
.features__panel { grid-area: 1 / 1; aspect-ratio: 608 / 424; overflow: hidden; border-radius: 4px; background: #dedbe6; }
.features__panel:not(:first-child) { visibility: hidden; }
.features__panel img { display: block; width: 100%; height: 100%; object-fit: cover; }
@media (max-width: 800px) { .features__body { grid-template-columns: 1fr; gap: 40px; } }
/* 動きを減らす設定では、自動で進めないので線を出さず、開閉も一瞬で行う */
@media (prefers-reduced-motion: reduce) {
.features__fill { display: none; }
.features__name,
.features__more,
.features__text { transition: none; }
}
import { gsap } from "gsap";
const HOLD = 4; // 1 つの機能を見せる時間(秒)。線はこの時間で満ちる
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".features").forEach((section) => {
const body = section.querySelector(".features__body");
const tabs = [...section.querySelectorAll('[role="tab"]')];
const panels = tabs.map((tab) => document.getElementById(tab.getAttribute("aria-controls")));
const fills = tabs.map((tab) => tab.querySelector(".features__fill"));
let current = Math.max(0, tabs.findIndex((tab) => tab.getAttribute("aria-selected") === "true"));
let top = 1; // いちばん上に重ねた図の z-index
let clock = null; // 線を満たすトゥイーン。満ちたら次のタブへ進む
// 止める理由。重ねている・キーボードの焦点がある・画面の外にある、のどれか 1 つでもあれば止める
const hold = { hover: false, focus: false, hidden: true };
const update = () => clock && (hold.hover || hold.focus || hold.hidden ? clock.pause() : clock.resume());
function select(next) {
const prev = current;
current = next;
tabs.forEach((tab, i) => {
tab.setAttribute("aria-selected", String(i === next));
tab.tabIndex = i === next ? 0 : -1;
});
if (prev !== next) {
// 次の図をいちばん上に重ね、下から開いて前の図を覆う。覆い終わったら、前の図を読み上げとタブ移動から外す
const panel = panels[next];
gsap.set(panel, { zIndex: ++top, visibility: "visible" });
gsap.fromTo(panel, { clipPath: "inset(100% 0% 0% 0%)" }, {
clipPath: "inset(0% 0% 0% 0%)",
duration: reduceMotion ? 0 : 0.7,
ease: "power3.inOut",
overwrite: "auto",
// 途中で次のタブが押されたら、そちらに任せる
onComplete: () => panel === panels[current] && panels.forEach((other) => other !== panel && gsap.set(other, { visibility: "hidden" })),
});
if (!reduceMotion) gsap.fromTo(panel.firstElementChild, { y: 24 }, { y: 0, duration: 0.7, ease: "power3.inOut" });
}
// 前の線は満ちたまま薄れて消え、いまの線は 0 から満ちる。動きを減らす設定では自動で進めない
clock?.kill();
fills.forEach((fill, i) => {
if (i === prev && prev !== next) {
gsap.to(fill, { opacity: 0, duration: 0.3, overwrite: true, onComplete: () => gsap.set(fill, { scaleX: 0, opacity: 1 }) });
} else if (i !== next) gsap.set(fill, { scaleX: 0, opacity: 1, overwrite: true });
});
if (reduceMotion) return;
clock = gsap.fromTo(fills[next], { scaleX: 0, opacity: 1 }, {
scaleX: 1,
duration: HOLD,
ease: "none",
overwrite: true,
onComplete: () => select((current + 1) % tabs.length),
});
update();
}
// 押したタブへ切り替え、そこから進める
tabs.forEach((tab, i) => tab.addEventListener("click", () => i !== current && select(i)));
// ↑ ↓ で隣のタブへ(端では反対の端へ回る)。Home と End で先頭と末尾へ
section.querySelector('[role="tablist"]').addEventListener("keydown", (event) => {
const step = { ArrowDown: current + 1, ArrowUp: current - 1, Home: 0, End: tabs.length - 1 }[event.key];
if (step === undefined) return;
event.preventDefault();
select((step + tabs.length) % tabs.length);
tabs[current].focus();
});
// 重ねている間は止める。焦点はキーボードのとき(:focus-visible)だけ止める。マウスで押しても焦点は入るため
body.addEventListener("pointerenter", () => ((hold.hover = true), update()));
body.addEventListener("pointerleave", () => ((hold.hover = false), update()));
section.addEventListener("focusin", (event) => ((hold.focus = event.target.matches(":focus-visible")), update()));
section.addEventListener("focusout", (event) => !section.contains(event.relatedTarget) && ((hold.focus = false), update()));
// 画面に入るまでは進めない(見ていない間に先へ進まないように)
new IntersectionObserver(([entry]) => ((hold.hidden = !entry.isIntersecting), update())).observe(section);
select(current);
});