First CH Designs
BLK-31Blocks

Works — Asymmetric Showcase Grid

A works index that borrows the grid split and information hierarchy of an online-shop product listing and re-targets it at case studies. In a three-column grid the first entry alone spans 2×2 while five smaller cells fill the remaining slots, so a 3×3 field is exactly filled. Each cell has an image-free plate (project type + specimen number lockup) instead of a thumbnail, a status label where a NEW/SALE badge would sit, a before→after KPI with a change chip where the price would sit, and filled/outlined dots for the phases we handled where colour swatches would sit. Sorting (newest / impact) swaps which entry occupies the 2×2 hero slot. Breakpoints are container queries, not viewport ones, so the layout holds inside narrow embeds. One stretched link per cell (no nested anchors); motion is transform/opacity only and fully degraded under prefers-reduced-motion.

Added:
2026-08-19
Dependencies:
None
tags
#block #works #case-study #grid #asymmetric #bento #container-query #sort #kpi #stretched-link #no-image #no-dependency

Preview

BLK-31 — Works / Showcase

数字で並べ替えられる実績一覧。

商品一覧のように、サムネ・ラベル・タイトル・数字の順で読ませます。価格が置かれる場所には、 公開後に動いた指標を before → after で並べました。

6 / cases

並び替え

新着順で全6件を表示中。先頭の1件を大きく表示しています。

"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import { Montserrat } from "next/font/google";

const montserrat = Montserrat({
  weight: ["600", "700", "800"],
  subsets: ["latin"],
  display: "swap",
});

/**
 * 実績ショーケース(不揃いグリッド) / Works — Asymmetric Showcase Grid
 * 通販サイトの商品一覧に見られる「グリッド分割」と「情報の詰め方の優先順位」を、
 * 実績・事例の情報設計へ置き換えて再構築したブロック。文言・配色・構成はすべてオリジナル、
 * 外部コード・素材の取り込みはなし。画像素材ゼロ・ライブラリ依存ゼロ。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - 不揃いグリッド(1件だけ 2×2): 3カラムの grid に対し先頭の1件だけ col-span-2 row-span-2 を与えると、
 *   3×3=9セルを「大1+小5」でちょうど埋められる。並び替えで先頭が入れ替わると主役の枠も入れ替わり、
 *   「一番押したい1件」を構造で表現できる(カードを同じ大きさで並べるより一覧に主従が生まれる)。
 * - ビューポートではなくコンテナで折り返す: 親に @container を付け、@md: / @2xl: のコンテナクエリで
 *   1→2→3カラムに切り替える。埋め込み先の幅が狭い(プレビュー枠・サイドバー内など)ときに、
 *   ビューポート基準の sm:/lg: では起きる「広い画面なのに箱は狭い」ズレが構造的に起きない。
 * - 価格の位置に成果指標: 商品一覧の視線は サムネ→ラベル→商品名→価格 の順に落ちる。この最後の「価格」を
 *   KPI(before → after + 変化率チップ)に置換すると、実績一覧が読み物ではなく「比較できる一覧」になる。
 *   数字は tabular-nums、参考価格の位置に before を小さい灰色で置くと変化量が一目で読める。
 * - カラースウォッチの転用: 商品の色バリエーション(小さな丸の列)を「担当した工程」の塗り/中抜きに置換。
 *   同じ形で違う意味を運べるので、EC由来の見た目を残したまま非ECの情報を載せられる。
 * - stretched link: セル内のリンクは h3 の <a> 1つだけにして after:absolute after:inset-0 でセル全面を
 *   クリック領域にする。<a> の入れ子を作らずにカード全体を押せる(クイックアクションの丸は装飾=aria-hidden)。
 * - 並び替えはアニメーションさせない: 並び順が変わる操作はレイアウトの再計算そのものなので、transform/opacity で
 *   表現できない。トランジションを付けず即時に差し替え、変化は aria-live のテキストで伝える。
 * - モーションは transform/opacity のみ・すべて motion-reduce ガード。リビールは IntersectionObserver で
 *   1回だけ発火させ、reduced-motion 環境では初期状態=最終状態にする。
 */

