本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ナビゲーション

ページ送りの今の点がカプセルになり、次の点まで伸びてから縮んで移る

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

動作
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 にすると伸びずに滑ります。動きを減らす設定では、その場で切り替えます。下線が滑るタブや、残り時間を塗りで示す点と違い、点と点のあいだをカプセルが伸び縮みしてつなぎ、矢印と点のどちらで送っても同じ動きになります。このデモでは矢印と点を押して試せます(← → キーは、コードのほうで受けます)。ヒーローのスライダー、オンボーディング、カルーセルのページ送りに向いています。

コード

HTML
<!-- ヒーローのスライダー。今のスライドは点の 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>

CSS
.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; }
}

JavaScript
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);
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。