Company by the Numbers — Hairline Grid
A "by the numbers" block for company and recruiting pages. Founding year, project count, retention rate and more sit as equals in a lattice bound by 1px rules alone — no cards. The parent uses grid gap-px on a gray background with white cells, so the gaps themselves become the rules and no doubled lines appear when the column count changes. Numbers are oversized Montserrat with tabular-nums; units and captions stay subordinate. Values count up from 0 via requestAnimationFrame once the section enters the viewport, and the same eased progress drives the scaleX of an amber measure bar so digits and rule grow on one timeline. Columns reflow with container queries (@md/@2xl), so the block holds up inside narrow containers. Zero image assets; reduced-motion users see final values instantly.
- Added:
- 2026-08-25
- Dependencies:
- None
- tags
- #block #company #stats #counter #count-up #tabular-nums #hairline #grid #container-query #recruit #no-image
Preview
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>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/company-numbers-grid.json