Service — Stacked Info Cards over a Tall Visual
A services block that stacks explanation cards onto a single tall visual instead of listing them as a 3-up card grid. Each card shifts its horizontal offset, width, and overlap with the previous one; depth comes from static z-index and transform only, so nothing reflows. The left visual is built from a gradient plane, a hairline ring, a diagonal band, a blurred disc, and a dot grid — zero image assets — and fixes the frame so a real photo can drop in later without touching the layout. From lg up the visual sticks while the cards scroll past. Use it when a project has only one or two photos, as the counterpart to the alternating layout (BLK-04) and the card grid (CRD-01). Every motion, including the IntersectionObserver stagger, collapses to its final state under prefers-reduced-motion.
- Added:
- 2026-08-17
- Dependencies:
- None
- tags
- #block #service #stacked #overlap #sticky #hairline #gradient #scroll-reveal #no-image
Preview
BLK-28 — Service / サービス
面は1枚。そこに、積む。
カットを増やさずに密度をつくる構図。縦長のビジュアル1枚を背にして、提供内容を1枚ずつ位置と幅をずらして重ねます。写真が1〜2枚しかない案件でも成立します。
掲載しているサービス名・説明はサンプルです。件数は3〜4件までが読みやすく、5件以上では一覧性のある構成を選びます。
"use client";
import { useEffect, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["500", "600", "700", "800"],
subsets: ["latin"],
display: "swap",
});
/**
* サービス紹介(縦長ビジュアル×積層インフォカード) / Service — Stacked Info Cards over a Tall Visual
* レイアウト構造の着想のみを取り込み、配色・文言・構成は First CH ブランド(白基調+アンバー #d97706)で
* 全面的に再構築している。サービス名・数値はすべて架空のプレースホルダー。画像素材ゼロ・依存ゼロ。
*
* 汎用技法メモ(web-design-playbook 還流用):
* - **「面を分けず、1枚に積む」**: サービスを N 件のカードに分けて並べる(羅列)でも、左右交互に組む
* (ジグザグ)でもなく、**縦長ビジュアル1枚の上へ説明カードを重ねる**。写真が1〜2カットしかない案件で
* 密度を作れるのが本命の効能で、カット数を増やさずにスクロールの「発見」を作れる。
* - **ずらすのは x・幅・縦の重なりの3つ**: カードを同じ位置に積むと単なる箱の列に戻る。1枚ずつ
* ①水平オフセット(translate-x)②幅(w-[..%])③直前カードとの食い込み(-mt-*)を変えると、
* 同じ部品のまま「置かれた紙」に見える。値は配列に**リテラルのクラス文字列**として持つ
* (動的に組み立てると Tailwind のスキャンから外れて出力されない)。
* - **重なりは z-index、動きは transform**: 重ね順は DOM 順+静的な z で決め、hover では
* `-translate-y-1` と z の引き上げだけ行う。レイアウトを動かさないのでリフローが起きない。
* - **影は「浮かせる」のではなく「置く」**: ぼかしを広く・不透明度を低く(`0_18px_40px_-30px`)すると、
* カードが飛び出さず面の上に静かに乗る。強い影は積層の枚数だけノイズが増える。
* - **ビジュアルは素材ゼロで代替できる**: グラデーション面+幾何形(リング・対角バンド・ぼかし円・
* 四分円)+ドット方眼の重ね合わせで「写真の枠」を先に確定させておくと、実案件で写真へ差し替える
* ときにレイアウトを触らずに済む(aspect-* を保ったまま <img> を入れるだけ)。
* - **sticky は lg 以上だけ**: ビジュアルを `lg:sticky lg:top-10` にすると、カードを読み進める間だけ
* 面が留まって「1枚に積んでいる」ことが伝わる。モバイルでは高さを食うので付けない。
*/
const services = [
{
idx: "01",
en: "Website",
title: "コーポレートサイト制作",
body: "情報設計からデザイン・実装まで一貫して。伝わる構造と、公開後に育てられる土台を同時に用意します。",
meta: ["情報設計", "デザイン", "実装"],
// ①水平オフセット ②幅 ③直前との食い込み を1件ずつ変える(リテラル文字列で持つ)
offset: "z-[1] mr-auto w-[94%] lg:w-[88%] lg:-translate-x-14",
gap: "mt-0",
},
{
idx: "02",
en: "Landing Page",
title: "ランディングページ制作",
body: "成果から逆算した構成とコピーで、見た人を次の一歩へ。速度・可読性・計測導線まで数字で語れる面をつくります。",
meta: ["構成設計", "コピー", "計測導線"],
offset: "z-[2] ml-auto w-[92%] lg:w-[96%] lg:translate-x-0",
gap: "-mt-4 lg:-mt-5",
},
{
idx: "03",
en: "Operation",
title: "保守・改善運用",
body: "つくって終わりにしない。公開後もデータを見ながら、更新・改善・AI活用で軽く回り続ける運用へ。",
meta: ["保守", "改善提案", "AI活用"],
offset: "z-[3] mr-auto w-[96%] lg:w-[92%] lg:-translate-x-8",
gap: "-mt-4 lg:-mt-5",
},
];
export default function ServiceStackedVisual() {
const [active, setActive] = useState(false);
const rootRef = useRef<HTMLElement>(null);
// 画面内に入ったら1度だけリビール(IO 非対応環境は rAF で即時表示)
useEffect(() => {
const node = rootRef.current;
if (!node || typeof IntersectionObserver === "undefined") {
const id = requestAnimationFrame(() => setActive(true));
return () => cancelAnimationFrame(id);
}
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
setActive(true);
io.disconnect();
}
},
{ threshold: 0.2 },
);
io.observe(node);
return () => io.disconnect();
}, []);
const reveal = `transition-[opacity,transform] duration-700 ease-out motion-reduce:transition-none ${
active
? "translate-y-0 opacity-100"
: "translate-y-6 opacity-0 motion-reduce:translate-y-0 motion-reduce:opacity-100"
}`;
// ドット方眼(標本モチーフ・装飾なので aria-hidden 側に置く)
const dotGrid = {
backgroundImage:
"radial-gradient(circle, rgba(34,30,25,0.12) 1px, transparent 1px)",
backgroundSize: "14px 14px",
};
return (
<section
ref={rootRef}
aria-labelledby="svc-stack-heading"
className="w-full max-w-5xl bg-white px-1"
>
{/* ヘッダー */}
<header className="max-w-2xl">
<p
className={`${montserrat.className} text-xs font-bold tracking-[0.25em] text-amber-600 uppercase ${reveal}`}
style={{ transitionDelay: "0ms" }}
>
BLK-28 — Service / サービス
</p>
<h2
id="svc-stack-heading"
className={`mt-4 text-[clamp(1.8rem,4vw,2.6rem)] leading-[1.25] font-bold tracking-tight text-gray-900 ${reveal}`}
style={{ transitionDelay: "80ms" }}
>
面は1枚。
<span className="relative whitespace-nowrap">
そこに、積む。
<span
aria-hidden="true"
className="absolute -bottom-1 left-0 h-[3px] w-full bg-amber-500/70"
/>
</span>
</h2>
<p
className={`mt-5 text-[15px] leading-[1.9] font-medium text-gray-600 ${reveal}`}
style={{ transitionDelay: "160ms" }}
>
カットを増やさずに密度をつくる構図。縦長のビジュアル1枚を背にして、提供内容を1枚ずつ位置と幅をずらして重ねます。写真が1〜2枚しかない案件でも成立します。
</p>
</header>
{/* 積層本体 */}
<div className="relative mt-12 lg:grid lg:grid-cols-12 lg:items-start lg:gap-0">
{/* 縦長ビジュアル(実案件では同じ枠に写真を入れ替える) */}
<figure
className={`m-0 lg:col-span-6 lg:col-start-1 lg:row-start-1 ${reveal}`}
style={{ transitionDelay: "240ms" }}
>
<div className="lg:sticky lg:top-10">
<div
aria-hidden="true"
className="relative aspect-[3/4] w-full overflow-hidden rounded-sm border border-gray-200 sm:aspect-[4/5] lg:aspect-[3/5]"
>
{/* 下地グラデーション */}
<span className="absolute inset-0 bg-[linear-gradient(158deg,#f8fafc_0%,#eef2f6_40%,#fdf3e0_100%)]" />
{/* 対角のアンバー帯 */}
<span
className={`absolute top-[44%] -left-1/4 h-[34%] w-[150%] -rotate-[18deg] bg-[linear-gradient(90deg,rgba(217,119,6,0)_0%,rgba(217,119,6,0.18)_48%,rgba(217,119,6,0)_100%)] transition-transform duration-1000 ease-out motion-reduce:transition-none ${
active ? "translate-x-0" : "-translate-x-6"
}`}
/>
{/* 細線リング */}
<span
className={`absolute top-[13%] left-[11%] block size-[62%] rounded-full border border-amber-600/35 transition-transform duration-1000 ease-out motion-reduce:transition-none ${
active ? "scale-100" : "scale-90"
}`}
/>
{/* 四分円(グレー面) */}
<span className="absolute top-[7%] right-[9%] block size-[24%] rounded-bl-[100%] bg-gray-900/[0.06]" />
{/* ぼかしのアンバー円 */}
<span className="absolute -right-[10%] bottom-[10%] block size-[50%] rounded-full bg-amber-500/20 blur-[26px]" />
{/* ドット方眼の帯 */}
<span
className="absolute bottom-0 left-0 block h-[28%] w-full opacity-70"
style={dotGrid}
/>
{/* 縦組みラベル(構図の起点) */}
<span
className={`${montserrat.className} absolute top-5 left-4 text-[10px] font-semibold tracking-[0.3em] text-gray-500 uppercase [writing-mode:vertical-rl]`}
>
Service
</span>
<span className="absolute top-5 left-[26px] block h-10 w-px bg-amber-600/60" />
</div>
{/* 積層カードが右下へ食い込むので、キャプションは左側に収める */}
<figcaption className="mt-3 text-[11px] leading-[1.7] font-medium text-gray-400 lg:max-w-[70%]">
抽象ビジュアル(画像素材ゼロ)。実案件では同じ枠に写真1カットを入れます。
</figcaption>
</div>
</figure>
{/* 積層カード */}
{/* col-start が figure の列と重なるので row-start-1 を明示する(既定の auto-flow だと次の行へ送られ、重ならない) */}
<ol className="mt-8 flex list-none flex-col pl-0 lg:col-span-7 lg:col-start-6 lg:row-start-1 lg:mt-20">
{services.map((s, i) => (
<li
key={s.idx}
className={`group relative hover:z-10 ${s.offset} ${s.gap} ${reveal}`}
style={{ transitionDelay: `${320 + i * 110}ms` }}
>
<div className="relative rounded-sm border border-gray-200 bg-white px-6 py-7 shadow-[0_18px_40px_-30px_rgba(15,23,42,0.55)] transition-transform duration-500 ease-out group-hover:-translate-y-1 motion-reduce:transition-none sm:px-8 sm:py-8">
{/* 番号 + 英語ラベル + 罫線 */}
<div className="flex items-center gap-3">
<span
className={`${montserrat.className} text-[13px] font-bold tracking-[0.18em] text-amber-600 tabular-nums`}
>
{s.idx}
</span>
<span
className={`${montserrat.className} text-[11px] font-semibold tracking-[0.2em] text-gray-400 uppercase`}
>
{s.en}
</span>
<span
aria-hidden="true"
className="h-px flex-1 bg-gray-200 transition-colors duration-500 group-hover:bg-amber-500/50 motion-reduce:transition-none"
/>
</div>
<h3 className="mt-4 text-[19px] leading-[1.5] font-bold tracking-tight text-gray-900 sm:text-[21px]">
{s.title}
</h3>
<p className="mt-3 max-w-md text-[14px] leading-[1.9] font-medium text-gray-600">
{s.body}
</p>
<div className="mt-5 flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-gray-200 pt-4 text-[11px] font-semibold text-gray-500">
{s.meta.map((m, mi) => (
<span key={m} className="flex items-center gap-3">
{mi > 0 && (
<span aria-hidden="true" className="text-gray-300">
/
</span>
)}
{m}
</span>
))}
<a
href="#"
className="ml-auto inline-flex items-center gap-2 text-[12px] font-bold 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 h-px w-full origin-left scale-x-100 bg-gray-900 transition-transform duration-300 ease-out group-hover:scale-x-0 motion-reduce:transition-none"
/>
<span
aria-hidden="true"
className="absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-amber-600 transition-transform delay-150 duration-300 ease-out group-hover:origin-left group-hover:scale-x-100 motion-reduce:transition-none"
/>
</span>
<span
aria-hidden="true"
className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1 motion-reduce:transition-none"
>
→
</span>
</a>
</div>
</div>
</li>
))}
</ol>
</div>
{/* 注記 */}
<p
className={`mt-10 text-[11px] leading-[1.8] text-gray-400 ${reveal}`}
style={{ transitionDelay: "700ms" }}
>
掲載しているサービス名・説明はサンプルです。件数は3〜4件までが読みやすく、5件以上では一覧性のある構成を選びます。
</p>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/service-photo-stack.json