デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
カーソルが重なったら、overflow: hidden の枠の中で画像を 1.14 倍へゆっくり寄せ、下半分に暗いグラデーションを重ねて、分類と作品名を行の下から持ち上げます。続けて右下の矢印の丸を、回しながら大きくして出します。動きは止めたタイムライン 1 本にまとめ、重なったら play()、離れたら reverse() します。
画像の寄りは 0.9 秒、キャプションの行は 0.6 秒で 0.06 秒ずつずらし、どれも ease は power2.inOut です。out 系の ease や行き過ぎる back.out を逆にたどると、戻り始めがゆっくりになり、隣の作品へ移ったあとも前のキャプションが残って、2 枚とも出て見えます。離れたときは timeScale で入るときの倍の速さにして戻します。
キャプションの各行は枠で包んではみ出しを隠し、中身を yPercent 110 から 0 へ動かします。沈めた初めの位置を CSS の transform に書くと、GSAP がそれを px として読み込み、yPercent と二重になって出てこなくなります。初めの位置は、タイムラインの fromTo に置かせます。
カード全体を 1 つのリンクにし、名前は作品名だけ、分類は説明として読ませます。キーボードで焦点が入ったときも同じ動きにし、動きを減らす設定ではほぼ一瞬で切り替えます。ホバーの無いタッチ端末では、キャプションをはじめから見せたままにします。
寄りを 1.05 前後にすると上品に、大きくすると派手になります。暗いグラデーションは文字を読ませるためのものなので、明るい画像ほど濃くします。ポートフォリオの一覧・商品のグリッド・記事カードに向いています。
コード
<section class="works" aria-labelledby="works-title">
<div class="works__head">
<h2 class="works__title" id="works-title">最近の制作実績</h2>
<a class="works__all" href="/works/">すべての作品を見る →</a>
</div>
<ul class="works__grid">
<li>
<!-- カード全体が 1 つのリンク。名前は作品名だけにし、分類は説明として読ませる -->
<a class="work" href="/works/seaside-house/" aria-labelledby="work-1-name" aria-describedby="work-1-kind">
<img class="work__image" src="/img/seaside-house.jpg" alt="" width="704" height="896">
<span class="work__shade"></span>
<span class="work__index" aria-hidden="true">01</span>
<span class="work__caption">
<!-- 行ごとに overflow: hidden の枠で包み、中身を行の下から持ち上げる -->
<span class="work__line"><span class="work__kind" id="work-1-kind">インテリア</span></span>
<span class="work__line"><span class="work__name" id="work-1-name">海辺の平屋</span></span>
<span class="work__button" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="M7 17 17 7M9 7h8v8" /></svg></span>
</span>
</a>
</li>
<li>
<a class="work" href="/works/night-garden/" aria-labelledby="work-2-name" aria-describedby="work-2-kind">
<img class="work__image" src="/img/night-garden.jpg" alt="" width="704" height="896">
<span class="work__shade"></span>
<span class="work__index" aria-hidden="true">02</span>
<span class="work__caption">
<span class="work__line"><span class="work__kind" id="work-2-kind">エディトリアル</span></span>
<span class="work__line"><span class="work__name" id="work-2-name">夜の庭</span></span>
<span class="work__button" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="M7 17 17 7M9 7h8v8" /></svg></span>
</span>
</a>
</li>
<li>
<!-- 明るい画像の上は、番号を墨色にする -->
<a class="work work--light" href="/works/paper-suns/" aria-labelledby="work-3-name" aria-describedby="work-3-kind">
<img class="work__image" src="/img/paper-suns.jpg" alt="" width="704" height="896">
<span class="work__shade"></span>
<span class="work__index" aria-hidden="true">03</span>
<span class="work__caption">
<span class="work__line"><span class="work__kind" id="work-3-kind">展覧会</span></span>
<span class="work__line"><span class="work__name" id="work-3-name">紙の太陽</span></span>
<span class="work__button" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="M7 17 17 7M9 7h8v8" /></svg></span>
</span>
</a>
</li>
</ul>
</section>
.works {
--ground: #111111; /* 地 */
--ink: #eeece6; /* 見出しとリンク */
--on-image: #ffffff; /* 暗いグラデーションの上の題 */
--on-image-mute: #dedcd6; /* 同じく分類のラベル */
--on-light: #262626; /* 明るい画像の上の番号 */
--button: #ffffff; /* 矢印の丸 */
--button-ink: #111111; /* 丸の上の矢印 */
--zoom: 1.14; /* ホバー中の画像の寄り */
padding: 64px clamp(16px, 6vw, 96px);
background: var(--ground);
color: var(--ink);
}
.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__all { color: inherit; font-size: 15px; font-weight: 600; letter-spacing: 0.04em; text-decoration-thickness: 1px; text-underline-offset: 0.4em; }
.works__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 32px;
margin: 0;
padding: 0;
list-style: none;
}
/* 画像はこの枠の中で寄るので、枠の大きさは変わらない */
.work {
position: relative;
display: block;
aspect-ratio: 11 / 14;
overflow: hidden;
border-radius: 4px;
background: #2a2a2a;
color: var(--on-image);
text-decoration: none;
}
.work:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
.work__image { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
/* キャプションを読ませる暗いグラデーション。明るい画像ほど濃くする */
.work__shade { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(17, 17, 17, 0) 40%, rgba(17, 17, 17, 0.78) 100%); }
.work__index {
position: absolute;
left: 24px;
top: 24px;
font-size: 13px;
font-weight: 700;
line-height: 1;
letter-spacing: 0.14em;
font-variant-numeric: tabular-nums;
}
.work--light .work__index { color: var(--on-light); }
.work__caption {
position: absolute;
left: 24px;
right: 24px;
bottom: 24px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
column-gap: 16px;
}
/* 行の枠。中身は GSAP で行の下へ沈めておく(初めの位置を CSS の transform に書くと、GSAP が px として読んで二重になる) */
.work__line { display: block; grid-column: 1; overflow: hidden; }
.work__line:first-child { margin-bottom: 8px; }
.work__line > span { display: block; }
.work__kind { color: var(--on-image-mute); font-size: 13px; font-weight: 700; line-height: 1.2; letter-spacing: 0.06em; }
.work__name { font-size: 24px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.work__button {
display: grid;
grid-column: 2;
grid-row: 1 / span 2;
place-items: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--button);
color: var(--button-ink);
}
.work__button svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
import { gsap } from "gsap";
const LEAVE = 2; // 離れたときに戻る速さ(入るときの倍率)。隣へ移ったとき、前のキャプションが残って 2 枚とも出て見えない
// 指で触る端末ではホバーが無いので、キャプションをはじめから見せたままにする(CSS の初めの見た目のまま)
const canHover = window.matchMedia("(hover: hover)").matches;
// 動きを減らす設定では、ほぼ一瞬で切り替える
const speed = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 20 : 1;
if (canHover) {
document.querySelectorAll(".work").forEach((work) => {
const zoom = parseFloat(getComputedStyle(work).getPropertyValue("--zoom"));
// どれも行きと帰りで同じ形になる inOut の ease にする。out や back.out を逆にたどると、
// 戻り始めがゆっくりになり(back.out はいったん跳ねてから沈む)、隣へ移っても前のキャプションが残る
const hover = gsap
.timeline({ paused: true })
.fromTo(work.querySelector(".work__image"), { scale: 1 }, { scale: zoom, duration: 0.9, ease: "power2.inOut" }, 0)
.fromTo(work.querySelector(".work__shade"), { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.5, ease: "power2.inOut" }, 0)
.fromTo(work.querySelectorAll(".work__line > span"), { yPercent: 110 }, { yPercent: 0, duration: 0.6, ease: "power2.inOut", stagger: 0.06 }, 0.05)
.fromTo(work.querySelector(".work__button"), { scale: 0, rotation: -45 }, { scale: 1, rotation: 0, duration: 0.5, ease: "power2.inOut" }, 0.2);
const show = () => hover.timeScale(speed).play();
const hide = () => {
// カーソルもキーボードの焦点もカードから外れたときだけ戻す
if (work.matches(":hover") || work.matches(":focus-visible")) return;
hover.timeScale(speed * LEAVE).reverse();
};
work.addEventListener("pointerenter", show);
work.addEventListener("pointerleave", hide);
// キーボードで送ったときも、ホバーと同じようにキャプションを出す
work.addEventListener("focus", show);
work.addEventListener("blur", hide);
});
}