First CH Designs
BLK-46Blocks

Certification & Award Badge Band

A trust band that presents certifications, standards conformance and awards as typographic specimen badges — no logo images at all. Six entries run in ascending order of the year obtained, bound by gap-px hairlines instead of cards. Each badge sets a Latin lockup inside a thin frame with amber corner ticks and aligns the year in tabular-nums along the bottom edge. Hover or keyboard focus opens a one-line tooltip whose left offset is clamped inside the band by measuring the band, cell and tooltip rects, so edge columns never overflow (only the caret keeps pointing at the cell centre). The spoken description lives permanently in an sr-only span inside each button rather than a toggled aria-describedby. Below the band, a year scale rail carries a marker that moves with translateX(%) alone. All entries are display samples.

Added:
2026-08-30
Dependencies:
None
tags
#block #trust #certification #award #badge #tooltip #tabular-nums #hairline #container-query #no-image

Preview

第三者の審査を通った、事実だけ

資格・認証・受賞はロゴ画像を並べるほど信頼が増すものではありません。取得年と適用範囲を添えて、審査を受けた事実として掲示します。

BLK-46

6 items / 20192024

各バッジにカーソルを重ねる、またはキーボードで選ぶと一言説明が開きます。

※ 掲載内容はすべて表示サンプルです。実在の認証・受賞との関係はありません。実運用では登録番号・有効期限・審査機関名を必ず併記し、更新が切れた項目は帯から外してください。

"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { Montserrat } from "next/font/google";

const montserrat = Montserrat({
  weight: ["600", "700", "800"],
  subsets: ["latin"],
  display: "swap",
});

/**
 * 資格・認証・受賞バッジ帯
 * 既存の信頼系は「数値セルの等価な格子」「縦レールで読ませる沿革」「名前だけを流す動く帯」が
 * 既出のため、本ブロックは静止した標本帯にした。ロゴ画像を使わずタイポ+細枠の枠で1件ずつ
 * 掲示し、hover/フォーカスで一言説明のツールチップを開く操作と、取得年の目盛レールを持たせている。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - コンテナ内クランプ型ツールチップ: セル中央に出すだけだと端の列で枠外へ出る。帯・セル・吹き出しの
 *   3つの rect を useEffect で測り、left を [4, bandW - tipW - 4] に clamp、上に入らなければ下へ回す。
 *   吹き出しの三角だけは「セル中央 - clamp後のleft」で別途動かすので、ズレても指す先は狂わない。
 *   位置は style(left/top)で置き、トランジションは opacity/transform だけに掛ける。
 * - ツールチップの中身は aria-describedby をhover状態でトグルせず、ボタン内の sr-only に常設する。
 *   状態に依存しないので読み上げが取りこぼれず、視覚側の吹き出しは aria-hidden にできる。
 * - 目盛上のマーカーを transform だけで動かす: 親と同じ幅(w-full)の空箱を translateX(x%) すると、
 *   %は自分自身の幅基準=レール幅基準になるので、left を使わずに任意の位置へ置ける。箱がレール外へ
 *   はみ出す分は外側の overflow-x-clip で受ける(left を transition すると層が再レイアウトされる)。
 */

type Credential = {
  /** 種別。塗り=受賞、中抜き=認証・適合・登録でマークの形を変える */
  kind: "認証" | "適合" | "受賞" | "登録";
  /** 枠内の欧文ロックアップ(上段=規格系・下段=番号や年) */
  markTop: string;
  markBottom: string;
  name: string;
  year: number;
  scope: string;
  /** ツールチップに出す一言説明 */
  note: string;
};

/** 取得年の昇順で並べる(帯の読み順とレールの目盛が一致する) */
const credentials: Credential[] = [
  {
    kind: "認証",
    markTop: "ISO",
    markBottom: "9001",
    name: "品質マネジメントシステム",
    year: 2019,
    scope: "全社(制作・運用)",
    note: "制作から公開後の運用までの手順を文書化し、年1回の外部審査で維持を確認しています。",
  },
  {
    kind: "登録",
    markTop: "CLOUD",
    markBottom: "PARTNER",
    name: "クラウド認定パートナー",
    year: 2020,
    scope: "インフラ構築部門",
    note: "構築実績と有資格者数の基準を満たした事業者として登録し、毎年更新しています。",
  },
  {
    kind: "認証",
    markTop: "ISO",
    markBottom: "27001",
    name: "情報セキュリティマネジメント",
    year: 2021,
    scope: "受託開発部門",
    note: "お預かりしたデータの保管・持ち出しの規程を定め、内部監査を半期ごとに実施しています。",
  },
  {
    kind: "適合",
    markTop: "WCAG",
    markBottom: "2.2 AA",
    name: "ウェブアクセシビリティ適合",
    year: 2022,
    scope: "自社サイト・納品物",
    note: "試験結果と対応方針を公開し、指摘は次のリリースまでに修正する運用にしています。",
  },
  {
    kind: "受賞",
    markTop: "AWARD",
    markBottom: "2023",
    name: "地域ビジネスデザイン賞 優秀賞",
    year: 2023,
    scope: "受託案件(小売業)",
    note: "地域事業者のブランド刷新をテーマにした部門で、公開後の売上寄与まで含めて評価されました。",
  },
  {
    kind: "認証",
    markTop: "ISO",
    markBottom: "27701",
    name: "プライバシー情報マネジメント",
    year: 2024,
    scope: "全社(委託先を含む)",
    note: "個人情報の取得から廃棄までを台帳化し、委託先の点検も同じ基準で行っています。",
  },
];

