First CH Designs
BLK-07Blocks

お客様の声(引用の主従・声の台帳)

「お客様の声」を、引用の主従で見せるブロック。1件を主となる声として大きな引用文で置き、氏名・属性はamber hairlineを添えて控えめに従える。残りの声はカードで囲まず、左に縦レール1本+divide罫線で束ねた“声の台帳”として並べる。Swiperに頼らず、各証言のマーカーへ共有keyframeをanimation-delayでずらして掛けることで、明滅のハイライトが縦に流れる律動をCSSだけで作る(マーカーは各行にアンカーされるので行高が可変でも整列が崩れない)。氏名は医療系サイトにならいイニシャル・年代・属性で個人を特定しすぎず添える。画像素材ゼロ・prefers-reduced-motion時は静的マーカーと即時表示にフォールバック(参考: 参考サイトKB 医療系サイト群の声の見せ方をブランド再構築・2026-07-30)。

追加日:
2026-07-30
依存:
なし
tags
#block #testimonial #voices #quote #editorial #css-rhythm #no-swiper #no-image #medical-reference

プレビュー

BLK-07 — Voices / お客様の声

制作の“その後”を、お客様の言葉で。

語るのはいつもお客様。First CH と歩んだあと、現場で何が変わったのか。 飾らない言葉のまま並べます。

『作って終わり』だと思っていました。公開してからのほうが、問い合わせも相談も増えていく。Webがようやく“働く”感覚を、初めて持てました。
M.K 様地域密着クリニック 事務長継続ご利用 3年5.0
  • 相談のたびに、こちらの言葉に翻訳し直してくれる。専門用語で丸め込まれない安心感があります。
    工務店 代表50代
  • スマホでの見え方まで、ここまで詰めてもらえるとは。公開後、問い合わせの数字が静かに動きました。
    学習塾 教室長T.S 様
  • 納品して終わりにしない。運用を任せきりにしない設計が、少人数のうちには合っていました。
    社会保険労務士事務所 所長60代
  • デザインの意図を、そのつど言葉で説明してくれる。だから社内でも、自信を持って共有できます。
    製造業 広報A.Y 様
"use client";

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

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

// preload: false は和文フォント(Zen Old Mincho / Noto Sans JP 等)では必須。
// 理由と実測は src/app/[lang]/layout.tsx のコメント参照。
const zenOldMincho = Zen_Old_Mincho({
  weight: ["700"],
  subsets: ["latin"],
  display: "swap",
  preload: false,
});

/**
 * お客様の声(引用の主従 / 声の台帳)
 * 参考: 医療系サイト群の「声の見せ方」(引用文を主役に、氏名・属性を控えめに添える構図)をブランド再構築(コピーではない)。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - Swiperに頼らずCSSで律動を作る: 各証言の左レール上のマーカーに共有 keyframe(voice-pulse) を掛け、
 *   animation-delay を index*step でずらすと、明滅のハイライトが縦に"流れて"1件ずつ視線を誘導する。
 *   単一runnerを translateY で走らせる方式と違い、マーカーが各行にアンカーされるので行高が可変でも整列が崩れない。
 *   アニメーションは opacity/transform(scale) のみ。motion-reduce:animate-none で静的な実マーカーへフォールバック(消えない)。
 * - 引用の主従: 引用文を巨大タイポ+和文本文フォントで主役に置き、氏名/属性は Montserrat の小さめ+amber hairline で従える。
 *   セマンティクスは <figure><blockquote>…</blockquote><figcaption>…</figcaption></figure>。装飾引用符は aria-hidden。
 * - 脱カード: 箱で囲まず border-t/b と divide 罫線+余白でグルーピングし、左に縦レール1本で束ねる。画像素材ゼロ。
 * - reveal は IntersectionObserver で画面内に入ったら transform/opacity のみでスタッガー表示(reduced-motion は即時)。
 */

type Voice = {
  quote: string;
  attr: string;
  /** 氏名・イニシャル・年代など(医療系にならい個人を特定しすぎない添え方) */
  name: string;
};

const featured: {
  quote: string;
  name: string;
  attr: string;
  meta: string;
  rating: number;
} = {
  quote:
    "『作って終わり』だと思っていました。公開してからのほうが、問い合わせも相談も増えていく。Webがようやく“働く”感覚を、初めて持てました。",
  name: "M.K 様",
  attr: "地域密着クリニック 事務長",
  meta: "継続ご利用 3年",
  rating: 5.0,
};

const supporting: Voice[] = [
  {
    quote:
      "相談のたびに、こちらの言葉に翻訳し直してくれる。専門用語で丸め込まれない安心感があります。",
    attr: "工務店 代表",
    name: "50代",
  },
  {
    quote:
      "スマホでの見え方まで、ここまで詰めてもらえるとは。公開後、問い合わせの数字が静かに動きました。",
    attr: "学習塾 教室長",
    name: "T.S 様",
  },
  {
    quote:
      "納品して終わりにしない。運用を任せきりにしない設計が、少人数のうちには合っていました。",
    attr: "社会保険労務士事務所 所長",
    name: "60代",
  },
  {
    quote:
      "デザインの意図を、そのつど言葉で説明してくれる。だから社内でも、自信を持って共有できます。",
    attr: "製造業 広報",
    name: "A.Y 様",
  },
];

