BLK-39Blocks
数字で見る会社(hairline格子インフォグラフィック)
会社紹介・採用ページ向けの「数字で見る◯◯」ブロック。創業年数・累計実績・継続率などを優劣なく等価に並べ、カードで囲まずに1pxの罫線だけで格子に束ねる。親を grid gap-px bg-gray-200・セルを bg-white にすることで、隙間そのものが罫線になり列数が変わっても二重線が出ない。数字は特大Montserratのtabular-nums、単位と注記は小さく従える。IntersectionObserverで画面内に入るとrAFで0→目標値へカウントアップし、同じ進捗をamberの計測バーのscaleXにも流して数字と罫線を同じ時間軸で伸ばす。列の折り返しはコンテナクエリ(@md/@2xl)なので狭い枠に埋め込んでも破綻しない。画像素材ゼロ、prefers-reduced-motion時はカウントせず最終値を即表示(2026-08-25追加)。
- 追加日:
- 2026-08-25
- 依存:
- なし
- tags
- #block #company #stats #counter #count-up #tabular-nums #hairline #grid #container-query #recruit #no-image
プレビュー
BLK-39 — Company / Numbers
数字で見る、わたしたちのこと。
言葉より先に、実績と姿勢を数字で。会社案内・採用ページの冒頭に置ける、飾らないファクトの一覧です。
- 創業からの年数
- 創業からの年数 12年Web制作ひと筋で積み上げた時間。
- 累計制作実績
- 累計制作実績 320件以上コーポレート・LP・保守運用の合計。
- 継続・再依頼率
- 継続・再依頼率 98パーセント公開後も伴走した結果としての数字。
- 最短公開までの日数
- 最短公開までの日数 14日LP1本・要件確定からの実績値。
- 支援した業種の数
- 支援した業種の数 26業種製造・医療・士業・小売など。
- 公開までの打ち合わせ
- 公開までの打ち合わせ 平均8回平均回数。決めきるまで話す。
※ 数値はサンプルです。実運用では集計時点(例: 2026年3月末時点)を必ず添えてください。
会社概要を見る"use client";
import { useEffect, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["600", "700", "800"],
subsets: ["latin"],
display: "swap",
});
/**
* 数字で見る会社(hairline格子インフォグラフィック)
* 会社紹介・採用ページ向け。創業年数・実績数・継続率などを等価に並べ、カードで囲まず
* 1pxの罫線だけで格子に束ねる。画面内に入ると数字がカウントアップする。
*
* 汎用技法メモ(web-design-playbook 還流用):
* - hairline格子: セルごとに border を振ると、列数がブレークポイントで変わったときに
* 端の罫線が二重になったり消えたりする。親を `grid gap-px bg-gray-200`、セルを `bg-white`
* にすると、隙間そのものが罫線になるので列数が変わっても常に1px・二重線なしで揃う。
* 外周は親側の border-y だけを足す(左右を開けると全幅の編集的な帯になる)。
* - コンテナクエリで組む: `@container` + `@md:` / `@2xl:` はビューポートではなく親幅で発火するので、
* 狭い枠に埋め込んだときも列数が破綻しない(`lg:` だと枠が狭くても3列に組まれて潰れる)。
* - カウントアップ: rAF で 0→target を easeOutCubic 補間し、同じ進捗 p を amber の計測バーの
* scaleX にも流す(数字と罫線が同じ時間軸で伸びる)。動かすのは transform/opacity のみ。
* display の初期値を target にしておくと SSR・no-JS・reduced-motion でそのまま最終値が出る。
* - tabular-nums 必須。無いとカウント中に桁幅が揺れて格子の縦位置がガタつく。
* - a11y: 変化する数字は aria-hidden にし、sr-only で最終値を1回だけ読ませる。
*/
type Metric = {
/** カウントの目標値 */
target: number;
decimals: number;
/** 数字の直後に大きめに添える記号(% など) */
suffix?: string;
/** 数字の後ろに小さく従える単位(件・年 など) */
unit?: string;
label: string;
caption: string;
/** スクリーンリーダー用の完成形テキスト */
sr: string;
};
const metrics: Metric[] = [
{
target: 12,
decimals: 0,
unit: "年",
label: "創業からの年数",
caption: "Web制作ひと筋で積み上げた時間。",
sr: "創業からの年数 12年",
},
{
target: 320,
decimals: 0,
suffix: "+",
unit: "件",
label: "累計制作実績",
caption: "コーポレート・LP・保守運用の合計。",
sr: "累計制作実績 320件以上",
},
{
target: 98,
decimals: 0,
suffix: "%",
label: "継続・再依頼率",
caption: "公開後も伴走した結果としての数字。",
sr: "継続・再依頼率 98パーセント",
},
{
target: 14,
decimals: 0,
unit: "日",
label: "最短公開までの日数",
caption: "LP1本・要件確定からの実績値。",
sr: "最短公開までの日数 14日",
},
{
target: 26,
decimals: 0,
unit: "業種",
label: "支援した業種の数",
caption: "製造・医療・士業・小売など。",
sr: "支援した業種の数 26業種",
},
{
target: 8,
decimals: 0,
unit: "回",
label: "公開までの打ち合わせ",
caption: "平均回数。決めきるまで話す。",
sr: "公開までの打ち合わせ 平均8回",
},
];
const COUNT_MS = 1500;
/** 0→target を rAF で補間し、進捗を計測バーの scaleX にも流すセル */
function MetricCell({
metric,
index,
active,
}: {
metric: Metric;
index: number;
active: boolean;
}) {
// 初期値=最終値。SSR・no-JS・reduced-motion ではこれがそのまま出る。
const [display, setDisplay] = useState(metric.target);
const [progress, setProgress] = useState(1);
useEffect(() => {
if (!active) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
// 開始のズラしは setTimeout ではなく rAF 内の経過時間で見る。
// effect 本体で同期的に setState しない(cascading render を避ける)ための型でもある。
const delayMs = 140 + index * 110;
let rafId = 0;
let start = 0;
const step = (now: number) => {
if (!start) start = now;
const p = Math.min(1, Math.max(0, (now - start - delayMs) / COUNT_MS));
const eased = 1 - Math.pow(1 - p, 3);
setDisplay(metric.target * eased);
setProgress(eased);
if (p < 1) {
rafId = requestAnimationFrame(step);
} else {
setDisplay(metric.target);
setProgress(1);
}
};
rafId = requestAnimationFrame(step);
return () => cancelAnimationFrame(rafId);
}, [active, index, metric.target]);
const shown = display.toLocaleString("en-US", {
minimumFractionDigits: metric.decimals,
maximumFractionDigits: metric.decimals,
});
return (
<div className="relative bg-white px-5 py-8 @md:px-6 @md:py-10 @2xl:px-8">
<span
aria-hidden="true"
className={`${montserrat.className} absolute top-5 right-5 text-[10px] font-semibold tracking-[0.2em] text-gray-300 @md:top-6 @md:right-6`}
>
{String(index + 1).padStart(2, "0")}
</span>
<dt className="text-[11px] font-semibold tracking-[0.14em] text-amber-700">
{metric.label}
</dt>
<dd className="mt-3">
<span className="sr-only">{metric.sr}</span>
<span
aria-hidden="true"
className="flex items-baseline gap-1 text-gray-900"
>
<span
className={`${montserrat.className} text-[clamp(2.6rem,9cqw,3.9rem)] leading-[0.9] font-extrabold tracking-tight tabular-nums`}
>
{shown}
</span>
{metric.suffix ? (
<span
className={`${montserrat.className} text-[clamp(1.3rem,4cqw,1.9rem)] leading-none font-extrabold text-amber-600`}
>
{metric.suffix}
</span>
) : null}
{metric.unit ? (
<span className="text-[13px] font-semibold text-gray-500">
{metric.unit}
</span>
) : null}
</span>
{/* 計測バー: 数字と同じ進捗で伸びる(transform のみ)。
reduced-motion 時は progress=1 のまま=最初から満ちた状態で出る。 */}
<span aria-hidden="true" className="mt-4 block h-px w-full bg-gray-200">
<span
className="block h-px w-full origin-left bg-amber-600"
style={{ transform: `scaleX(${progress})` }}
/>
</span>
<span className="mt-3 block text-[13px] leading-[1.8] text-gray-500">
{metric.caption}
</span>
</dd>
</div>
);
}
export default function CompanyNumbersGrid() {
const [active, setActive] = useState(false);
const sectionRef = useRef<HTMLElement>(null);
useEffect(() => {
const el = sectionRef.current;
if (!el) return;
if (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(el);
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"
}`;
const delay = (ms: number) => ({ transitionDelay: `${ms}ms` });
return (
<section
ref={sectionRef}
aria-labelledby="company-numbers-grid-heading"
className="@container w-full max-w-5xl bg-white px-1"
>
<header className="max-w-2xl">
<p
style={delay(0)}
className={`${montserrat.className} text-xs font-bold tracking-[0.25em] text-amber-600 uppercase ${reveal}`}
>
BLK-39 — Company / Numbers
</p>
<h2
id="company-numbers-grid-heading"
style={delay(80)}
className={`mt-4 text-[clamp(1.8rem,4vw,2.5rem)] leading-[1.3] font-bold tracking-tight text-gray-900 ${reveal}`}
>
<span className="relative inline-block">
数字
<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={`mt-5 text-[15px] leading-[1.9] font-medium text-gray-600 ${reveal}`}
>
言葉より先に、実績と姿勢を数字で。会社案内・採用ページの冒頭に置ける、飾らないファクトの一覧です。
</p>
</header>
<dl
style={delay(280)}
className={`mt-10 grid grid-cols-1 gap-px border-y border-gray-200 bg-gray-200 @md:grid-cols-2 @2xl:grid-cols-3 ${reveal}`}
>
{metrics.map((metric, i) => (
<MetricCell key={metric.label} metric={metric} index={i} active={active} />
))}
</dl>
<div
style={delay(420)}
className={`mt-6 flex flex-col gap-5 @md:flex-row @md:items-center @md:justify-between ${reveal}`}
>
<p className="text-[12px] leading-[1.8] text-gray-400">
※ 数値はサンプルです。実運用では集計時点(例: 2026年3月末時点)を必ず添えてください。
</p>
<a
href="#"
className="group inline-flex flex-none items-center gap-3 text-sm font-semibold text-gray-900 focus-visible:outline-2 focus-visible:outline-amber-600 focus-visible:outline-offset-4"
>
<span className="relative whitespace-nowrap">
会社概要を見る
<span 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 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>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/company-numbers-grid.json