First CH Designs
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