const minYear = credentials[0].year;
const maxYear = credentials[credentials.length - 1].year;
/** レール上の位置(%)。年の分布をそのまま横軸にする */
const railPct = (year: number) =>
  maxYear === minYear ? 50 : ((year - minYear) / (maxYear - minYear)) * 100;

type TipPos = { index: number; left: number; top: number; caret: number; below: boolean };

export default function CertificationBadgeBand() {
  const [active, setActive] = useState<number | null>(null);
  /** クリック/タップで開きっぱなしにする(ポインタが外れても消さない) */
  const [sticky, setSticky] = useState(false);
  const [tip, setTip] = useState<TipPos | null>(null);
  const [revealed, setRevealed] = useState(false);

  const sectionRef = useRef<HTMLElement>(null);
  const bandRef = useRef<HTMLDivElement>(null);
  const tipRef = useRef<HTMLDivElement>(null);
  const cellRefs = useRef<Array<HTMLLIElement | null>>([]);

  // 画面内に入ったら1度だけ点灯(年の順に灯る+レールが左から引かれる)
  useEffect(() => {
    const el = sectionRef.current;
    if (!el) return;
    if (typeof IntersectionObserver === "undefined") {
      const id = requestAnimationFrame(() => setRevealed(true));
      return () => cancelAnimationFrame(id);
    }
    const io = new IntersectionObserver(
      (entries) => {
        if (entries.some((e) => e.isIntersecting)) {
          setRevealed(true);
          io.disconnect();
        }
      },
      { threshold: 0.15 },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);

  // 吹き出しの位置を測って帯の内側へ収める(上に入らなければ下へ回す)
  const placeTip = useCallback(() => {
    if (active === null) {
      setTip(null);
      return;
    }
    const band = bandRef.current;
    const cell = cellRefs.current[active];
    const tipEl = tipRef.current;
    if (!band || !cell || !tipEl) return;

    const b = band.getBoundingClientRect();
    const c = cell.getBoundingClientRect();
    const t = tipEl.getBoundingClientRect();
    const gap = 10;
    const edge = 4;

    const center = c.left - b.left + c.width / 2;
    const maxLeft = Math.max(edge, b.width - t.width - edge);
    const left = Math.min(Math.max(center - t.width / 2, edge), maxLeft);
    const above = c.top - b.top - t.height - gap;
    // 上に入らない行(帯の1行目など)は下へ回す。三角の向きも合わせて返す。
    const below = above < 0;
    const top = below ? c.bottom - b.top + gap : above;
    // 三角は clamp 後の吹き出しから見たセル中央に置く(端の列でも指す先が狂わない)
    const caret = Math.min(Math.max(center - left, 14), Math.max(14, t.width - 14));

    setTip({ index: active, left, top, caret, below });
  }, [active]);

  // 測定は effect 本体で同期的に行わず、次の rAF で1回だけ走らせる
  // (effect 内の同期 setState は cascading render になるため)。
  useEffect(() => {
    const id = requestAnimationFrame(placeTip);
    window.addEventListener("resize", placeTip);
    return () => {
      cancelAnimationFrame(id);
      window.removeEventListener("resize", placeTip);
    };
  }, [placeTip]);

  const open = (i: number) => {
    setActive(i);
  };
  const close = () => {
    setActive(null);
    setSticky(false);
  };

  const activeCredential = active === null ? null : credentials[active];
  // 位置が確定していれば表示。バッジ間を移動したときに一瞬消えないよう、
  // tip.index === active までは待たない(1フレームだけ前の位置に残る方が、明滅より目立たない)。
  const tipVisible = active !== null && tip !== null;

  return (
    <section
      ref={sectionRef}
      aria-labelledby="certification-badge-band-heading"
      className="@container w-full max-w-5xl bg-white px-1"
    >
      <header className="flex flex-col gap-5 @2xl:flex-row @2xl:items-end @2xl:justify-between">
        <div className="max-w-xl">
          <h2
            id="certification-badge-band-heading"
            className="text-[clamp(1.7rem,3.6vw,2.35rem)] leading-[1.35] font-bold tracking-tight text-gray-900"
          >
            第三者の審査を通った、
            <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 className="mt-5 text-[15px] leading-[1.9] font-medium text-gray-600">
            資格・認証・受賞はロゴ画像を並べるほど信頼が増すものではありません。取得年と適用範囲を添えて、審査を受けた事実として掲示します。
          </p>
        </div>

        <div className="flex shrink-0 items-center gap-3 @2xl:flex-col @2xl:items-end @2xl:gap-1.5">
          <p
            className={`${montserrat.className} flex items-center gap-2 text-[11px] font-semibold text-gray-400`}
          >
            <span aria-hidden="true" className="inline-block size-1 bg-amber-600" />
            BLK-46
          </p>
          <p
            className={`${montserrat.className} text-[11px] font-semibold text-gray-500 tabular-nums`}
          >
            {credentials.length} items / {minYear}–{maxYear}
          </p>
        </div>
      </header>

      <p className="mt-8 text-[11px] leading-[1.8] text-gray-500">
        各バッジにカーソルを重ねる、またはキーボードで選ぶと一言説明が開きます。
      </p>

      {/* 標本帯: セルに border を振らず gap-px + 下地の bg-gray-200 を罫線にする(列数が変わっても二重線が出ない) */}
      <div ref={bandRef} className="relative mt-3">
        <ul
          className="grid grid-cols-2 gap-px border-y border-gray-200 bg-gray-200 @md:grid-cols-3 @3xl:grid-cols-6"
          onMouseLeave={() => {
            if (!sticky) setActive(null);
          }}
        >
          {credentials.map((c, i) => {
            const isActive = active === i;
            return (
              <li
                key={c.name}
                ref={(el) => {
                  cellRefs.current[i] = el;
                }}
                className={`bg-white transition-opacity duration-700 ease-out motion-reduce:transition-none ${
                  revealed
                    ? "opacity-100"
                    : "opacity-0 motion-reduce:opacity-100"
                }`}
                style={{ transitionDelay: `${120 + i * 70}ms` }}
              >
                <button
                  type="button"
                  className="group/badge flex h-full w-full flex-col items-start px-4 py-5 text-left transition-colors duration-200 hover:bg-amber-50/50 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none @md:px-5 @md:py-6"
                  onMouseEnter={() => open(i)}
                  onFocus={() => open(i)}
                  onBlur={close}
                  onKeyDown={(e) => {
                    if (e.key === "Escape") close();
                  }}
                  onClick={() => {
                    if (isActive && sticky) {
                      close();
                    } else {
                      open(i);
                      setSticky(true);
                    }
                  }}
                >
                  {/* 種別: 受賞だけ塗り、認証・適合・登録は中抜きの小片で区別する */}
                  <span className="flex items-center gap-1.5 text-[10px] font-semibold text-amber-700">
                    <span
                      aria-hidden="true"
                      className={`inline-block size-1.5 ${
                        c.kind === "受賞"
                          ? "bg-amber-600"
                          : "border border-amber-600"
                      }`}
                    />
                    {c.kind}
                  </span>

                  {/* 細枠の標本枠。四隅のトンボ風の目印で「台紙に留めた標本」に寄せる */}
                  <span
                    aria-hidden="true"
                    className={`relative mt-3 flex h-[76px] w-full items-center justify-center border px-1.5 transition-colors duration-200 motion-reduce:transition-none ${
                      isActive
                        ? "border-gray-900"
                        : "border-gray-300 group-hover/badge:border-gray-900"
                    }`}
                  >
                    <span className="absolute -top-px -left-px block h-2 w-px bg-amber-600" />
                    <span className="absolute -top-px -left-px block h-px w-2 bg-amber-600" />
                    <span className="absolute -right-px -bottom-px block h-2 w-px bg-amber-600" />
                    <span className="absolute -right-px -bottom-px block h-px w-2 bg-amber-600" />
                    <span
                      className={`${montserrat.className} flex flex-col items-center gap-0.5 text-center leading-none`}
                    >
                      <span className="text-[11px] font-semibold tracking-[0.16em] text-gray-500">
                        {c.markTop}
                      </span>
                      <span className="text-[clamp(1.05rem,4.4cqw,1.35rem)] font-extrabold tracking-tight text-gray-900 tabular-nums">
                        {c.markBottom}
                      </span>
                    </span>
                  </span>

                  <span className="mt-3 mb-4 block text-[13px] leading-[1.65] font-bold text-gray-900">
                    {c.name}
                  </span>

                  {/* 取得年: 桁幅を固定して帯の下端をそろえる */}
                  <span className="mt-auto flex w-full items-baseline justify-between border-t border-gray-200 pt-2.5">
                    <span className="text-[10px] font-semibold text-gray-400">
                      取得
                    </span>
                    <span className="flex items-baseline gap-0.5 text-gray-900">
                      <span
                        className={`${montserrat.className} text-[15px] leading-none font-bold tabular-nums`}
                      >
                        {c.year}
                      </span>
                      <span className="text-[10px] font-semibold text-gray-500">
                        年
                      </span>
                    </span>
                  </span>

                  {/* 読み上げには常設で渡す(hoverでの aria-describedby トグルに依存しない) */}
                  <span className="sr-only">
                    {`${c.kind}。適用範囲は${c.scope}。${c.note}`}
                  </span>
                </button>
              </li>
            );
          })}
        </ul>

        {/* ツールチップ: 帯を基準に絶対配置し、left/top は style・動きは opacity と transform だけ */}
        <div
          ref={tipRef}
          aria-hidden="true"
          className={`pointer-events-none absolute z-20 w-[min(280px,calc(100%-8px))] border border-gray-300 bg-white px-4 py-3 shadow-[0_10px_30px_-16px_rgba(17,24,39,0.45)] transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none ${
            tipVisible
              ? "translate-y-0 opacity-100"
              : "translate-y-1 opacity-0 motion-reduce:translate-y-0"
          }`}
          style={{
            left: tip ? `${tip.left}px` : 0,
            top: tip ? `${tip.top}px` : 0,
            visibility: active === null ? "hidden" : "visible",
          }}
        >
          <span
            className={`absolute block size-2 -translate-x-1/2 rotate-45 border-gray-300 bg-white ${
              tip?.below
                ? "-top-px -translate-y-1/2 border-t border-l"
                : "-bottom-px translate-y-1/2 border-r border-b"
            }`}
            style={{ left: tip ? `${tip.caret}px` : "50%" }}
          />
          <span className="block h-[2px] w-8 bg-amber-600" />
          <span className="mt-2.5 block text-[12px] leading-[1.85] font-medium text-gray-800">
            {activeCredential?.note}
          </span>
          <span className="mt-2.5 flex items-baseline justify-between gap-3 border-t border-gray-200 pt-2 text-[10px] text-gray-500">
            <span className="font-semibold text-gray-400">適用範囲</span>
            <span className="text-right leading-[1.6] text-gray-700">
              {activeCredential?.scope}
            </span>
          </span>
        </div>
      </div>

      {/* 取得年のレール。帯の並び順(年の昇順)を横軸で裏づける */}
      <div className="mt-7 overflow-x-clip px-6">
        <div className="relative h-11">
          <span
            aria-hidden="true"
            className="absolute inset-x-0 top-3 block h-px bg-gray-200"
          />
          <span
            aria-hidden="true"
            className="absolute inset-x-0 top-3 block h-px origin-left bg-amber-600 transition-transform duration-[900ms] ease-out motion-reduce:transition-none"
            style={{ transform: `scaleX(${revealed ? 1 : 0})` }}
          />

          {credentials.map((c, i) => (
            <span
              key={c.year}
              aria-hidden="true"
              className="absolute top-1 flex -translate-x-1/2 flex-col items-center"
              style={{ left: `${railPct(c.year)}%` }}
            >
              <span
                className={`block h-4 w-px transition-colors duration-200 motion-reduce:transition-none ${
                  active === i ? "bg-amber-600" : "bg-gray-300"
                }`}
              />
              <span
                className={`${montserrat.className} mt-1.5 text-[10px] font-semibold tabular-nums transition-colors duration-200 motion-reduce:transition-none ${
                  active === i ? "text-gray-900" : "text-gray-400"
                }`}
              >
                {c.year}
              </span>
            </span>
          ))}

          {/* 選択中の年を指すマーカー。w-full の空箱を translateX(%) で送るので left を触らない */}
          <span
            aria-hidden="true"
            className={`pointer-events-none absolute top-0 left-0 block w-full transition-transform duration-500 ease-out motion-reduce:transition-none ${
              active === null ? "opacity-0" : "opacity-100"
            }`}
            style={{
              transform: `translateX(${railPct(activeCredential?.year ?? minYear)}%)`,
            }}
          >
            <span className="absolute top-0 left-0 block size-1.5 -translate-x-1/2 rotate-45 bg-amber-600" />
          </span>
        </div>
      </div>

      <p className="mt-6 text-[12px] leading-[1.9] text-gray-400">
        ※ 掲載内容はすべて表示サンプルです。実在の認証・受賞との関係はありません。実運用では登録番号・有効期限・審査機関名を必ず併記し、更新が切れた項目は帯から外してください。
      </p>
    </section>
  );
}

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/certification-badge-band.json