本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
カード

横に並んだ縦長のカードがホバーで広がり、文字が遅れて出る

デモ

操作:カーソルを重ねる。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
カード
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

縦長のカード(パネル)を flex の横並びにし、それぞれの flex-grow を補間して幅を入れ替えます。カーソルが入ったパネルを 4、ほかを 1 にするので、開いたパネルは細いパネルの 4 倍の幅になります。細いパネルは縦書きの名前だけを見せ、開くと名前が消えて、見出し・説明・ボタンが下から順に出てきます。並びから出ると、最初のパネルに戻ります。

幅は 0.7 秒の power3.inOut で動かします。開くパネルと閉じるパネルを同じ対称な曲線で同時に動かすと、flex-grow の合計が一定になり、ほかのパネルの幅が揺れません。中身は開き始めて 0.3 秒後から、18px 下から 0.07 秒ずつずらして出し、閉じるパネルの中身は 0.2 秒で消します。縦書きの名前は、細くなってから(0.4 秒後に)出します。遅れのある動きは、前の動きを自分で止めてから作ります。自動の上書きに任せると、待っていた古い動きが後から始まって、新しい動きを上書きします。

写真と中身は、パネルと一緒に伸び縮みさせません。写真は開いたときの幅のままパネルの中央に置き、overflow: hidden で切り抜くので、開くほど見える範囲が広がります。中身も開いたときの幅に固定しておくと、幅の補間の途中で文字の折り返しが変わらず、行が暴れません。開いたときの幅は、並びの幅(コンテナの幅の単位 cqw)と倍率から CSS で計算しています。

閉じたパネルの中身は透明にするだけで、見えない状態(visibility)にはしません。Tab で閉じたパネルのリンクへ移ったときにも焦点が届くので、焦点が入ったパネルを開けば、キーボードでも同じように選べます。指で触る端末にはホバーが無いので、タップで開きます。閉じたパネルの中身は押せないようにしておき、1 回目のタップはパネルを開くだけにします。狭い画面では縦に積み、高さで開きます。

つまみは、開いたパネルの倍率、幅が入れ替わる時間、文字が出始めるまでの遅れです。開いたままにしたいなら、並びから出たときに戻す処理を外します。旅行やサービスの紹介、事例やチームの紹介、カテゴリの入口に向いています。

コード

HTML
<section class="stays" aria-labelledby="stays-title">
  <div class="stays__head">
    <div>
      <p class="stays__label">STAYS</p>
      <h2 class="stays__title" id="stays-title">景色で選ぶ、<wbr>静かな宿</h2>
    </div>
    <a class="stays__all" href="/stays/">94件の宿をすべて見る →</a>
  </div>

  <!-- is-open のパネルが最初から開いている。--tint は写真が届くまでの地の色、
       --number は左上の番号の色(写真の明るい所に載るなら墨色、暗い所なら白) -->
  <ul class="panels">
    <li class="panel is-open" style="--tint: #d99590; --number: #262626">
      <img class="panel__image" src="/img/stays-coast.jpg" alt="" width="1024" height="976">
      <span class="panel__number" aria-hidden="true">01</span>
      <span class="panel__label" aria-hidden="true">海辺</span>
      <div class="panel__body">
        <p class="panel__kicker"><span>海辺</span> 24件の宿</p>
        <h3 class="panel__title">潮風の届く海辺の宿</h3>
        <p class="panel__text">ここに説明文が入ります。</p>
        <a class="panel__link" href="/stays/coast/">宿を見る<span class="panel__sr">(海辺)</span> →</a>
      </div>
    </li>
    <li class="panel" style="--tint: #ccaa7e; --number: #262626">
      <img class="panel__image" src="/img/stays-dunes.jpg" alt="" width="1024" height="976">
      <span class="panel__number" aria-hidden="true">02</span>
      <span class="panel__label" aria-hidden="true">砂丘</span>
      <div class="panel__body">
        <p class="panel__kicker"><span>砂丘</span> 12件の宿</p>
        <h3 class="panel__title">砂丘を望む宿</h3>
        <p class="panel__text">ここに説明文が入ります。</p>
        <a class="panel__link" href="/stays/dunes/">宿を見る<span class="panel__sr">(砂丘)</span> →</a>
      </div>
    </li>
    <li class="panel" style="--tint: #a2c49f; --number: #262626">
      <img class="panel__image" src="/img/stays-forest.jpg" alt="" width="1024" height="976">
      <span class="panel__number" aria-hidden="true">03</span>
      <span class="panel__label" aria-hidden="true">森</span>
      <div class="panel__body">
        <p class="panel__kicker"><span>森</span> 31件の宿</p>
        <h3 class="panel__title">松林に囲まれた宿</h3>
        <p class="panel__text">ここに説明文が入ります。</p>
        <a class="panel__link" href="/stays/forest/">宿を見る<span class="panel__sr">(森)</span> →</a>
      </div>
    </li>
    <li class="panel" style="--tint: #62839D; --number: #262626">
      <img class="panel__image" src="/img/stays-peaks.jpg" alt="" width="1024" height="976">
      <span class="panel__number" aria-hidden="true">04</span>
      <span class="panel__label" aria-hidden="true">山</span>
      <div class="panel__body">
        <p class="panel__kicker"><span>山</span> 18件の宿</p>
        <h3 class="panel__title">雲海を見下ろす山の宿</h3>
        <p class="panel__text">ここに説明文が入ります。</p>
        <a class="panel__link" href="/stays/peaks/">宿を見る<span class="panel__sr">(山)</span> →</a>
      </div>
    </li>
    <li class="panel" style="--tint: #383755; --number: #ffffff">
      <img class="panel__image" src="/img/stays-night.jpg" alt="" width="1024" height="976">
      <span class="panel__number" aria-hidden="true">05</span>
      <span class="panel__label" aria-hidden="true">星空</span>
      <div class="panel__body">
        <p class="panel__kicker"><span>星空</span> 9件の宿</p>
        <h3 class="panel__title">星空がきれいな宿</h3>
        <p class="panel__text">ここに説明文が入ります。</p>
        <a class="panel__link" href="/stays/night/">宿を見る<span class="panel__sr">(星空)</span> →</a>
      </div>
    </li>
  </ul>
