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

クリックしたカードがその場から広がり、詳細画面になる

デモ

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

仕様

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

しくみと調整

カードを押すと、カードと同じ見た目の面が、カードの位置と大きさから画面いっぱいまで広がり、左半分に画像、右半分に説明が並ぶ詳細画面になります。広がる前の位置と大きさを測っておき、広がりきった見た目との間を補間する FLIP という型です。面の位置・幅・高さと角の丸みを動かし、中の画像も「カードの上側」から「画面の左半分」へ、別に大きさを補間します。閉じるときは同じタイムラインを逆にたどり、押したカードの位置へ縮んで戻ります。

広がるのは 0.9 秒の power3.inOut です。文字は補間せず、カードの文字は最初の 0.2 秒で消し、詳細の文字は 0.56 秒(広がりの 6 割ほど)から 0.06 秒ずつずらして 28px 下から出します。ほかのカードは 0.5 秒で濃さ 35%・大きさ 95% に下げ、押したカードだけが前へ出てくるように見せます。× は 0.63 秒から回りながら出ます。逆にたどるので、閉じるときは詳細の文字が消えてから面が縮み始めます。

transform の scale で広げる本来の FLIP は軽いのですが、中の画像と文字まで一緒に引き伸ばされ、縦長のカードから横長の画面へ比が変わる途中で歪みます。中身ごと広げるなら、幅と高さを補間します。位置は開くたびに測ります(スクロールや画面の大きさで変わるため)。広がりきったら位置と大きさの指定を外して CSS に任せ、閉じるときに測り直すと、開いているあいだに画面の大きさが変わっても、元のカードへぴったり戻ります。

詳細はダイアログの要素に入れて showModal で開くので、焦点の閉じ込めと後ろの締め出しはブラウザに任せられます。× を詳細の先頭に置くと、開いたとき焦点がまず × に入ります。Esc は cancel を止めて同じ動きで閉じ、閉じたら押したカードへ焦点を戻します。狭い画面では、画像を上に、文字をその下に並べます。

つまみは、広がる時間とイージング、詳細の文字が出始める時刻、ほかのカードを下げる濃さです。ページを移る遷移にするなら、閉じるボタンの代わりに詳細ページの URL へ進めます。ポートフォリオの一覧から作品の詳細へ、商品カードから詳しい説明へ、記事カードから本文へ、といった場面に向いています。

コード