/** 5点満点のレーティングを amber の目盛りで表現(画像不使用・aria-label で数値を読ませる) */
function Rating({ value }: { value: number }) {
  return (
    <span
      className="inline-flex items-center gap-2"
      role="img"
      aria-label={`5点満点中${value.toFixed(1)}`}
    >
      <span aria-hidden="true" className="flex items-end gap-[3px]">
        {[0, 1, 2, 3, 4].map((i) => (
          <span
            key={i}
            className={`block w-[3px] rounded-full ${
              i < Math.round(value) ? "bg-amber-500" : "bg-gray-200"
            }`}
            style={{ height: `${8 + i * 2}px` }}
          />
        ))}
      </span>
      <span
        className={`${montserrat.className} text-sm font-bold tabular-nums text-gray-700`}
      >
        {value.toFixed(1)}
      </span>
    </span>
  );
}

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

  // 画面内に入ったら reveal を開始(IO 非対応環境は即開始)
  // reduced-motion 対応は reveal / keyframes の motion-reduce: variant で完結(JS判定は不要)
  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.15 },
    );
    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="testimonial-voices-heading"
      className="w-full max-w-3xl bg-white px-1"
    >
      {/* ヘッダー(宣言) */}
      <header className="max-w-2xl">
        <p
          style={{ transitionDelay: "0ms" }}
          className={`${montserrat.className} text-xs font-bold tracking-[0.25em] text-amber-600 uppercase ${reveal}`}
        >
          BLK-07 — Voices / お客様の声
        </p>
        <h2
          id="testimonial-voices-heading"
          style={{ transitionDelay: "80ms" }}
          className={`mt-4 text-[clamp(1.8rem,4vw,2.6rem)] leading-[1.3] font-bold tracking-tight text-gray-900 ${reveal}`}
        >
          制作の
          <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
          style={{ transitionDelay: "160ms" }}
          className={`mt-5 text-[15px] leading-[1.9] font-medium text-gray-600 ${reveal}`}
        >
          語るのはいつもお客様。First CH と歩んだあと、現場で何が変わったのか。
          飾らない言葉のまま並べます。
        </p>
      </header>

      {/* 主となる声(引用を大きく・氏名/属性を従える・上下を hairline で挟む) */}
      <figure
        style={{ transitionDelay: "260ms" }}
        className={`mt-10 border-t border-b border-gray-200 py-9 sm:mt-14 sm:py-12 ${reveal}`}
      >
        <div className="grid grid-cols-[auto_1fr] gap-x-4 sm:gap-x-7">
          <span
            aria-hidden="true"
            className={`${zenOldMincho.className} -mt-4 block leading-none text-amber-500/80 select-none text-[clamp(3.5rem,9vw,6rem)]`}
          >
            &ldquo;
          </span>
          <div>
            <blockquote className="text-[clamp(1.3rem,3vw,2rem)] leading-[1.7] font-medium tracking-[0.01em] text-gray-900">
              {featured.quote}
            </blockquote>
            <figcaption className="mt-6 flex flex-wrap items-center gap-x-4 gap-y-3">
              <span aria-hidden="true" className="h-px w-8 bg-amber-600" />
              <span className="text-[15px] font-semibold text-gray-800">
                {featured.name}
              </span>
              <span className="text-sm text-gray-500">
                {featured.attr}・{featured.meta}
              </span>
              <span className="ml-auto">
                <Rating value={featured.rating} />
              </span>
            </figcaption>
          </div>
        </div>
      </figure>

      {/* 従となる声の台帳(カードで囲まず左レール+divide 罫線で束ねる) */}
      <ul role="list" className="relative mt-2">
        {/* 縦レール(連続する1本の hairline) */}
        <span
          aria-hidden="true"
          className="absolute top-8 bottom-8 left-[5px] w-px bg-gray-200"
        />
        {supporting.map((v, i) => (
          <li
            key={v.attr + v.name}
            style={{ transitionDelay: `${360 + i * 90}ms` }}
            className={`relative border-b border-gray-200 py-7 pl-8 last:border-b-0 ${reveal}`}
          >
            {/* マーカー: 共有 keyframe を delay でずらし、ハイライトが縦に流れる(Swiper不要の律動) */}
            <span
              aria-hidden="true"
              className="absolute top-[2.1rem] left-0 block h-[11px] w-[11px] rounded-full bg-amber-600 [animation:voice-pulse_4.4s_ease-in-out_infinite] motion-reduce:animate-none"
              style={{ animationDelay: `${i * 1.1}s` }}
            />
            <figure>
              <blockquote className="text-[clamp(1rem,1.6vw,1.15rem)] leading-[1.9] text-gray-800">
                {v.quote}
              </blockquote>
              <figcaption className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1">
                <span aria-hidden="true" className="h-px w-6 bg-amber-600/70" />
                <span className="text-sm font-semibold text-gray-700">
                  {v.attr}
                </span>
                <span className="text-[13px] text-gray-400">{v.name}</span>
              </figcaption>
            </figure>
          </li>
        ))}
      </ul>

      {/* テキストCTA(下線ワイプ・focus-visible) */}
      <div
        style={{ transitionDelay: `${360 + supporting.length * 90}ms` }}
        className={`mt-9 ${reveal}`}
      >
        <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>

      {/* マーカーの律動 keyframes(このブロック内で完結。opacity/transform のみ) */}
      <style>{`
        @keyframes voice-pulse {
          0% { opacity: .25; transform: scale(.72); }
          9% { opacity: 1; transform: scale(1); }
          24% { opacity: .25; transform: scale(.72); }
          100% { opacity: .25; transform: scale(.72); }
        }
      `}</style>
    </section>
  );
}

shadcn CLI でプロジェクトに追加

npx shadcn@latest add https://designs.first-ch.com/r/testimonial-voices.json