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件以上
- 平均お客様満足度
- 平均お客様満足度 5点満点中4.9点
- 最短納期
- 最短納期 14日
コーポレート・LP・保守運用まで
納品後アンケートの5段階評価
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