First CH Designs
BLK-28Blocks

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枚しかない案件でも成立します。

抽象ビジュアル(画像素材ゼロ)。実案件では同じ枠に写真1カットを入れます。
  1. 01Website

    コーポレートサイト制作

    情報設計からデザイン・実装まで一貫して。伝わる構造と、公開後に育てられる土台を同時に用意します。

    情報設計デザイン実装詳しく見る
  2. 02Landing Page

    ランディングページ制作

    成果から逆算した構成とコピーで、見た人を次の一歩へ。速度・可読性・計測導線まで数字で語れる面をつくります。

    構成設計コピー計測導線詳しく見る
  3. 03Operation

    保守・改善運用

    つくって終わりにしない。公開後もデータを見ながら、更新・改善・AI活用で軽く回り続ける運用へ。

    保守改善提案AI活用詳しく見る

掲載しているサービス名・説明はサンプルです。件数は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