First CH Designs
BLK-05Blocks

Strengths — Declarative Stats

A declarative strengths/stats block that lets numbers do the talking. One KPI is placed as a dominant feature stat in giant type, with three supporting stats grouped by hairline rules below (few claims, clear hierarchy). Numbers count up from 0 via requestAnimationFrame once the section scrolls into view (IntersectionObserver), with tabular-nums to keep digit widths fixed and avoid jitter. Grouped with rules and negative space instead of cards — zero image assets. Reduced-motion users see final values instantly.

Added:
2026-07-28
Dependencies:
None
tags
#block #stats #counter #count-up #tabular-nums #strengths #editorial #no-image

Preview

BLK-05 — Strengths / 選ばれる理由

数字が、いちばん強い説得力。

語るより、示す。First CH がこれまで積み上げてきた成果を、 少数の数字で言い切ります。

継続・再依頼率
継続・再依頼率 98パーセント

公開して終わりではなく、成果が出るまで伴走する。だからほとんどのお客様が、もう一度声をかけてくれる。

累計制作実績
累計制作実績 120件以上

コーポレート・LP・保守運用まで

平均お客様満足度
平均お客様満足度 5点満点中4.9点

納品後アンケートの5段階評価

最短納期
最短納期 14日

LP1本・要件確定からの実績

"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",
});

/**
 * 強み・数字(少数の主張を巨大タイポで置く / 数字・宣言型)
 * 参考: 他社サイトに見られる「数字を主役に据えた宣言型」構図をブランド再構築(コピーではない)。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - 数字カウントアップ: rAF で 0→target を easeOutCubic 補間。タイムスタンプは rAF の
 *   DOMHighResTimeStamp を使う(Date.now 不要)。IntersectionObserver で「画面内に入ったら開始」。
 *   prefers-reduced-motion 時はアニメーションせず最終値を即表示(display 初期値=target のまま)。
 * - tabular-nums(等幅数字)が肝: これが無いとカウント中に桁幅が揺れてガタつく。巨大タイポほど顕著。
 *   Tailwind の tabular-nums を数字スパンに必ず付ける。
 * - 脱カード: KPI を箱で囲まず、hairline 罫線(divide-x / border-t,b)+広い余白でグルーピング。
 *   1つを feature stat として支配的に大きく置き、残りを supporting 3-up で従える(少数の主張の階層化)。
 * - a11y: カウント中の数字は aria-hidden、代わりに sr-only で最終値を1回だけ読ませ、
 *   スクリーンリーダーに途中経過を連呼させない。
 */

type StatDef = {
  target: number;
  decimals: number;
  prefix?: string;
  suffix?: string;
  unit?: string;
  label: string;
  caption: string;
  /** スクリーンリーダー用の完成形テキスト */
  sr: string;
};

const featureStat: StatDef = {
  target: 98,
  decimals: 0,
  suffix: "%",
  label: "継続・再依頼率",
  caption:
    "公開して終わりではなく、成果が出るまで伴走する。だからほとんどのお客様が、もう一度声をかけてくれる。",
  sr: "継続・再依頼率 98パーセント",
};

const supportStats: StatDef[] = [
  {
    target: 120,
    decimals: 0,
    suffix: "+",
    unit: "件",
    label: "累計制作実績",
    caption: "コーポレート・LP・保守運用まで",
    sr: "累計制作実績 120件以上",
  },
  {
    target: 4.9,
    decimals: 1,
    unit: "/ 5.0",
    label: "平均お客様満足度",
    caption: "納品後アンケートの5段階評価",
    sr: "平均お客様満足度 5点満点中4.9点",
  },
  {
    target: 14,
    decimals: 0,
    unit: "日",
    label: "最短納期",
    caption: "LP1本・要件確定からの実績",
    sr: "最短納期 14日",
  },
];

/** 0→target を rAF で補間する数字。in-view かつ motion 許可時のみアニメート。 */
function CountNumber({
  def,
  active,
  reduce,
  delay,
  className,
}: {
  def: StatDef;
  active: boolean;
  reduce: boolean;
  delay: number;
  className: string;
}) {
  // 初期値=target: SSR・no-JS・reduced-motion では最終値がそのまま出る(0のフラッシュを避ける)。
  const [display, setDisplay] = useState(def.target);

  useEffect(() => {
    if (!active) return;
    // reduce 時は初期値(=target)のまま。同期 setState を避けるため何もしない。
    if (reduce) return;
    let rafId = 0;
    let startTs: number | null = null;
    const duration = 1500;
    const tick = (ts: number) => {
      if (startTs === null) startTs = ts;
      const p = Math.min(1, (ts - startTs) / duration);
      const eased = 1 - Math.pow(1 - p, 3); // easeOutCubic
      setDisplay(def.target * eased);
      if (p < 1) rafId = requestAnimationFrame(tick);
      else setDisplay(def.target);
    };
    // setTimeout コールバック内で 0 にリセット→開始(同期 setState を避ける/
    // 開始をずらしてリズムを出す。reveal の opacity フェードが最初の一瞬を隠す)
    const timer = window.setTimeout(() => {
      setDisplay(0);
      rafId = requestAnimationFrame(tick);
    }, delay);
    return () => {
      window.clearTimeout(timer);
      cancelAnimationFrame(rafId);
    };
  }, [active, reduce, delay, def.target]);

  const shown = display.toLocaleString("en-US", {
    minimumFractionDigits: def.decimals,
    maximumFractionDigits: def.decimals,
  });

  return (
    <span className={`${montserrat.className} tabular-nums ${className}`}>
      {def.prefix}
      {shown}
    </span>
  );
}

