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がようやく“働く”感覚を、初めて持てました。
相談のたびに、こちらの言葉に翻訳し直してくれる。専門用語で丸め込まれない安心感があります。
工務店 代表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>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/testimonial-voices.json