HTML
<section class="works" aria-labelledby="works-title">
  <div class="works__head">
    <h2 class="works__title" id="works-title">制作実績</h2>
    <p class="works__range">2024〜2026年</p>
  </div>

  <ul class="works__grid">
    <li class="work">
      <!-- カード。押すと、この位置と大きさから下の詳細が広がる。--tint は写真が届くまでの地の色 -->
      <button class="work__card" type="button" aria-haspopup="dialog" style="--tint: #d9ba92">
        <span class="work__art"><img src="/img/work-harbor.jpg" alt="" width="1280" height="1440"></span>
        <span class="work__meta">
          <span><span class="work__tag">ブランディング</span> <span class="work__name">しおさい荘</span></span>
          <span class="work__year">2026年</span>
        </span>
      </button>
      <!-- 詳細。showModal() で開くと、焦点の閉じ込めと Esc が付いてくる。× を先に置き、開いたら焦点がまず × に入る -->
      <dialog class="work__detail" aria-labelledby="work-harbor">
        <button class="work__close" type="button" aria-label="閉じる"></button>
        <div class="work__text">
          <p class="work__kicker">ブランディング・2026年</p>
          <h3 class="work__title" id="work-harbor">しおさい荘</h3>
          <p class="work__lead">港町の宿のリニューアル</p>
          <p class="work__body">ここに説明文が入ります。</p>
          <dl class="work__facts">
            <div><dt>担当</dt><dd>ロゴ・印刷物</dd></div>
            <div><dt>チーム</dt><dd>4名</dd></div>
            <div><dt>公開</dt><dd>2026年</dd></div>
          </dl>
          <a class="work__link" href="/work/harbor/">事例を詳しく見る →</a>
        </div>
      </dialog>
    </li>

    <li class="work">
      <button class="work__card" type="button" aria-haspopup="dialog" style="--tint: #475c6a">
        <span class="work__art"><img src="/img/work-orbit.jpg" alt="" width="1280" height="1440"></span>
        <span class="work__meta">
          <span><span class="work__tag">プロダクトデザイン</span> <span class="work__name">ひより</span></span>
          <span class="work__year">2026年</span>
        </span>
      </button>
      <dialog class="work__detail" aria-labelledby="work-orbit">
        <button class="work__close" type="button" aria-label="閉じる"></button>
        <div class="work__text">
          <p class="work__kicker">プロダクトデザイン・2026年</p>
          <h3 class="work__title" id="work-orbit">ひより</h3>
          <p class="work__lead">予定管理アプリの画面設計</p>
          <p class="work__body">ここに説明文が入ります。</p>
          <dl class="work__facts">
            <div><dt>担当</dt><dd>アプリ・Web</dd></div>
            <div><dt>チーム</dt><dd>6名</dd></div>
            <div><dt>公開</dt><dd>2026年</dd></div>
          </dl>
          <a class="work__link" href="/work/orbit/">事例を詳しく見る →</a>
        </div>
      </dialog>
    </li>

    <li class="work">
      <button class="work__card" type="button" aria-haspopup="dialog" style="--tint: #eddcd8">
        <span class="work__art"><img src="/img/work-bloom.jpg" alt="" width="1280" height="1440"></span>
        <span class="work__meta">
          <span><span class="work__tag">キャンペーン</span> <span class="work__name">花めぐり</span></span>
          <span class="work__year">2025年</span>
        </span>
      </button>
      <dialog class="work__detail" aria-labelledby="work-bloom">
        <button class="work__close" type="button" aria-label="閉じる"></button>
        <div class="work__text">
          <p class="work__kicker">キャンペーン・2025年</p>
          <h3 class="work__title" id="work-bloom">花めぐり</h3>
          <p class="work__lead">春の新作キャンペーン</p>
          <p class="work__body">ここに説明文が入ります。</p>
          <dl class="work__facts">
            <div><dt>担当</dt><dd>広告・SNS</dd></div>
            <div><dt>チーム</dt><dd>8名</dd></div>
            <div><dt>公開</dt><dd>2025年</dd></div>
          </dl>
          <a class="work__link" href="/work/bloom/">事例を詳しく見る →</a>
        </div>
      </dialog>
    </li>
  </ul>
</section>

CSS
.works {
  --ground: #f2f1ed;  /* 地 */
  --surface: #ffffff; /* カードと詳細の面 */
  --ink: #262626;     /* 文字と、× の線 */
  --mute: #6b6b6b;    /* 補足の文字 */
  --line: rgba(38, 38, 38, 0.12); /* 区切り線 */
  --radius: 4px;      /* カードの角。広がると 0 になる */
  --gutter: clamp(16px, 7.5vw, 96px);
  padding: 64px var(--gutter);
  background: var(--ground);
  color: var(--ink);
}

/* 開いているあいだは後ろをスクロールさせない。バーの幅を先に空けておき、カードの位置がずれないようにする */
html { scrollbar-gutter: stable; }
html:has(.work__detail[open]) { overflow: hidden; }

