デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- 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 回目のタップはパネルを開くだけにします。狭い画面では縦に積み、高さで開きます。
つまみは、開いたパネルの倍率、幅が入れ替わる時間、文字が出始めるまでの遅れです。開いたままにしたいなら、並びから出たときに戻す処理を外します。旅行やサービスの紹介、事例やチームの紹介、カテゴリの入口に向いています。
コード
<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>
.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; }
}
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);
});
});