デモ
デモを読み込めませんでした。
仕様
- 用途
- ナビゲーション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
今のスライドの点は横長のカプセルになっていて、次へ進むと、カプセルの進む向きの端が次の点まで伸び、少し遅れて後ろの端が追いついて縮みます。遠くの点へ飛ぶときは、あいだの点をまたいで長く伸びます。スライドは、カプセルが伸び始めてから少し遅れて、進む向きから拭うように現れ、中身は同じ向きから滑り込みます。
先の端は 0.36 秒の power3.inOut で次の点へ伸ばし、後ろの端を 0.16 秒遅らせて同じ動きで追わせます。この遅れが、伸びる長さになります。1 つ遠い点ほど 0.06 秒ずつ長くかけます。スライドは押してから 0.26 秒後に、0.8 秒かけて clip-path の inset で拭い、中身は 90px 横から 0.9 秒の power3.out で、0.05 秒ずつずらして滑り込ませます。
点は「今の枠だけが広く、ほかは点の幅」で並べ、カプセルが移るあいだは、前の枠が縮むのと次の枠が広がるのを、後ろの端と同じ進み具合で補間します。2 つの幅の和が変わらないので、外側の点は動かず、あいだの点だけがカプセルの下で詰め直ります。位置と幅は left と width に整数 px で書いてください。scaleX で伸ばすと、丸い端まで横に歪みます。
前のスライドの文字と写真の上の札は、拭う縁が届く前に消します。残すと、縁の両側に 2 つの見出しが並んで、別の語に読めてしまいます。右へ進むときは縁が右から来るので遅めに、戻るときは縁が先に文字を通るので早めに消します。点は各スライドへのボタンで、今の点に aria-current を付け、隠れたスライドは inert にして焦点が入らないようにします。スライドの枠を aria-live にしておくと、切り替わったスライドが読み上げられます。
点の大きさ、今の枠の幅、点のあいだは CSS の変数で変えられ、JS はそこから位置を計算します。遅れを長くすると大きく伸び、0 にすると伸びずに滑ります。動きを減らす設定では、その場で切り替えます。下線が滑るタブや、残り時間を塗りで示す点と違い、点と点のあいだをカプセルが伸び縮みしてつなぎ、矢印と点のどちらで送っても同じ動きになります。このデモでは矢印と点を押して試せます(← → キーは、コードのほうで受けます)。ヒーローのスライダー、オンボーディング、カルーセルのページ送りに向いています。
コード
<!-- ヒーローのスライダー。今のスライドは点の aria-current で伝え、切り替わると aria-live が新しいスライドを読み上げる -->
<section class="slider" aria-roledescription="カルーセル" aria-label="おすすめの旅">
<!-- スライドは同じ升に重ね、選んだスライドを上に重ねて、進む向きから拭うように出す -->
<div class="slider__slides" aria-live="polite">
<div class="slide" role="group" aria-roledescription="スライド" aria-label="5枚中1枚目">
<div class="slide__inner">
<div class="slide__copy">
<p class="slide__eyebrow">01 — 北極圏の夜</p>
<h2 class="slide__title">オーロラを<br>追いかける旅。</h2>
<p class="slide__text">ここに説明文が入ります。</p>
<p class="slide__actions"><a class="slide__button" href="/trips/arctic/">ツアーを見る <span aria-hidden="true">→</span></a> <span class="slide__price">¥298,000〜・6泊</span></p>
</div>
<figure class="slide__art"><img src="/images/trip-arctic.jpg" alt=""><figcaption class="slide__chip">★ 4.9・口コミ312件</figcaption></figure>
</div>
</div>
<div class="slide" role="group" aria-roledescription="スライド" aria-label="5枚中2枚目" inert>
<div class="slide__inner">
<div class="slide__copy">
<p class="slide__eyebrow">02 — 夜市めぐり</p>
<h2 class="slide__title">屋台の路地で、<br>食べ歩き。</h2>
<p class="slide__text">ここに説明文が入ります。</p>
<p class="slide__actions"><a class="slide__button" href="/trips/markets/">ツアーを見る <span aria-hidden="true">→</span></a> <span class="slide__price">¥218,000〜・5泊</span></p>
</div>
<figure class="slide__art"><img src="/images/trip-markets.jpg" alt=""><figcaption class="slide__chip">★ 4.9・口コミ267件</figcaption></figure>
</div>
</div>
<div class="slide" role="group" aria-roledescription="スライド" aria-label="5枚中3枚目" inert>
<div class="slide__inner">
<div class="slide__copy">
<p class="slide__eyebrow">03 — 島めぐり</p>
<h2 class="slide__title">青い海で、<br>ゆっくり過ごす。</h2>
<p class="slide__text">ここに説明文が入ります。</p>
<p class="slide__actions"><a class="slide__button" href="/trips/islands/">ツアーを見る <span aria-hidden="true">→</span></a> <span class="slide__price">¥328,000〜・8泊</span></p>
</div>
<figure class="slide__art"><img src="/images/trip-islands.jpg" alt=""><figcaption class="slide__chip">★ 4.9・口コミ428件</figcaption></figure>
</div>
</div>
<div class="slide" role="group" aria-roledescription="スライド" aria-label="5枚中4枚目" inert>
<div class="slide__inner">
<div class="slide__copy">
<p class="slide__eyebrow">04 — 山小屋の縦走</p>
<h2 class="slide__title">雲の上を、<br>歩く。</h2>
<p class="slide__text">ここに説明文が入ります。</p>
<p class="slide__actions"><a class="slide__button" href="/trips/alpine/">ツアーを見る <span aria-hidden="true">→</span></a> <span class="slide__price">¥148,000〜・5泊</span></p>
</div>
<figure class="slide__art"><img src="/images/trip-alpine.jpg" alt=""><figcaption class="slide__chip">★ 4.7・口コミ158件</figcaption></figure>
</div>
</div>
<div class="slide" role="group" aria-roledescription="スライド" aria-label="5枚中5枚目" inert>
<div class="slide__inner">
<div class="slide__copy">
<p class="slide__eyebrow">05 — 砂漠のキャンプ</p>
<h2 class="slide__title">満天の星の下で、<br>眠る。</h2>
<p class="slide__text">ここに説明文が入ります。</p>
<p class="slide__actions"><a class="slide__button" href="/trips/desert/">ツアーを見る <span aria-hidden="true">→</span></a> <span class="slide__price">¥186,000〜・4泊</span></p>
</div>
<figure class="slide__art"><img src="/images/trip-desert.jpg" alt=""><figcaption class="slide__chip">★ 4.8・口コミ196件</figcaption></figure>
</div>
</div>
</div>
<div class="slider__bar">
<p class="slider__count" aria-hidden="true">
<span class="slider__window"><span class="slider__digits"><span>01</span><span>02</span><span>03</span><span>04</span><span>05</span></span></span>
<span class="slider__rule"></span><span>05</span>
</p>
<!-- 点は各スライドへのボタン。位置と幅は JS が決め、今の点の上に墨のカプセルを重ねる -->
<div class="slider__dots">
<button type="button" aria-label="1枚目のスライドへ" aria-current="true"></button>
<button type="button" aria-label="2枚目のスライドへ"></button>
<button type="button" aria-label="3枚目のスライドへ"></button>
<button type="button" aria-label="4枚目のスライドへ"></button>
<button type="button" aria-label="5枚目のスライドへ"></button>
<span class="slider__capsule" aria-hidden="true"></span>
</div>
<div class="slider__arrows">
<button class="slider__arrow" type="button" aria-label="前のスライド"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M19 12H5M11 6l-6 6 6 6" /></svg></button>
<button class="slider__arrow" type="button" aria-label="次のスライド"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 12h14M13 6l6 6-6 6" /></svg></button>
</div>
</div>
</section>
.slider {
--ink: #262626; /* 文字・今の点のカプセル・矢印の線 */
--dot: #898989; /* 今のスライドでない点 */
--mute: #6b6b6b; /* 補足の文字 */
--ground: #f2f1ed; /* ページとスライドの地。重ねたスライドが透けないよう、スライドも塗る */
--surface: #ffffff; /* 点を載せる台 */
--on-ink: #ffffff;
--chip: rgba(0, 0, 0, 0.32); /* 写真の上の札 */
--press: rgba(38, 38, 38, 0.12); /* 矢印を押した瞬間に敷く色 */
--dot-size: 12px; /* 点の直径 */
--cap: 48px; /* 今の点(カプセル)の幅 */
--gap: 16px; /* 点と点のあいだ */
padding: 0 clamp(16px, 7vw, 96px) 48px;
background: var(--ground);
color: var(--ink);
}
/* スライドを同じ升に重ねる。横へずれて入る中身は、枠の外で切る */
.slider__slides { display: grid; overflow: clip; }
.slide { grid-area: 1 / 1; background: var(--ground); }
.slide:not(:first-child) { visibility: hidden; }
.slide__inner {
display: grid;
grid-template-columns: minmax(0, 9fr) minmax(0, 7fr);
align-items: center;
gap: 64px;
padding: 48px 0;
}
.slide__eyebrow { margin: 0; color: var(--mute); font-size: 13px; font-weight: 700; letter-spacing: 0.06em; font-variant-numeric: tabular-nums; }
.slide__title { margin: 24px 0 0; font-size: clamp(40px, 6vw, 64px); font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; }
.slide__text { margin: 24px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }
.slide__actions { display: flex; flex-wrap: wrap; align-items: center; gap: 24px; margin: 40px 0 0; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; }
.slide__button { padding: 0 32px; border-radius: 999px; background: var(--ink); color: var(--on-ink); line-height: 48px; text-decoration: none; }
.slide__price { color: var(--mute); font-variant-numeric: tabular-nums; }
/* 写真は角 4px・影なし。札は写真の上に載るので、半透明の黒に白い文字 */
.slide__art { position: relative; margin: 0; aspect-ratio: 448 / 464; border-radius: 4px; overflow: hidden; background: var(--dot); }
.slide__art img { display: block; width: 100%; height: 100%; object-fit: cover; }
.slide__chip { position: absolute; left: 16px; bottom: 16px; padding: 0 12px; border-radius: 99px; background: var(--chip); color: var(--on-ink); }
.slide__chip { font-size: 13px; font-weight: 600; line-height: 32px; letter-spacing: 0.06em; white-space: nowrap; }
/* 下の帯: 左に番号、中央に点、右に矢印 */
.slider__bar { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 16px; }
.slider__count { display: flex; align-items: center; gap: 16px; margin: 0; font-size: 14px; font-weight: 700; line-height: 24px; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
.slider__window { height: 24px; overflow: hidden; }
.slider__digits, .slider__digits span { display: block; }
.slider__rule { width: 40px; height: 1px; background: var(--mute); }
.slider__count > :last-child { color: var(--mute); }
/* 点の台。幅と、点・カプセルの位置は JS が決める */
.slider__dots { position: relative; height: 48px; padding: 0 24px; border-radius: 99px; background: var(--surface); }
.slider__dots > * { position: absolute; top: calc(24px - var(--dot-size) / 2); height: var(--dot-size); border-radius: 99px; }
.slider__dots button { padding: 0; border: 0; background: var(--dot); cursor: pointer; }
.slider__dots button::after { content: ""; position: absolute; inset: calc(var(--dot-size) / 2 - 24px) calc(var(--gap) / -2); } /* 押せる範囲を台の高さと点のあいだまで広げる */
.slider__capsule { background: var(--ink); pointer-events: none; }
.slider__arrows { display: flex; justify-content: flex-end; gap: 16px; }
.slider__arrow {
display: grid;
place-items: center;
width: 48px;
height: 48px;
padding: 0;
border: 0;
border-radius: 50%;
background-color: transparent;
box-shadow: inset 0 0 0 1px var(--ink);
color: var(--ink);
cursor: pointer;
transition: background-color 0.3s ease-out;
}
/* 押した瞬間に色を敷き、離すと 0.3 秒で抜く */
.slider__arrow:active { background-color: var(--press); transition: none; }
.slider__arrow svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.slider :focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
/* 狭い画面では、写真を上にして 1 列にし、番号を隠す */
@media (max-width: 720px) {
.slide__inner { grid-template-columns: minmax(0, 1fr); gap: 32px; padding-top: 24px; }
.slide__art { order: -1; aspect-ratio: 4 / 3; }
.slide__text { font-size: 16px; }
.slider__bar { grid-template-columns: auto 1fr; }
.slider__count { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.slider__arrow { transition: none; }
}
import { gsap } from "gsap";
const HEAD = 0.36; // カプセルの先の端が隣の点に届くまで(秒)
const STRETCH = 0.06; // 1 つ遠い点ほど、この分だけ長くかける
const LAG = 0.16; // 後ろの端が動き出すまでの遅れ。伸びる長さが決まる
const WIPE = 0.8; // スライドが拭うように現れる時間
// 押してからの時刻(秒)。先にカプセルが伸び、少し遅れてスライドが拭うように現れ(wipe)、中身が滑り込む(slide)。
// 前のスライドの文字(text は戻る・進むとき)と写真の上の札(chip)は、拭う縁が届く前に消す
const AT = { wipe: 0.26, slide: 0.4, text: [0.34, 0.46], chip: 0.32 };
const SHIFT = 90; // 中身が滑り込む量(px)
const SHIFT_OUT = 38; // 前のスライドの中身が逆へずれる量(px)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const lerp = (p, q, k) => p + (q - p) * k;
document.querySelectorAll(".slider").forEach((root) => {
const slides = [...root.querySelectorAll(".slide")];
const pill = root.querySelector(".slider__dots");
const dots = [...pill.querySelectorAll("button")];
const capsule = root.querySelector(".slider__capsule");
const digits = root.querySelector(".slider__digits");
const [prev, next] = root.querySelectorAll(".slider__arrow");
const n = slides.length;
const worm = { from: 0, to: 0, head: 1, tail: 1 }; // カプセルの移り方。先の端を head、後ろの端を tail で動かす
const count = { v: 0 }; // 番号の列の位置
let size = {};
let turn = null;
let [current, top] = [0, 1]; // 今のスライドと、いちばん上に重ねたスライドの z-index
// a から b へ移る途中(s = 0〜1)の各点の [左, 右]。a の枠は縮み、b の枠は広がる。2 つの和は変わらないので、外側の点は動かない
const edges = (a, b, s) => {
const { d, c, g, pad } = size;
let x = pad;
return slides.map((_, i) => {
const w = i === a && i === b ? c : i === a ? c - (c - d) * s : i === b ? d + (c - d) * s : d;
x += w + g;
return [x - w - g, x - g];
});
};
// 位置と幅は left / width に整数 px で書く(scaleX で伸ばすと、丸い端まで歪む)
const place = (el, l, r) => Object.assign(el.style, { left: `${Math.round(l)}px`, width: `${Math.round(r) - Math.round(l)}px` });
const draw = () => {
const { from: a, to: b, head: h, tail: k } = worm;
const e = edges(a, b, k);
let [L, R] = e[b];
if (b > a) [L, R] = [lerp(e[a][0], e[b][0], k), lerp(e[a][1], e[b][1], h)];
if (b < a) [L, R] = [lerp(e[a][0], e[b][0], h), lerp(e[a][1], e[b][1], k)];
e.forEach(([l, r], i) => place(dots[i], l, r));
place(capsule, L, R);
digits.style.transform = `translateY(${-count.v * digits.firstElementChild.offsetHeight}px)`;
};
// 点の大きさは CSS から読む。今の枠だけが広いので、台の幅は「点 n − 1 個 + 広い枠 1 つ + あいだ」
const layout = () => {
const css = getComputedStyle(root);
size = { d: parseFloat(css.getPropertyValue("--dot-size")), c: parseFloat(css.getPropertyValue("--cap")), g: parseFloat(css.getPropertyValue("--gap")) };
size.pad = parseFloat(getComputedStyle(pill).paddingLeft);
pill.style.width = `${(n - 1) * (size.d + size.g) + size.c}px`;
draw();
};
function go(to) {
if (to === current) return;
turn?.progress(1); // 動いている途中で押されたら、前の移動を終わらせてから始める
const dir = to > current ? 1 : -1;
const head = HEAD + STRETCH * (Math.abs(to - current) - 1);
const [a, b] = [slides[current], slides[to]];
Object.assign(worm, { from: current, to, head: 0, tail: 0 });
gsap.set(b, { zIndex: ++top, visibility: "visible" });
turn = gsap
.timeline({ onUpdate: draw, onComplete: () => gsap.set(a, { visibility: "hidden" }) })
.to(worm, { head: 1, duration: head, ease: "power3.inOut" }, 0)
.to(worm, { tail: 1, duration: head, ease: "power3.inOut" }, LAG)
.fromTo(count, { v: current }, { v: to, duration: 0.55, ease: "power3.inOut" }, AT.wipe)
// 次のスライドを進む向きから拭うように出す。前に出たときにずらした中身と、消した文字と札は戻しておく
.fromTo(b, { clipPath: dir > 0 ? "inset(0% 0% 0% 100%)" : "inset(0% 100% 0% 0%)" }, { clipPath: "inset(0% 0% 0% 0%)", duration: WIPE, ease: "power3.inOut" }, AT.wipe)
.set(b.querySelector(".slide__inner"), { x: 0 }, 0)
.set(b.querySelectorAll(".slide__copy, .slide__chip"), { opacity: 1 }, 0)
.fromTo(b.querySelectorAll(".slide__copy > *, .slide__art"), { x: dir * SHIFT }, { x: 0, duration: 0.9, ease: "power3.out", stagger: 0.05 }, AT.slide)
// 前のスライドは中身ごと逆へ少しずれ、文字と札は拭う縁が届く前に消す(縁の両側で 2 つの見出しが混ざらないように)
.fromTo(a.querySelector(".slide__inner"), { x: 0 }, { x: -dir * SHIFT_OUT, duration: WIPE, ease: "power3.inOut" }, AT.wipe)
.to(a.querySelector(".slide__copy"), { opacity: 0, duration: 0.2, ease: "power1.in" }, AT.text[dir > 0 ? 1 : 0])
.to(a.querySelector(".slide__chip"), { opacity: 0, duration: 0.15, ease: "power1.in" }, AT.chip);
dots.forEach((dot, i) => (i === to ? dot.setAttribute("aria-current", "true") : dot.removeAttribute("aria-current")));
slides.forEach((slide, i) => (slide.inert = i !== to)); // 隠れたスライドのリンクに、焦点が入らないように
current = to;
if (reduceMotion) turn.progress(1); // 動きを減らす設定では、その場で切り替える
}
dots.forEach((dot, i) => dot.addEventListener("click", () => go(i)));
prev.addEventListener("click", () => go((current - 1 + n) % n));
next.addEventListener("click", () => go((current + 1) % n));
// ← → キーでも送る(スライドの中のリンクにいるときは、ふつうのキー操作のまま)
root.addEventListener("keydown", (event) => {
const step = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
if (!step || event.target.closest(".slide")) return;
event.preventDefault();
go((current + step + n) % n);
});
layout();
window.addEventListener("resize", layout);
});