type Work = {
  id: string;
  code: string;
  kind: string;
  industry: string;
  title: string;
  excerpt: string;
  metric: string;
  before: string;
  after: string;
  lift: number;
  liftLabel: string;
  badge: string | null;
  phases: [boolean, boolean, boolean];
  added: string;
  shape: number;
};

const PHASES = ["設計", "制作", "運用"] as const;

const works: Work[] = [
  {
    id: "w-2601",
    code: "W-2601",
    kind: "SITE",
    industry: "製造・金属加工",
    title: "工場見学の予約導線を、問い合わせの主役に",
    excerpt:
      "設備写真を並べる会社案内をやめ、見学予約を軸に再編。図面ダウンロードの手前で担当者の顔と連絡先を出し、比較検討中の相手を取りこぼさない構成にした。",
    metric: "月間問い合わせ数",
    before: "4件",
    after: "13件",
    lift: 225,
    liftLabel: "+225%",
    badge: "新着",
    phases: [true, true, false],
    added: "2026-06",
    shape: 0,
  },
  {
    id: "w-2555",
    code: "W-2555",
    kind: "LP",
    industry: "医療・歯科",
    title: "初診予約の離脱を、3ステップで止める",
    excerpt: "症状別の入口と費用の目安を先に見せ、入力項目を7から4へ。",
    metric: "予約完了率",
    before: "4.2%",
    after: "11.8%",
    lift: 181,
    liftLabel: "+181%",
    badge: null,
    phases: [false, true, true],
    added: "2026-05",
    shape: 1,
  },
  {
    id: "w-2512",
    code: "W-2512",
    kind: "SITE",
    industry: "建設・リフォーム",
    title: "施工事例を、更新できる資産にする",
    excerpt: "現場写真の投稿をスマホで完結。担当者が週1で更新できる実績データベースに。",
    metric: "平均滞在時間",
    before: "1:12",
    after: "3:04",
    lift: 155,
    liftLabel: "+155%",
    badge: "継続支援中",
    phases: [true, true, true],
    added: "2026-04",
    shape: 2,
  },
  {
    id: "w-2488",
    code: "W-2488",
    kind: "FORM",
    industry: "教育・スクール",
    title: "体験申込フォームの入力を、途中でやめさせない",
    excerpt: "住所の自動入力と一時保存を追加し、長い同意文は開閉式に。",
    metric: "体験申込の完了数",
    before: "11件",
    after: "26件",
    lift: 136,
    liftLabel: "+136%",
    badge: null,
    phases: [false, true, false],
    added: "2026-03",
    shape: 3,
  },
  {
    id: "w-2440",
    code: "W-2440",
    kind: "SHOP",
    industry: "食品・産直",
    title: "贈答セットの回遊をつくる特集ページ",
    excerpt: "用途別の入口と同梱提案を足し、単品購入から詰め合わせへ。",
    metric: "平均客単価",
    before: "3,480円",
    after: "5,120円",
    lift: 47,
    liftLabel: "+47%",
    badge: null,
    phases: [true, true, false],
    added: "2026-02",
    shape: 1,
  },
  {
    id: "w-2398",
    code: "W-2398",
    kind: "SITE",
    industry: "士業・社会保険労務",
    title: "専門用語をやめたら、相談が増えた",
    excerpt: "サービス名を悩みの言葉に置換し、料金の考え方を先出し。",
    metric: "資料ダウンロード数",
    before: "8件",
    after: "41件",
    lift: 413,
    liftLabel: "+413%",
    badge: "継続支援中",
    phases: [true, false, true],
    added: "2026-01",
    shape: 0,
  },
];

type SortKey = "new" | "impact";

