Customer Voices — Quote-led Ledger
A testimonials block that leads with the quote and subordinates the attribution. One voice is placed as a dominant blockquote in large type, with the name/attribute added quietly beside an amber hairline. The remaining voices are grouped — not as cards — into a 'voice ledger' bound by a single vertical rail and divide rules. Instead of relying on Swiper, a shared keyframe is applied to each testimonial's rail marker with a staggered animation-delay, so a blinking highlight travels down the list in pure CSS (markers are anchored per row, so alignment holds even with variable row heights). Names follow the restrained style of medical sites (initials, age band, attribute) to avoid over-identifying individuals. Zero image assets; reduced-motion falls back to static markers and instant reveal.
- Added:
- 2026-07-30
- Dependencies:
- None
- tags
- #block #testimonial #voices #quote #editorial #css-rhythm #no-swiper #no-image #medical-reference
Preview
BLK-07 — Voices / お客様の声
制作の“その後”を、お客様の言葉で。
語るのはいつもお客様。First CH と歩んだあと、現場で何が変わったのか。 飾らない言葉のまま並べます。
『作って終わり』だと思っていました。公開してからのほうが、問い合わせも相談も増えていく。Webがようやく“働く”感覚を、初めて持てました。
相談のたびに、こちらの言葉に翻訳し直してくれる。専門用語で丸め込まれない安心感があります。
工務店 代表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)]`}
>
“
</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>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/testimonial-voices.json