製造・金属加工2026.06
工場見学の予約導線を、問い合わせの主役に
設備写真を並べる会社案内をやめ、見学予約を軸に再編。図面ダウンロードの手前で担当者の顔と連絡先を出し、比較検討中の相手を取りこぼさない構成にした。
4件13件+225%
月間問い合わせ数
担当した工程: 設計 / 制作
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.
BLK-31 — Works / Showcase
商品一覧のように、サムネ・ラベル・タイトル・数字の順で読ませます。価格が置かれる場所には、 公開後に動いた指標を before → after で並べました。
6 / cases
新着順で全6件を表示中。先頭の1件を大きく表示しています。
製造・金属加工2026.06
設備写真を並べる会社案内をやめ、見学予約を軸に再編。図面ダウンロードの手前で担当者の顔と連絡先を出し、比較検討中の相手を取りこぼさない構成にした。
4件13件+225%
月間問い合わせ数
担当した工程: 設計 / 制作
"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>
);
}
npx shadcn@latest add https://designs.first-ch.com/r/commerce-grid-layout.json