const SORTS: { key: SortKey; label: string }[] = [
  { key: "new", label: "新着順" },
  { key: "impact", label: "成果順" },
];

/** プレート(サムネ代替)の装飾図形。index ではなく work.shape で固定し、並び替えで形が飛ばないようにする */
function PlateShape({ shape }: { shape: number }) {
  const common =
    "pointer-events-none absolute transition-transform duration-500 ease-out motion-reduce:transition-none";
  if (shape === 0) {
    return (
      <span
        aria-hidden="true"
        className={`${common} -top-8 -left-8 size-28 rounded-full border-[8px] border-amber-500/25 group-hover:translate-x-1.5 group-hover:translate-y-1.5`}
      />
    );
  }
  if (shape === 1) {
    return (
      <span
        aria-hidden="true"
        className={`${common} -right-6 -bottom-6 size-24 rotate-12 bg-amber-500/15 group-hover:rotate-[24deg]`}
      />
    );
  }
  if (shape === 2) {
    return (
      <span
        aria-hidden="true"
        className={`${common} top-1/2 -left-2 h-20 w-3 -translate-y-1/2 bg-amber-500/25 group-hover:translate-x-2 group-hover:-translate-y-1/2`}
      />
    );
  }
  return (
    <span
      aria-hidden="true"
      className={`${common} -top-10 right-6 h-24 w-24 -rotate-12 border-y-[8px] border-amber-500/25 group-hover:-translate-y-1.5`}
    />
  );
}

/** 担当した工程(商品一覧のカラースウォッチの位置・見た目を転用) */
function PhaseSwatch({ phases }: { phases: Work["phases"] }) {
  const taken = PHASES.filter((_, i) => phases[i]);
  return (
    <p className="mt-3 flex items-center gap-2">
      <span aria-hidden="true" className="flex items-center gap-1">
        {phases.map((on, i) => (
          <span
            key={i}
            className={`size-2.5 rounded-full border ${
              on ? "border-gray-900 bg-gray-900" : "border-gray-300 bg-white"
            }`}
          />
        ))}
      </span>
      <span className="text-[11px] leading-none font-medium text-gray-400">
        <span className="sr-only">担当した工程: </span>
        {taken.join(" / ")}
      </span>
    </p>
  );
}

/** 価格の位置に置く成果指標(before → after + 変化率チップ) */
function MetricLine({ work, large = false }: { work: Work; large?: boolean }) {
  return (
    <div className="mt-3">
      <p className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
        <span
          className={`${montserrat.className} text-[11px] font-semibold text-gray-400 tabular-nums line-through decoration-gray-300`}
        >
          {work.before}
        </span>
        <span
          className={`${montserrat.className} font-extrabold tracking-tight text-gray-900 tabular-nums ${
            large ? "text-[28px]" : "text-[21px]"
          }`}
        >
          {work.after}
        </span>
        <span
          className={`${montserrat.className} rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-bold text-amber-700 tabular-nums`}
        >
          {work.liftLabel}
        </span>
      </p>
      <p className="mt-1 text-[11px] leading-none text-gray-500">{work.metric}</p>
    </div>
  );
}