</section>

CSS
.stays {
  --ground: #f2f1ed;       /* 地 */
  --ink: #262626;          /* 見出しの文字 */
  --mute: #6b6b6b;         /* 小さいラベル */
  --on-image: #ffffff;     /* 写真の上の文字 */
  --on-image-mute: #dedcd6;/* 写真の上の小さいラベル */
  --on-button: #111111;    /* 白いボタンの文字 */
  /* 文字の下に敷く幕。開いたときの文字の範囲(下から 5 割ほど)まで濃くする */
  --scrim: linear-gradient(0deg, rgba(17, 17, 17, 0.64), rgba(17, 17, 17, 0.4) 44%, rgba(17, 17, 17, 0) 68%);
  padding: 48px clamp(16px, 7vw, 96px);
  background: var(--ground);
  color: var(--ink);
}

.stays__head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: last baseline; gap: 16px 48px; margin-bottom: 40px; }
.stays__label { margin: 0; color: var(--mute); font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; }
.stays__title { margin: 16px 0 0; font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; word-break: keep-all; }
.stays__all { color: inherit; font-size: 15px; font-weight: 600; letter-spacing: 0.04em; text-underline-offset: 0.4em; white-space: nowrap; }

/* 並び。開いたパネルは、ほかのパネルの --grow 倍の幅。--open はその幅(写真と中身はこの幅に固定して、切り抜いて見せる) */
.panels {
  --count: 5;
  --grow: 4;
  --gap: 16px;
  --pad: 32px;
  --title: 44px; /* 見出しの大きさの上限 */
  --open: calc((100cqw - (var(--count) - 1) * var(--gap)) * var(--grow) / (var(--grow) + var(--count) - 1));
  container-type: inline-size;
  display: flex;
  gap: var(--gap);
  height: 488px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.panel {
  position: relative;
  isolation: isolate;
  flex: 1 1 0;
  min-width: 0;
  overflow: hidden;
  border-radius: 4px;
  background: var(--tint);
  color: var(--on-image);
}

.panel.is-open { flex-grow: var(--grow); }
.panel::after { content: ""; position: absolute; inset: 0; background: var(--scrim); }

/* 写真はパネルと一緒に伸び縮みさせず、開いたときの幅のまま中央に置く。開くほど見える範囲が広がる */
.panel__image { position: absolute; top: 0; left: 50%; width: var(--open); height: 100%; translate: -50% 0; object-fit: cover; }

.panel__number,
.panel__label,
.panel__body { position: absolute; z-index: 1; left: var(--pad); }

.panel__number { top: var(--pad); color: var(--number); font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; font-variant-numeric: tabular-nums; }

/* 細いときの名前(和文の縦書き。上から下へ読む) */
.panel__label { bottom: var(--pad); writing-mode: vertical-rl; font-size: 24px; font-weight: 700; line-height: 1.3; white-space: nowrap; }

/* 中身は開いたときの幅に固定し、折り返しを変えない。閉じたパネルの中身は押せないようにし、タップはパネルが受ける */
.panel__body { bottom: var(--pad); width: calc(var(--open) - 2 * var(--pad)); }
.panel:not(.is-open) .panel__body { pointer-events: none; }
.panel:not(.is-open) .panel__body > *,
.panel.is-open .panel__label { opacity: 0; }

.panel__kicker { display: flex; gap: 12px; margin: 0; color: var(--on-image-mute); font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.06em; }
/* 見出しは 10 字までが 1 行に収まる大きさにする(中身の幅 ÷ 10.1。上限は --title) */
.panel__title { margin: 16px 0 0; font-size: min(var(--title), calc((var(--open) - 2 * var(--pad)) / 10.1)); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.panel__text { margin: 16px 0 0; font-size: 14px; line-height: 1.8; letter-spacing: 0.04em; }

/* 写真の上の主ボタン(白い丸帯) */
.panel__link {
  display: inline-block;
  margin-top: 24px;
  padding: 0 24px;
  border-radius: 999px;
  background: var(--on-image);
  color: var(--on-button);
  font-size: 15px;
  font-weight: 600;
  line-height: 48px;
  letter-spacing: 0.04em;
  text-decoration: none;
  white-space: nowrap;
}

.panel__link:focus-visible { outline: 2px solid var(--on-image); outline-offset: 3px; }
.panel__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* 狭い画面では縦に積み、高さで開く。番号は右上へ、名前は横書きに */
@media (max-width: 719px) {
  .panels { --pad: 24px; --title: 28px; --open: 100cqw; flex-direction: column; height: 680px; }
  .panel__image { left: 0; width: 100%; translate: none; }
  .panel__number { left: auto; right: var(--pad); }
  .panel__label { writing-mode: horizontal-tb; }
}

JavaScript
import { gsap } from "gsap";

const EXPAND = 0.7; // 幅が入れ替わる時間(秒)
const DELAY = 0.3; // 開き始めてから文字が出始めるまで(秒)
const RISE = 18; // 文字がせり上がる量(px)
// 動きを減らす設定では、ほぼ一瞬で切り替える(時間と遅れに掛ける)
const k = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0.05 : 1;

document.querySelectorAll(".panels").forEach((row) => {
  const panels = [...row.querySelectorAll(".panel")];
  const parts = panels.map((panel) => [...panel.querySelectorAll(".panel__body > *")]);
  const labels = panels.map((panel) => panel.querySelector(".panel__label"));
  const grow = parseFloat(getComputedStyle(row).getPropertyValue("--grow"));
  const home = Math.max(0, panels.findIndex((panel) => panel.classList.contains("is-open")));
  let current = home;

  // はじめの見た目を GSAP の値にしておく(以後はクラスではなく、この値を動かす)
  panels.forEach((_, i) => {
    gsap.set(parts[i], { opacity: i === home ? 1 : 0 });
    gsap.set(labels[i], { opacity: i === home ? 0 : 1 });
  });

  // 値ごとに前の動きを止めてから作る。delay 付きの動きを overwrite: "auto" に任せると、
  // 待っている古い動きが始まった時点で、新しい動きを上書きしてしまう
  const tweens = new Map();
  const to = (key, targets, vars) => {
    tweens.get(key)?.kill();
    tweens.set(key, gsap.to(targets, vars));
  };

  function activate(i) {
    if (i === current) return;
    current = i;
    panels.forEach((panel, j) => panel.classList.toggle("is-open", j === i));
    // 開くパネルと閉じるパネルを同じ対称な ease で同時に動かすと、flex-grow の合計が一定になり、ほかのパネルの幅が揺れない
    to("grow", panels, { flexGrow: (j) => (j === i ? grow : 1), duration: EXPAND * k, ease: "power3.inOut" });
    panels.forEach((_, j) => {
      if (j === i) {
        // 消えきった行だけ下へ沈めてから、少し遅れて下から順に出す。縦書きの名前はすぐ消す
        gsap.set(parts[j].filter((el) => gsap.getProperty(el, "opacity") === 0), { y: RISE });
        to(`parts${j}`, parts[j], { opacity: 1, y: 0, duration: 0.5 * k, delay: DELAY * k, stagger: 0.07 * k, ease: "power3.out" });
        to(`label${j}`, labels[j], { opacity: 0, duration: 0.2 * k });
      } else {
        // 閉じるパネルの中身はすぐ消し、名前は細くなってから出す
        to(`parts${j}`, parts[j], { opacity: 0, duration: 0.2 * k });
        to(`label${j}`, labels[j], { opacity: 1, duration: 0.35 * k, delay: 0.4 * k });
      }
    });
  }

  panels.forEach((panel, i) => {
    // 指で触る端末にはホバーが無いので、タップ(click)で開く
    panel.addEventListener("pointerenter", (event) => event.pointerType !== "touch" && activate(i));
    panel.addEventListener("click", () => activate(i));
    // Tab で閉じたパネルのリンクへ移っても開く(中身は透明にしてあるだけなので、焦点が届く)
    panel.addEventListener("focusin", () => activate(i));
  });

  // 並びから出たら最初のパネルに戻す(開いたままにするなら、この 2 つを消す)
  row.addEventListener("pointerleave", (event) => {
    if (event.pointerType !== "touch" && !row.querySelector(":focus-visible")) activate(home);
  });
  row.addEventListener("focusout", (event) => {
    if (!row.contains(event.relatedTarget) && !row.matches(":hover")) activate(home);
  });
});

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

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