export default function StrengthStat() {
  const rootRef = useRef<HTMLElement>(null);
  const [active, setActive] = useState(false);
  const [reduce, setReduce] = useState(false);

  // reduced-motion 判定(同期 setState を避けるため rAF 内で反映)
  useEffect(() => {
    const id = requestAnimationFrame(() =>
      setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches)
    );
    return () => cancelAnimationFrame(id);
  }, []);

  // 画面内に入ったら reveal+カウントアップを開始(IO 非対応環境は即開始)
  useEffect(() => {
    const el = rootRef.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"
  }`;

  return (
    <section
      ref={rootRef}
      aria-labelledby="strength-stat-heading"
      className="w-full max-w-5xl bg-white px-1"
    >
      {/* ヘッダー(宣言) */}
      <header className="max-w-2xl">
        <p
          className={`${montserrat.className} text-xs font-bold tracking-[0.25em] text-amber-600 uppercase ${reveal}`}
          style={{ transitionDelay: "0ms" }}
        >
          BLK-05 — Strengths / 選ばれる理由
        </p>
        <h2
          id="strength-stat-heading"
          className={`mt-4 text-[clamp(1.8rem,4vw,2.6rem)] leading-[1.25] font-bold tracking-tight text-gray-900 ${reveal}`}
          style={{ transitionDelay: "80ms" }}
        >
          <span className="relative whitespace-nowrap">
            数字
            <span
              aria-hidden="true"
              className="absolute -bottom-1 left-0 h-[3px] w-full bg-amber-500/70"
            />
          </span>
          が、いちばん強い説得力。
        </h2>
        <p
          className={`mt-5 text-[15px] leading-[1.9] font-medium text-gray-600 ${reveal}`}
          style={{ transitionDelay: "160ms" }}
        >
          語るより、示す。First CH がこれまで積み上げてきた成果を、
          少数の数字で言い切ります。
        </p>
      </header>

      {/* feature stat(1つを支配的に大きく置く・hairlineで挟む) */}
      <div
        className={`mt-12 grid grid-cols-1 items-end gap-6 border-t border-b border-gray-200 py-10 sm:mt-16 sm:grid-cols-12 sm:gap-10 sm:py-14 ${reveal}`}
        style={{ transitionDelay: "240ms" }}
      >
        <div className="sm:col-span-6">
          <dl>
            <dt
              className={`${montserrat.className} text-[11px] font-semibold tracking-[0.22em] text-amber-700 uppercase`}
            >
              {featureStat.label}
            </dt>
            <dd className="mt-2 flex items-baseline">
              <span className="sr-only">{featureStat.sr}</span>
              <span aria-hidden="true" className="flex items-baseline">
                <CountNumber
                  def={featureStat}
                  active={active}
                  reduce={reduce}
                  delay={0}
                  className="text-[clamp(4.5rem,16vw,9rem)] leading-[0.85] font-extrabold tracking-tight text-gray-900"
                />
                <span
                  className={`${montserrat.className} ml-1 text-[clamp(2rem,6vw,3.5rem)] font-extrabold text-amber-600`}
                >
                  {featureStat.suffix}
                </span>
              </span>
            </dd>
          </dl>
        </div>
        <div className="sm:col-span-6">
          <p className="flex items-start gap-3 text-[15px] leading-[1.9] font-medium text-gray-700">
            <span
              aria-hidden="true"
              className="mt-[0.9em] h-px w-6 flex-none bg-amber-600"
            />
            <span>{featureStat.caption}</span>
          </p>
        </div>
      </div>

      {/* supporting 3-up(箱で囲まず divide 罫線でグルーピング) */}
      <dl
        className={`grid grid-cols-1 divide-y divide-gray-200 border-b border-gray-200 sm:grid-cols-3 sm:divide-x sm:divide-y-0 sm:border-b-0 ${reveal}`}
        style={{ transitionDelay: "360ms" }}
      >
        {supportStats.map((s, i) => (
          <div key={s.label} className="py-8 sm:px-8 sm:py-10 sm:first:pl-0">
            <dt
              className={`${montserrat.className} text-[11px] font-semibold tracking-[0.22em] text-amber-700 uppercase`}
            >
              {s.label}
            </dt>
            <dd className="mt-3 flex items-baseline gap-1.5">
              <span className="sr-only">{s.sr}</span>
              <span aria-hidden="true" className="flex items-baseline gap-1.5">
                <CountNumber
                  def={s}
                  active={active}
                  reduce={reduce}
                  delay={120 + i * 120}
                  className="text-[clamp(2.8rem,7vw,4rem)] leading-none font-extrabold tracking-tight text-gray-900"
                />
                {s.suffix ? (
                  <span
                    className={`${montserrat.className} text-2xl font-extrabold text-amber-600 sm:text-3xl`}
                  >
                    {s.suffix}
                  </span>
                ) : null}
                {s.unit ? (
                  <span className="text-sm font-semibold text-gray-500">
                    {s.unit}
                  </span>
                ) : null}
              </span>
            </dd>
            <p className="mt-2 text-[13px] leading-[1.8] text-gray-500">
              {s.caption}
            </p>
          </div>
        ))}
      </dl>

      {/* テキストCTA(下線ワイプ・focus-visible) */}
      <div
        className={`mt-10 ${reveal}`}
        style={{ transitionDelay: "480ms" }}
      >
        <a
          href="#"
          className="group inline-flex items-center gap-2 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 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/strength-stat.json