export default function CommerceGridLayout() {
  const [sort, setSort] = useState<SortKey>("new");
  const [active, setActive] = useState(false);
  const rootRef = useRef<HTMLDivElement>(null);

  // 画面内に入ったら1回だけリビール(reduced-motion 時は motion-reduce: で初期状態=最終状態)
  useEffect(() => {
    const el = rootRef.current;
    if (!el || 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.15 },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);

  // 並び替えは順序の差し替えそのもの(レイアウト再計算)なのでトランジションを付けない
  const sorted = useMemo(
    () =>
      [...works].sort((a, b) =>
        sort === "new" ? b.added.localeCompare(a.added) : b.lift - a.lift,
      ),
    [sort],
  );

  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"
  }`;
  const delay = (ms: number) => ({ transitionDelay: `${ms}ms` });

  return (
    <section
      ref={rootRef}
      aria-labelledby="cgl-heading"
      className="@container w-full max-w-6xl bg-white px-1"
    >
      {/* 宣言 */}
      <div className="max-w-2xl">
        <p
          style={delay(0)}
          className={`${montserrat.className} ${reveal} text-xs font-bold tracking-[0.25em] text-amber-600 uppercase`}
        >
          BLK-31 — Works / Showcase
        </p>
        <h2
          id="cgl-heading"
          style={delay(80)}
          className={`${reveal} mt-4 text-[clamp(1.8rem,4vw,2.6rem)] leading-[1.25] font-bold tracking-tight text-gray-900`}
        >
          数字で
          <span className="relative inline-block">
            <span className="relative z-10">並べ替えられる</span>
            <span
              aria-hidden="true"
              className="absolute -bottom-1 left-0 h-[3px] w-full bg-amber-500/70"
            />
          </span>
          実績一覧。
        </h2>
        <p
          style={delay(160)}
          className={`${reveal} mt-5 text-[15px] leading-[1.9] font-medium text-gray-600`}
        >
          商品一覧のように、サムネ・ラベル・タイトル・数字の順で読ませます。価格が置かれる場所には、
          公開後に動いた指標を before → after で並べました。
        </p>
      </div>

      {/* ツールバー(商品一覧の「並び替え」行の置換) */}
      <div
        style={delay(240)}
        className={`${reveal} mt-8 flex flex-wrap items-center justify-between gap-3 border-y border-gray-200 py-3`}
      >
        <p className={`${montserrat.className} text-[12px] font-semibold text-gray-500 tabular-nums`}>
          <span className="text-gray-900">{works.length}</span>
          <span className="text-gray-400"> / cases</span>
        </p>
        <div className="flex items-center gap-2 whitespace-nowrap">
          <span className="text-[11px] font-semibold text-gray-400">並び替え</span>
          <div className="flex items-center gap-1 rounded-full bg-gray-100 p-1">
            {SORTS.map((s) => (
              <button
                key={s.key}
                type="button"
                aria-pressed={sort === s.key}
                onClick={() => setSort(s.key)}
                className={`rounded-full px-3 py-1 text-[12px] font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 ${
                  sort === s.key
                    ? "bg-white text-gray-900 shadow-[0_1px_2px_rgba(17,24,39,0.12)]"
                    : "text-gray-500 hover:text-gray-900"
                }`}
              >
                {s.label}
              </button>
            ))}
          </div>
        </div>
        <p role="status" aria-live="polite" className="sr-only">
          {sort === "new" ? "新着順" : "成果順"}で全{works.length}件を表示中。先頭の1件を大きく表示しています。
        </p>
      </div>

      {/* 不揃いグリッド: 3カラム × 3行を「大1(2×2)+小5」で埋める */}
      <ul className="mt-8 grid grid-cols-1 gap-x-5 gap-y-8 @md:grid-cols-2 @2xl:grid-cols-3">
        {sorted.map((w, i) => {
          const feature = i === 0;
          return (
            <li
              key={w.id}
              style={delay(320 + i * 70)}
              className={`${reveal} ${
                feature ? "@md:col-span-2 @2xl:row-span-2" : ""
              }`}
            >
              <article className="group relative flex h-full flex-col border-b border-gray-200 pb-6">
                {/* プレート(サムネの代替・画像ゼロ) */}
                <div
                  className={`relative overflow-hidden rounded-lg border border-gray-200 bg-gray-50 ${
                    feature
                      ? "min-h-[190px] flex-1 @2xl:max-h-[400px] @2xl:min-h-[240px]"
                      : "aspect-[4/3]"
                  }`}
                >
                  <span
                    aria-hidden="true"
                    className="absolute inset-0"
                    style={{
                      backgroundImage:
                        "repeating-linear-gradient(45deg, rgba(17,24,39,0.045) 0 1px, transparent 1px 9px)",
                    }}
                  />
                  <PlateShape shape={w.shape} />

                  {/* ロックアップ(制作種別+標本番号) */}
                  <span className="absolute inset-0 flex flex-col items-center justify-center transition-transform duration-500 ease-out group-hover:scale-[1.04] motion-reduce:transition-none">
                    <span
                      className={`${montserrat.className} font-extrabold tracking-[0.2em] text-gray-900/85 ${
                        feature ? "text-[26px] @2xl:text-[34px]" : "text-[19px]"
                      }`}
                    >
                      {w.kind}
                    </span>
                    <span
                      className={`${montserrat.className} mt-1.5 text-[10px] font-semibold tracking-[0.3em] text-gray-400 tabular-nums`}
                    >
                      {w.code}
                    </span>
                  </span>

                  {/* ラベル(商品一覧のNEW/SALEバッジの位置) */}
                  {w.badge && (
                    <span
                      className={`absolute top-3 left-3 rounded-full px-2.5 py-0.5 text-[11px] font-semibold ${
                        w.badge === "新着"
                          ? "bg-amber-600 text-white"
                          : "border border-gray-300 bg-white/90 text-gray-600"
                      }`}
                    >
                      {w.badge}
                    </span>
                  )}

                  {/* クイックアクション(商品一覧のカート追加ボタンの位置・装飾) */}
                  <span
                    aria-hidden="true"
                    className="absolute right-3 bottom-3 grid size-9 translate-y-0 place-items-center rounded-full bg-gray-900 text-[13px] text-white opacity-100 transition-[opacity,transform] duration-300 ease-out motion-reduce:transition-none @md:translate-y-2 @md:opacity-0 @md:group-hover:translate-y-0 @md:group-hover:opacity-100 @md:group-focus-within:translate-y-0 @md:group-focus-within:opacity-100"
                  >
                    →
                  </span>
                </div>

                {/* 情報(サムネ→ラベル→タイトル→数字の順に落とす) */}
                <div className="mt-4 flex flex-col">
                  <p className="flex items-center gap-2 text-[11px] font-semibold text-gray-500">
                    <span>{w.industry}</span>
                    <span aria-hidden="true" className="text-gray-300">
                      /
                    </span>
                    <span className={`${montserrat.className} text-gray-400 tabular-nums`}>
                      {w.added.replace("-", ".")}
                    </span>
                  </p>

                  <h3
                    className={`mt-2 leading-[1.55] font-bold text-gray-900 ${
                      feature
                        ? "text-[17px] @2xl:text-[19px]"
                        : "line-clamp-2 text-[14px] @md:min-h-[3.1em]"
                    }`}
                  >
                    <a
                      href="#"
                      className="after:absolute after:inset-0 after:content-[''] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
                    >
                      {w.title}
                    </a>
                  </h3>

                  {feature && (
                    <p className="mt-3 max-w-xl text-[13px] leading-[1.85] text-gray-500">
                      {w.excerpt}
                    </p>
                  )}

                  <MetricLine work={w} large={feature} />
                  <PhaseSwatch phases={w.phases} />
                </div>

                {/* フィーチャーの余白を埋める擬似リンク行(実リンクは h3 の1本だけ=aria-hidden の装飾) */}
                {feature && (
                  <p
                    aria-hidden="true"
                    className="mt-auto flex items-center gap-2 pt-6 text-[12px] font-semibold text-amber-700"
                  >
                    この事例をみる
                    <span className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1 motion-reduce:transition-none">
                      →
                    </span>
                  </p>
                )}
              </article>
            </li>
          );
        })}
      </ul>

      {/* CTA(下線ワイプ) */}
      <div style={delay(760)} className={`${reveal} mt-10`}>
        <a
          href="#"
          className="group inline-flex items-center gap-3 text-sm 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 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>
    </section>
  );
}

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/commerce-grid-layout.json