.works__head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: last baseline; gap: 16px 32px; margin-bottom: 48px; }
.works__title { margin: 0; font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.works__range { margin: 0; color: var(--mute); font-size: 14px; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
.works__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 32px; margin: 0; padding: 0; list-style: none; }

/* カード(白い面の上 7 割に画像、下に分類・作品名・年)。開くときは、この画像と文字を写した面(.work__panel)が広がる */
.work__card,
.work__panel { position: relative; overflow: hidden; background: var(--surface); color: var(--ink); }
.work__card { display: block; width: 100%; aspect-ratio: 341 / 448; padding: 0; border: 0; border-radius: var(--radius); font: inherit; text-align: left; cursor: pointer; }
.work__card:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

.work__art { position: absolute; top: 0; left: 0; width: 100%; height: 70%; }
.work__art img { display: block; width: 100%; height: 100%; object-fit: cover; background: var(--tint); }
.work__meta { position: absolute; left: 24px; right: 24px; bottom: 24px; display: flex; justify-content: space-between; align-items: last baseline; gap: 16px; }
.work__tag { display: block; color: var(--mute); font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.06em; }
.work__name { display: block; margin-top: 8px; font-size: 24px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.work__year { color: var(--mute); font-size: 14px; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }

/* 詳細。dialog を画面いっぱいに広げ、地は透明にする(広がる途中は、後ろの一覧が見える) */
.work__detail {
  width: 100%;
  max-width: none;
  height: 100%;
  max-height: none;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: var(--ink);
  overflow: hidden;
}

.work__detail::backdrop { background: none; }

/* 広がりきったときの見た目。画像は左半分、文字は右半分(JS はここへ向けて補間する) */
.work__panel { position: absolute; inset: 0; }
.work__panel .work__art { width: 50%; height: 100%; }
.work__text { position: absolute; inset: 0 0 0 50%; overflow-y: auto; padding: clamp(96px, 20vh, 144px) var(--gutter) 64px 64px; }

.work__kicker { margin: 0; color: var(--mute); font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.06em; }
.work__title { margin: 16px 0 0; font-size: clamp(44px, 7vw, 72px); font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; }
.work__lead { margin: 32px 0 0; font-size: clamp(20px, 2.4vw, 24px); font-weight: 700; line-height: 1.4; letter-spacing: 0.04em; }
.work__body { margin: 16px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }
/* 和文を文節の切れ目で折り返す(対応していないブラウザでは、ふつうに折り返す) */
.work__lead,
.work__body { word-break: auto-phrase; }

.work__facts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin: 32px 0 0; padding-top: 16px; border-top: 1px solid var(--line); }
.work__facts dt { color: var(--mute); font-size: 13px; letter-spacing: 0.04em; }
.work__facts dd { margin: 4px 0 0; font-size: 16px; font-weight: 600; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; white-space: nowrap; }

.work__link { display: inline-block; margin-top: 32px; color: inherit; font-size: 15px; font-weight: 600; letter-spacing: 0.04em; text-underline-offset: 0.4em; }

/* 閉じるボタン(線の丸)。画像の上に来る狭い画面でも読めるよう、面の色で塗る。× は線 2 本で描く */
.work__close {
  position: absolute;
  top: 32px;
  right: var(--gutter);
  z-index: 1;
  width: 48px;
  height: 48px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  box-shadow: inset 0 0 0 1px var(--ink);
  background: var(--surface);
  cursor: pointer;
}

.work__close::before,
.work__close::after { content: ""; position: absolute; inset: 23px 16px; border-radius: 1px; background: var(--ink); rotate: 45deg; }
.work__close::after { rotate: -45deg; }
.work__close:focus-visible,
.work__link:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

/* 狭い画面では、画像を上 4 割に、文字をその下に置く */
@media (max-width: 767px) {
  .work__panel .work__art { width: 100%; height: 40%; }
  .work__text { inset: 40% 0 0; padding: 32px var(--gutter) 48px; }
  .work__facts { grid-template-columns: repeat(3, auto); justify-content: start; column-gap: 24px; }
}

JavaScript
import { gsap } from "gsap";

const OPEN = 0.9; // 広がる時間(秒)。閉じるときは同じ動きを逆にたどる
const EASE = "power3.inOut";
// 動きを減らす設定では、ほぼ一瞬で切り替える
const speed = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 20 : 1;

document.querySelectorAll(".work").forEach((work) => {
  const card = work.querySelector(".work__card");
  const dialog = work.querySelector(".work__detail");
  const close = dialog.querySelector(".work__close");
  const parts = dialog.querySelectorAll(".work__text > *");
  const others = [...work.parentElement.querySelectorAll(".work__card")].filter((other) => other !== card);

  // 広がる面。カードの画像と文字を写して dialog のいちばん奥に敷く(同じ中身なので、読み上げには出さない)
  const panel = document.createElement("div");
  panel.className = "work__panel";
  panel.setAttribute("aria-hidden", "true");
  panel.append(card.querySelector(".work__art").cloneNode(true), card.querySelector(".work__meta").cloneNode(true));
  dialog.prepend(panel);
  const [art, meta] = panel.children;
  const moved = "left,top,width,height,borderRadius,opacity,transform"; // GSAP が書く値(カードの --tint は残す)
  let motion = null;

  // FLIP: カードの位置と大きさ(First)と、CSS が決めた広がりきった見た目(Last)を測り、その間を補間する。
  // scale ではなく left・top・width・height で動かすので、中の画像と文字が歪まない。位置は開くたびに測る
  function build() {
    gsap.set([panel, art], { clearProps: moved });
    const from = card.getBoundingClientRect();
    const fromArt = card.querySelector(".work__art").getBoundingClientRect();
    const to = panel.getBoundingClientRect();
    const toArt = art.getBoundingClientRect();
    return gsap
      .timeline({
        paused: true,
        // 広がりきったら CSS の見た目に任せる(画面の大きさが変わっても合う)。閉じきったら dialog を閉じる
        onComplete: () => gsap.set([panel, art], { clearProps: "left,top,width,height,borderRadius" }),
        onReverseComplete: () => dialog.close(),
      })
      .fromTo(panel, { left: from.left, top: from.top, width: from.width, height: from.height, borderRadius: getComputedStyle(card).borderRadius },
        { left: to.left, top: to.top, width: to.width, height: to.height, borderRadius: 0, duration: OPEN, ease: EASE }, 0)
      // 画像はカードの上側から、詳細の左半分へ
      .fromTo(art, { width: fromArt.width, height: fromArt.height }, { width: toArt.width, height: toArt.height, duration: OPEN, ease: EASE }, 0)
      // 文字は補間しない。カードの文字はすぐ消し、詳細の文字は広がりきる少し前から下から出す
      .fromTo(meta, { opacity: 1 }, { opacity: 0, duration: 0.2, ease: "none" }, 0)
      .fromTo(others, { opacity: 1, scale: 1 }, { opacity: 0.35, scale: 0.95, duration: 0.5, ease: "power2.out" }, 0)
      .fromTo(parts, { opacity: 0, y: 28 }, { opacity: 1, y: 0, duration: 0.5, ease: "power3.out", stagger: 0.06 }, OPEN * 0.62)
      .fromTo(close, { opacity: 0, scale: 0.6, rotation: -90 }, { opacity: 1, scale: 1, rotation: 0, duration: 0.5, ease: "back.out(1.8)" }, OPEN * 0.7)
      .timeScale(speed);
  }

  card.addEventListener("click", () => {
    dialog.showModal(); // 先に開いてから、広がりきった見た目を測る。焦点は × へ移る
    motion?.kill();
    motion = build().play();
  });

  // 同じ道をたどって、押したカードの位置へ戻す。広がりきったあとなら測り直す(画面の大きさが変わっていても合う)
  function shut() {
    if (!motion.isActive()) {
      motion.kill();
      motion = build().progress(1, true);
    }
    motion.reverse();
  }

  close.addEventListener("click", shut);
  // Esc は既定だとその場で閉じるので、いったん止めて同じ動きで閉じる
  dialog.addEventListener("cancel", (event) => {
    event.preventDefault();
    shut();
  });
  // 閉じたら元に戻し、焦点を押したカードへ返す(Esc を続けて押すと、動きを待たずに閉じることもある)
  dialog.addEventListener("close", () => {
    motion?.kill();
    gsap.set([panel, art, meta, close, ...parts, ...others], { clearProps: moved });
    card.focus({ preventScroll: true });
  });
});

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

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