Member Grid (Collage Portraits)
A team roster that introduces members without a single photograph. Each person is a four-layer paper collage — a halftone dot patch, a hairline frame, an initials disc and a tilted caption strip — that fans open on hover and focus-within. Grouped with a single rule and negative space instead of cards, revealed once in sequence via IntersectionObserver. No images, no dependencies; people, roles and quotes are fictional placeholders.
- Added:
- 2026-08-15
- Dependencies:
- None
- tags
- #member #team #profile #collage #halftone #hairline #reveal #no-image
Preview
BLK-26 — Members / メンバー
顔ではなく、
仕事の輪郭で
紹介します。
人数が少ないので、だれが何を持っているかは全部わかります。写真の代わりに、担当している範囲と考え方をそのまま置いておきます。
- ディレクション
要件整理・進行管理
在籍 6 年
決める前に、現場のことばへ戻します。
N.K の紹介 - フロントエンド
実装・表示速度
在籍 4 年
速さより、読み直せる実装を選びます。
T.S の紹介 - デザイン
情報設計・UI設計
在籍 5 年
線を1本引く理由まで説明できるように。
M.A の紹介 - 運用サポート
更新代行・改善提案
在籍 2 年
公開したあとが、いちばん長い時間です。
Y.R の紹介 - ライティング
取材・原稿作成
在籍 3 年
聞いた話を、短くするのが仕事です。
H.W の紹介 - バックエンド
API・データ連携
在籍 1 年
見えない側を、見える速さに変えます。
I.F の紹介
担当は案件ごとに組み替えます。だれが入るかは、最初の打ち合わせでお伝えします。
チームの紹介を読む"use client";
import { useEffect, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["500", "600", "700"],
subsets: ["latin"],
display: "swap",
});
/**
* メンバー紹介グリッド(顔写真ゼロ・印刷標本のコラージュ) / Member Grid — Collage Portraits
* 参考: 採用・チーム紹介系のページに見られる「顔写真に頼らない人物表現」の構成を着想として取り込み、
* 配色・文言・レイアウトは First CH ブランド(白基調+アンバー #d97706)で全面的に再構築している。
* 人物・役割・ひとことはすべて架空のプレースホルダー。画像素材ゼロ・依存ゼロ。
*
* 汎用技法メモ(web-design-playbook 還流用):
* - **網点(ハーフトーン)パッチは radial-gradient 1枚で作れる**: `background-image` に
* `radial-gradient(<color> 1.05px, transparent 1.15px)`、`background-size: 6px 6px` を敷くだけ。
* 写真の位置に「印刷物の網点」を置くと、素材ゼロでも人物の"枠"として読める。ドット径は
* background-size の 1/5 前後が印刷風で、1/3 を超えるとパターン柄(模様)に見えて人物性が消える。
* - **コラージュの奥行きは rotate の差分だけで出す**: 各層に -3° / +6° / 0° と異なる基準角を与え、
* hover では「開く」方向へ角度だけをずらす。translate を動かすと層の重なりが崩れやすいが、
* rotate なら重心が動かないので破綻しない。Tailwind v4 は rotate / translate / scale が
* 個別プロパティなので、**基準の translate を保ったまま rotate だけをアニメーションできる**。
* - **傾けて重ねた白い帯は「貼った紙」に見える**: 白地+hairline+微回転のキャプション帯を
* コラージュの下端に重ね、hover で rotate-0 に戻すと「手で貼り直した」ような反応になる。
* 面を増やさずに情報(役割)を1つ足せるので、カードを増やさずに済む。
* - **hover の等価物を group-focus-within で必ず用意する**: 装飾的な動きであっても、
* キーボードだけの利用者に同じ状態変化が起きるようにする(group-hover と必ず対で書く)。
* - **脱カード**: 各メンバーを箱で囲わず、上罫(border-t)1本と余白だけでグルーピングする。
* 囲いが無くても、コラージュの塊が視覚的な単位になるので迷わない。
*/
type Member = {
/** イニシャル(実名を置かない人物表現の核) */
initials: string;
/** キャプション帯に載せる役割 */
role: string;
/** 担当領域 */
field: string;
tenure: string;
voice: string;
/** イニシャル円のやわらかい色面 */
tone: string;
/** 網点パッチの色 */
dot: string;
};
const members: Member[] = [
{
initials: "N.K",
role: "ディレクション",
field: "要件整理・進行管理",
tenure: "在籍 6 年",
voice: "決める前に、現場のことばへ戻します。",
tone: "bg-amber-100",
dot: "#f59e0b",
},
{
initials: "T.S",
role: "フロントエンド",
field: "実装・表示速度",
tenure: "在籍 4 年",
voice: "速さより、読み直せる実装を選びます。",
tone: "bg-orange-100",
dot: "#fb923c",
},
{
initials: "M.A",
role: "デザイン",
field: "情報設計・UI設計",
tenure: "在籍 5 年",
voice: "線を1本引く理由まで説明できるように。",
tone: "bg-stone-200",
dot: "#a8a29e",
},
{
initials: "Y.R",
role: "運用サポート",
field: "更新代行・改善提案",
tenure: "在籍 2 年",
voice: "公開したあとが、いちばん長い時間です。",
tone: "bg-amber-50",
dot: "#fbbf24",
},
{
initials: "H.W",
role: "ライティング",
field: "取材・原稿作成",
tenure: "在籍 3 年",
voice: "聞いた話を、短くするのが仕事です。",
tone: "bg-orange-50",
dot: "#fdba74",
},
{
initials: "I.F",
role: "バックエンド",
field: "API・データ連携",
tenure: "在籍 1 年",
voice: "見えない側を、見える速さに変えます。",
tone: "bg-stone-100",
dot: "#d6d3d1",
},
];
export default function MemberGrid() {
const rootRef = useRef<HTMLElement>(null);
const [shown, setShown] = useState<ReadonlySet<string>>(new Set());
// リビールは「セクション単位」ではなく「要素ごとに1度だけ」。
// 縦に長いグリッドをセクション単位の threshold で判定すると、375px 幅(実測 2,700px 超)では
// 初期位置でしきい値に届かず、画面内にあるメンバーが opacity:0 のまま残る。
// reduced-motion 対応は各要素の motion-reduce: variant で完結する(JS判定は不要)。
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const targets = Array.from(
el.querySelectorAll<HTMLElement>("[data-reveal]"),
);
const keys = targets.map((t) => t.dataset.reveal ?? "");
if (typeof IntersectionObserver === "undefined") {
setShown(new Set(keys));
return;
}
const io = new IntersectionObserver(
(entries) => {
const hit: string[] = [];
for (const e of entries) {
if (!e.isIntersecting) continue;
hit.push((e.target as HTMLElement).dataset.reveal ?? "");
io.unobserve(e.target);
}
if (hit.length) setShown((prev) => new Set([...prev, ...hit]));
},
{ rootMargin: "0px 0px -8% 0px" },
);
targets.forEach((t) => io.observe(t));
return () => io.disconnect();
}, []);
const reveal = (key: string) =>
`transition-[opacity,transform] duration-700 ease-out motion-reduce:transition-none ${
shown.has(key)
? "translate-y-0 opacity-100"
: "translate-y-6 opacity-0 motion-reduce:translate-y-0 motion-reduce:opacity-100"
}`;
// 層ごとの「開く」動き。hover と focus-within を必ず対で書く
const layer =
"transition-transform duration-500 ease-out motion-reduce:transition-none";
return (
<section
ref={rootRef}
aria-labelledby="member-grid-heading"
className="@container w-full max-w-4xl overflow-hidden bg-white px-1 py-2"
>
{/* ヘッダー */}
<header className="flex flex-wrap items-start justify-between gap-x-8 gap-y-4">
{/* 見出しは折り返し位置を自分で決めたいので幅を絞らない。絞るのはリードだけ */}
{/* basis-full: 幅が足りないとき flex-basis 0 だと折り返さずに縮んで見出しが割れる(広いときだけ横並び) */}
<div className="min-w-0 grow basis-full @xl:basis-0">
<p
data-reveal="eyebrow"
className={`${montserrat.className} text-[11px] font-bold tracking-[0.25em] text-amber-600 uppercase ${reveal("eyebrow")}`}
>
BLK-26 — Members / メンバー
</p>
<h2
id="member-grid-heading"
data-reveal="heading"
style={{ transitionDelay: "80ms" }}
className={`mt-4 text-[clamp(1.6rem,4cqi,2.5rem)] leading-[1.4] font-bold tracking-tight text-gray-900 ${reveal("heading")}`}
>
{/* 和文の改行は成り行きに任せず、意図した位置で <br> を置く */}
顔ではなく、
<br />
<span className="relative inline-block whitespace-nowrap">
仕事の輪郭
<span
aria-hidden="true"
className={`absolute -bottom-1 left-0 block h-[3px] w-full origin-left bg-amber-500/70 transition-transform duration-[900ms] delay-200 ease-out motion-reduce:transition-none motion-reduce:scale-x-100 ${
shown.has("heading") ? "scale-x-100" : "scale-x-0"
}`}
/>
</span>
で
{/* 幅が狭いと2行目が「…紹介しま / す。」と割れるので、意図した位置で折る */}
<br className="@md:hidden" />
紹介します。
</h2>
{/* 和文段落はソース上も1行で書く(折ると「。 ど」のように句点の後だけ空く) */}
<p
data-reveal="lead"
style={{ transitionDelay: "160ms" }}
className={`mt-6 max-w-md text-[15px] leading-[1.9] font-medium text-gray-600 ${reveal("lead")}`}
>
人数が少ないので、だれが何を持っているかは全部わかります。写真の代わりに、担当している範囲と考え方をそのまま置いておきます。
</p>
</div>
<div
data-reveal="count"
style={{ transitionDelay: "220ms" }}
className={`flex shrink-0 items-baseline gap-2 @xl:ml-auto @xl:pt-1 ${reveal("count")}`}
>
<span
className={`${montserrat.className} text-[30px] leading-none font-bold text-gray-900 tabular-nums`}
>
06
</span>
<span
className={`${montserrat.className} text-[11px] font-semibold tracking-[0.2em] text-gray-400 uppercase`}
>
members
</span>
</div>
</header>
{/* メンバー(カードで囲わず、上罫1本と余白でグルーピング) */}
<ul
role="list"
className="mt-12 grid grid-cols-1 gap-x-8 gap-y-12 @md:grid-cols-2 @2xl:grid-cols-3"
>
{members.map((m, i) => (
<li
key={m.initials}
data-reveal={`m${i}`}
// 行内で左から順に立ち上がるよう、列位置ぶんだけ遅らせる(最大3列)。
// 通し番号で遅らせると、下の行が画面に入った時点で長い待ちが発生する。
style={{ transitionDelay: `${(i % 3) * 90}ms` }}
className={`group border-t border-gray-200 pt-7 ${reveal(`m${i}`)}`}
>
{/* コラージュ(網点パッチ → 細線フレーム → イニシャル円 → キャプション帯) */}
<div className="relative h-[126px] w-full max-w-[220px]">
<span
aria-hidden="true"
style={{
backgroundImage: `radial-gradient(${m.dot} 1.05px, transparent 1.15px)`,
backgroundSize: "6px 6px",
}}
className={`absolute top-3 left-7 block h-[86px] w-[86px] -rotate-3 opacity-70 group-hover:-rotate-[9deg] group-focus-within:-rotate-[9deg] ${layer}`}
/>
<span
aria-hidden="true"
className={`absolute top-6 left-[3.5rem] block h-[86px] w-[86px] rotate-6 border border-gray-300 group-hover:rotate-12 group-focus-within:rotate-12 ${layer}`}
/>
<span
aria-hidden="true"
className={`absolute top-0 left-0 flex h-[84px] w-[84px] items-center justify-center rounded-full ${m.tone} group-hover:scale-105 group-focus-within:scale-105 ${layer}`}
>
<span
className={`${montserrat.className} text-[17px] font-bold tracking-[0.08em] text-gray-800`}
>
{m.initials}
</span>
</span>
<span
aria-hidden="true"
className={`${montserrat.className} absolute top-1 right-1 text-[10px] font-bold text-gray-300 tabular-nums`}
>
{String(i + 1).padStart(2, "0")}
</span>
<span
className={`absolute bottom-0 left-3 inline-flex -rotate-2 items-center gap-2 border border-gray-200 bg-white px-2.5 py-1 group-hover:rotate-0 group-focus-within:rotate-0 ${layer}`}
>
<span aria-hidden="true" className="block h-px w-3 bg-amber-600" />
<span className="text-[11px] font-semibold whitespace-nowrap text-gray-800">
{m.role}
</span>
</span>
</div>
<p className="mt-6 text-[15px] font-semibold text-gray-900">
{m.field}
</p>
{/* 和文に英字向けの広いトラッキングを掛けると字間が割れるので弱めに留める */}
<p className="mt-1 text-[11px] font-semibold tracking-[0.04em] text-amber-600 tabular-nums">
{m.tenure}
</p>
<p className="mt-4 border-l border-amber-300 pl-3 text-[13px] leading-[1.9] text-balance text-gray-600">
{m.voice}
</p>
<a
href="#"
className="group/link mt-5 inline-flex items-center gap-1.5 text-[12px] font-semibold text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
>
<span className="relative">
{m.initials} の紹介
<span
aria-hidden="true"
className="absolute -bottom-1 left-0 block h-px w-full origin-left scale-x-0 bg-amber-600 transition-transform duration-300 ease-out group-hover/link:scale-x-100 motion-reduce:transition-none"
/>
</span>
<span
aria-hidden="true"
className="inline-block transition-transform duration-300 ease-out group-hover/link:translate-x-1 motion-reduce:transition-none"
>
→
</span>
</a>
</li>
))}
</ul>
{/* 導線 */}
<div
data-reveal="outro"
className={`mt-14 flex flex-col items-start gap-4 border-t border-gray-200 pt-8 @md:flex-row @md:items-center @md:justify-between ${reveal("outro")}`}
>
<p className="text-[13px] leading-[1.9] text-gray-500">
担当は案件ごとに組み替えます。だれが入るかは、最初の打ち合わせでお伝えします。
</p>
<a
href="#"
className="group/all inline-flex shrink-0 items-center gap-3 text-[15px] font-semibold text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
>
<span className="relative">
チームの紹介を読む
<span
aria-hidden="true"
className="absolute -bottom-1 left-0 block h-px w-full origin-left scale-x-100 bg-gray-900 transition-transform duration-300 ease-out group-hover/all:scale-x-0 motion-reduce:transition-none"
/>
<span
aria-hidden="true"
className="absolute -bottom-1 left-0 block h-px w-full origin-right scale-x-0 bg-amber-600 transition-transform delay-150 duration-300 ease-out group-hover/all:origin-left group-hover/all:scale-x-100 motion-reduce:transition-none"
/>
</span>
<span
aria-hidden="true"
className="inline-block transition-transform duration-300 ease-out group-hover/all:translate-x-1 motion-reduce:transition-none"
>
→
</span>
</a>
</div>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/member-grid.json