デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
写真に filter で白黒と暗さをかけておき、カーソルが重なったら元の色と明るさへ補間します。同時に、写真の枠の下端の外に隠しておいた SNS のアイコンの丸を、0.06 秒ずつずらして下からせり上げます。動きは止めたタイムライン 1 本にまとめ、重なったら play()、離れたら reverse() します。
白黒にするだけでは明るさが変わらないので、休みの写真は明るさを 72% に落としておきます。こうすると、ホバーしたメンバーだけが暗い列から浮かび上がって見えます。filter の値は、休みとホバーで関数の数と並び(grayscale → brightness)をそろえます。そろっていれば、GSAP が数ごとに補間します。アイコンは 0.45 秒の back.out で、止まる前に少し行き過ぎさせています。
色づきの ease を out 系にすると、逆にたどったときに出だしがゆっくりになり、隣のカードへ移ったあとも前の人の色がしばらく残って、2 人とも色づいて見えます。行きと帰りで同じ形になる sine.inOut にし、離れたときは timeScale で入るときの倍の速さにして戻します。
アイコンは隠れているあいだも Tab で届くリンクのままにし、焦点がカードの中へ入ったら(focusin)同じ動きで出します。ホバーの無いタッチ端末では、はじめから色とアイコンを見せます。アイコンは汎用の線の形にしているので、案件では各サービスのロゴに差し替えます。
カードを裏返して紹介文を見せる形と違い、写真の色とアイコンだけで反応を返すので、一覧の並びも高さも変わりません。明るさを 0.5 近くまで下げると、ホバーした人がより強く浮かびます。アイコンのずらしを 0.1 秒にすると、1 つずつ出る感じがはっきりします。会社案内・チーム紹介・登壇者の一覧に向いています。
コード
<section class="team" aria-labelledby="team-title">
<p class="team__label">OUR TEAM</p>
<h2 class="team__title" id="team-title">メンバー紹介</h2>
<!-- アイコンの形は 1 か所に書き、use で使い回す(案件では各サービスのロゴに差し替える) -->
<svg width="0" height="0" aria-hidden="true" style="position: absolute">
<symbol id="team-icon-chat" viewBox="0 0 24 24"><path d="M5 5h14v10h-8l-4 4v-4H5z" /></symbol>
<symbol id="team-icon-camera" viewBox="0 0 24 24"><rect x="3" y="7" width="18" height="13" rx="3" /><circle cx="12" cy="13.5" r="3.5" /><path d="M8.5 7 10 4h4l1.5 3" /></symbol>
<symbol id="team-icon-globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3c3 3.2 3 14.8 0 18M12 3c-3 3.2-3 14.8 0 18" /></symbol>
<symbol id="team-icon-mail" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2.5" /><path d="m4 7 8 6 8-6" /></symbol>
</svg>
<ul class="team__grid">
<!-- --tint は写真が届くまでの地の色 -->
<li class="member" style="--tint: #d99590">
<div class="member__photo">
<img src="/img/team-takahashi.jpg" alt="" width="496" height="704">
<!-- アイコンは隠れていても Tab で届く。焦点が入ったら、ホバーと同じように出す -->
<ul class="member__social" aria-label="高橋 奈央さんのSNSと連絡先">
<li><a href="https://example.com/@takahashi" aria-label="SNS"><svg aria-hidden="true"><use href="#team-icon-chat" /></svg></a></li>
<li><a href="https://example.com/takahashi/photos" aria-label="写真"><svg aria-hidden="true"><use href="#team-icon-camera" /></svg></a></li>
<li><a href="https://example.com/takahashi" aria-label="ウェブサイト"><svg aria-hidden="true"><use href="#team-icon-globe" /></svg></a></li>
<li><a href="mailto:sample@higashiyuki.com" aria-label="メール"><svg aria-hidden="true"><use href="#team-icon-mail" /></svg></a></li>
</ul>
</div>
<h3 class="member__name">高橋 奈央</h3>
<p class="member__role">アートディレクター</p>
</li>
<li class="member" style="--tint: #93b2c9">
<div class="member__photo">
<img src="/img/team-mori.jpg" alt="" width="496" height="704">
<ul class="member__social" aria-label="森 海斗さんのSNSと連絡先">
<li><a href="https://example.com/@mori" aria-label="SNS"><svg aria-hidden="true"><use href="#team-icon-chat" /></svg></a></li>
<li><a href="https://example.com/mori/photos" aria-label="写真"><svg aria-hidden="true"><use href="#team-icon-camera" /></svg></a></li>
<li><a href="https://example.com/mori" aria-label="ウェブサイト"><svg aria-hidden="true"><use href="#team-icon-globe" /></svg></a></li>
<li><a href="mailto:sample@higashiyuki.com" aria-label="メール"><svg aria-hidden="true"><use href="#team-icon-mail" /></svg></a></li>
</ul>
</div>
<h3 class="member__name">森 海斗</h3>
<p class="member__role">UIデザイナー</p>
</li>
<li class="member" style="--tint: #a2c49f">
<div class="member__photo">
<img src="/img/team-ishida.jpg" alt="" width="496" height="704">
<ul class="member__social" aria-label="石田 恵理さんのSNSと連絡先">
<li><a href="https://example.com/@ishida" aria-label="SNS"><svg aria-hidden="true"><use href="#team-icon-chat" /></svg></a></li>
<li><a href="https://example.com/ishida/photos" aria-label="写真"><svg aria-hidden="true"><use href="#team-icon-camera" /></svg></a></li>
<li><a href="https://example.com/ishida" aria-label="ウェブサイト"><svg aria-hidden="true"><use href="#team-icon-globe" /></svg></a></li>
<li><a href="mailto:sample@higashiyuki.com" aria-label="メール"><svg aria-hidden="true"><use href="#team-icon-mail" /></svg></a></li>
</ul>
</div>
<h3 class="member__name">石田 恵理</h3>
<p class="member__role">フロントエンドエンジニア</p>
</li>
</ul>
</section>
.team {
--ground: #111111; /* 地 */
--ink: #eeece6; /* 名前と見出し */
--mute: #a8a6a0; /* 肩書きとラベル */
--chip: #ffffff; /* アイコンの丸 */
--chip-ink: #111111; /* アイコンの線 */
/* 写真の休みとホバーの filter。数と並びをそろえると、GSAP が数ごとに補間する */
--rest: grayscale(1) brightness(0.72);
--hover: grayscale(0) brightness(1);
padding: 64px clamp(16px, 6vw, 96px);
background: var(--ground);
color: var(--ink);
}
.team__label { margin: 0; color: var(--mute); font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; }
.team__title { margin: 16px 0 48px; font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.team__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 32px;
margin: 0;
padding: 0;
list-style: none;
}
/* アイコンはこの枠の下端の外に隠す */
.member__photo {
position: relative;
aspect-ratio: 31 / 44;
border-radius: 4px;
overflow: hidden;
}
.member__photo img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
background: var(--tint);
filter: var(--rest);
}
.member__social {
position: absolute;
left: 16px;
bottom: 16px;
display: flex;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.member__social a {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--chip);
color: var(--chip-ink);
}
.member__social svg {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
.member__social a:focus-visible {
outline: 2px solid var(--chip);
outline-offset: 3px;
}
.member__name { margin: 20px 0 4px; font-size: 24px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.member__role { margin: 0; color: var(--mute); font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; }
/* ホバーの無い端末(指で触る)では、はじめから色とアイコンを見せる */
@media (hover: none) {
.member__photo img { filter: none; }
}
import { gsap } from "gsap";
const LEAVE = 2; // 離れたときに戻る速さ(入るときの倍率)。隣へ移ったとき、前の人の色が残らない
const canHover = window.matchMedia("(hover: hover)").matches;
// 動きを減らす設定では、ほぼ一瞬で切り替える
const speed = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 20 : 1;
// 指で触る端末では、色とアイコンをはじめから見せたままにする(CSS の hover: none)
if (canHover) {
document.querySelectorAll(".member").forEach((member) => {
const style = getComputedStyle(member);
const photo = member.querySelector(".member__photo img");
const icons = member.querySelectorAll(".member__social a");
// 写真の下端の外まで下げておく(アイコンの大きさ+下の余白)
const rise = icons[0].offsetHeight + parseFloat(getComputedStyle(icons[0].closest("ul")).bottom);
const hover = gsap
.timeline({ paused: true })
// 行きと帰りで同じ形になる inOut の ease(out を逆にたどると、離れてもしばらく色が残る)
.fromTo(photo, { filter: style.getPropertyValue("--rest").trim() }, { filter: style.getPropertyValue("--hover").trim(), duration: 0.6, ease: "sine.inOut" }, 0)
.fromTo(icons, { y: rise }, { y: 0, duration: 0.45, ease: "back.out(1.6)", stagger: 0.06 }, 0.08);
const show = () => hover.timeScale(speed).play();
const hide = (event) => {
// カーソルもキーボードの焦点もカードの外へ出たときだけ戻す
if (member.matches(":hover") || member.querySelector(":focus-visible") || member.contains(event.relatedTarget)) return;
hover.timeScale(speed * LEAVE).reverse();
};
member.addEventListener("pointerenter", show);
member.addEventListener("pointerleave", hide);
// キーボードでアイコンへ移ったときも、ホバーと同じように出す
member.addEventListener("focusin", show);
member.addEventListener("focusout", hide);
});
}