Customer Voice — One-Company Interview
A testimonial block that reads as a single-company interview rather than a set of short quotes. It opens with a three-cell summary (issue / approach / result) drawn as a 1px hairline grid via gap-px with no rounded corners; each cell is a button that jumps to the matching question, doubling as the article's table of contents. The body is a stack of Q&A: questions carry a small Latin label and an amber corner rule, answers are plain body paragraphs. Exactly one pull quote is set oversized, and its amber marker is drawn with an origin-left scale-x when it enters the viewport. Because staggered reveals fight with long-form reading, the section has none — motion is limited to that marker, hover, and the landing cue. Jump targets receive focus({ preventScroll: true }) on a tabIndex=-1 container so gaze and focus move together. Zero image assets; under prefers-reduced-motion the marker rests in its drawn state and scrolling falls back to behavior: 'auto'. All names and figures are fictional.
- Added:
- 2026-09-17
- Dependencies:
- None
- tags
- #block #testimonial #interview #case-study #long-form #qa #pull-quote #hairline #jump-to-anchor #no-image
Preview
BLK-64導入事例インタビュー
一社だけを、最後まで聞く
短い感想を並べる代わりに、一社の担当者に四つだけ質問しました。うまくいった話より、うまくいっていなかった頃の話を長めに聞いています。
サイトを作り直そうと思ったきっかけは何でしたか。
見た目が古いから、ではなかったです。問い合わせの数はそれなりにあって、でも半分くらいは最初の電話でお断りしていました。工期も予算も、うちがやっている仕事と合っていない。それが毎週続くと、さすがに何かがずれているなと。
社内では「広告を増やそう」という話になっていました。増やしたら、合わない相談も同じ比率で増えるだけじゃないかと言ったら、場が止まりまして。そこから、サイトの話になりました。
制作中、いちばん時間をかけた議論はどこでしたか。
最初のヒアリングで「御社の強みを3つ挙げてください」と聞かれると思っていたんです。実際に聞かれたのは、直近で断った仕事とその理由でした。12件ぜんぶ、理由を言葉にさせられました。
並べてみたら、断る理由はほぼ2種類しかなかった。それを裏返したものが、うちが引き受けたい仕事の条件そのものでした。トップページに何を書くかは、その日にほぼ決まりました。
強みを並べず、断った仕事の理由から書いた
公開してから、社内で変わったことはありますか。
問い合わせの件数は、正直そんなに変わっていません。月に20件前後のまま。ただ、初回の打ち合わせから見積もりに進む割合が、体感で倍くらいになりました。先方がサイトを読んだうえで、条件を分かって連絡してくださる。
現場の若い社員が、サイトの言葉をそのまま営業で使うようになったのも大きいです。自分の言葉で説明しづらかったことが、書いてあるので指させる。社内向けの文章にもなっていたんだな、と後から気づきました。
これから同じ悩みを持つ会社に、伝えるとしたら。
デザインをきれいにする話だと思わないほうがいいです。うちの場合は、断ってきた仕事の理由を人に説明する作業でした。地味だし、正直やりたくなかった。でもあれをやらずに見た目だけ変えていたら、同じ電話を受け続けていたと思います。
掲載はご本人の確認のうえ、社名と一部の数値を伏せています。この記事はデモ用に構成した架空のインタビューです。
"use client";
import { useEffect, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["600", "700"],
subsets: ["latin"],
display: "swap",
});
/**
* お客様の声(1件深掘りインタビュー)
* 既存の声2件は「複数社の短い引用を並べる=量」の型(BLK-07 整列の台帳 / BLK-18 傾き重ねのポラロイド)なので、
* 本ブロックは1社のインタビューをQ&Aで最後まで読ませる「深さ」の型にした(引用1〜2文 → 質問と回答の本文)。
* 1件深掘りの BLK-15 とは語り手が違う: あちらは制作側が課題→打ち手→結果を三人称で説明する、こちらはお客様の一人称の発話だけで構成する。
*
* 汎用技法メモ(web-design-playbook 還流用):
* - 読み物ブロックには出現アニメを掛けない: 節全体のスタッガー・リビールは「読み始める前に全部が動く」ので長文と相性が悪い。
* モーションはプルクオートのマーカー1箇所(origin-left の scale-x)だけに絞り、他は静止で出す。
* - 要約→本文のジャンプ: 冒頭の3点サマリを button にして scrollIntoView(reduced-motion 時は behavior:"auto")+
* 着地先 div に tabIndex={-1} で focus({ preventScroll: true }) を当て、視線とフォーカスを同時に運ぶ。
* 着地の合図は opacity のみで明滅させる(レイアウトを動かさない)。連打でも再点灯するよう state に seq を持たせる。
* - 角丸ゼロ・箱ゼロ: グルーピングは gap-px + 背景色の 1px hairline 格子と border-t/b だけで作る。
*/
type Qa = {
/** aria-controls 用の id(1ページに複数標本が並ぶのでスラッグを接頭辞にする) */
id: string;
no: string;
q: string;
a: string[];
};
const speaker = {
name: "K.M 様",
attr: "建設業・専務",
scale: "従業員 18名",
when: "2026.03",
};
const summary: { label: string; body: string; jump: number }[] = [
{
label: "課題",
body: "問い合わせはあるのに、話の合う相手には届いていなかった",
jump: 0,
},
{
label: "施策",
body: "強みを並べる前に、断った仕事の理由を全部書き出した",
jump: 1,
},
{
label: "成果",
body: "件数はほぼ横ばい。初回の打ち合わせで決まる割合が変わった",
jump: 2,
},
];
const qa: Qa[] = [
{
id: "voice-interview-q1",
no: "Q1",
q: "サイトを作り直そうと思ったきっかけは何でしたか。",
a: [
"見た目が古いから、ではなかったです。問い合わせの数はそれなりにあって、でも半分くらいは最初の電話でお断りしていました。工期も予算も、うちがやっている仕事と合っていない。それが毎週続くと、さすがに何かがずれているなと。",
"社内では「広告を増やそう」という話になっていました。増やしたら、合わない相談も同じ比率で増えるだけじゃないかと言ったら、場が止まりまして。そこから、サイトの話になりました。",
],
},
{
id: "voice-interview-q2",
no: "Q2",
q: "制作中、いちばん時間をかけた議論はどこでしたか。",
a: [
"最初のヒアリングで「御社の強みを3つ挙げてください」と聞かれると思っていたんです。実際に聞かれたのは、直近で断った仕事とその理由でした。12件ぜんぶ、理由を言葉にさせられました。",
"並べてみたら、断る理由はほぼ2種類しかなかった。それを裏返したものが、うちが引き受けたい仕事の条件そのものでした。トップページに何を書くかは、その日にほぼ決まりました。",
],
},
{
id: "voice-interview-q3",
no: "Q3",
q: "公開してから、社内で変わったことはありますか。",
a: [
"問い合わせの件数は、正直そんなに変わっていません。月に20件前後のまま。ただ、初回の打ち合わせから見積もりに進む割合が、体感で倍くらいになりました。先方がサイトを読んだうえで、条件を分かって連絡してくださる。",
"現場の若い社員が、サイトの言葉をそのまま営業で使うようになったのも大きいです。自分の言葉で説明しづらかったことが、書いてあるので指させる。社内向けの文章にもなっていたんだな、と後から気づきました。",
],
},
{
id: "voice-interview-q4",
no: "Q4",
q: "これから同じ悩みを持つ会社に、伝えるとしたら。",
a: [
"デザインをきれいにする話だと思わないほうがいいです。うちの場合は、断ってきた仕事の理由を人に説明する作業でした。地味だし、正直やりたくなかった。でもあれをやらずに見た目だけ変えていたら、同じ電話を受け続けていたと思います。",
],
},
];
/**
* 本文から抜いたプルクオート(特大・1箇所だけ)。マーカーは transform のみで引く。
* 改行位置は意味の切れ目で自分で決める。1行は和文7〜8字まで——行ごとの inline-block に
* マーカーを敷くので、行が器の中で内部折り返しすると最終行だけに罫が残って崩れる。
* フォントの clamp 下限(1.9rem)も、いちばん狭い器(ギャラリーの台=約270px)で
* 7字が1行に収まる値から決めている。
*/
const pullQuote = ["強みを並べず、", "断った仕事の", "理由から書いた"];
export default function VoiceInterviewFeature() {
const qaRefs = useRef<(HTMLDivElement | null)[]>([]);
const quoteRef = useRef<HTMLElement>(null);
const [quoteIn, setQuoteIn] = useState(false);
/** ジャンプ着地の合図。同じセルを連打しても再点灯するよう seq を持つ */
const [landed, setLanded] = useState<{ index: number; seq: number } | null>(null);
// プルクオートのマーカーだけ、画面に入ったら引く(IO 非対応環境は即時)
useEffect(() => {
const el = quoteRef.current;
if (!el) return;
if (typeof IntersectionObserver === "undefined") {
const id = requestAnimationFrame(() => setQuoteIn(true));
return () => cancelAnimationFrame(id);
}
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
setQuoteIn(true);
io.disconnect();
}
},
{ threshold: 0.4 },
);
io.observe(el);
return () => io.disconnect();
}, []);
// 着地の合図は一定時間で消す
useEffect(() => {
if (!landed) return;
const id = window.setTimeout(() => setLanded(null), 1800);
return () => window.clearTimeout(id);
}, [landed]);
const jumpTo = (index: number) => {
const el = qaRefs.current[index];
if (!el) return;
const reduce =
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
el.scrollIntoView({ behavior: reduce ? "auto" : "smooth", block: "center" });
// 視線とフォーカスを同時に運ぶ(スクロールは scrollIntoView 側に任せる)
el.focus({ preventScroll: true });
setLanded((prev) => ({ index, seq: (prev?.seq ?? 0) + 1 }));
};
return (
<section
aria-labelledby="voice-interview-feature-heading"
className="w-full max-w-3xl bg-white px-1"
>
{/* 記事のクレジット行(英字キッカーは置かない・標本IDは記事メタとして添える) */}
<p className="text-[11px] font-semibold text-gray-400">
<span className={`${montserrat.className} tabular-nums`}>BLK-64</span>
<span aria-hidden="true" className="mx-2 text-gray-300">
/
</span>
導入事例インタビュー
</p>
{/* 見出しは体言止め(「。」で止めない) */}
<h2
id="voice-interview-feature-heading"
className="mt-3 text-[clamp(1.8rem,4vw,2.6rem)] leading-[1.3] font-bold tracking-tight text-gray-900"
>
一社だけを、最後まで聞く
</h2>
<p className="mt-5 text-[15px] leading-[1.9] font-medium text-gray-600">
短い感想を並べる代わりに、一社の担当者に四つだけ質問しました。うまくいった話より、うまくいっていなかった頃の話を長めに聞いています。
</p>
{/* 3点サマリ(hairline 格子・角丸なし)。押すと該当の質問へ着地する記事の目次 */}
<div className="mt-9 grid grid-cols-1 gap-px border border-gray-200 bg-gray-200 sm:grid-cols-3">
{summary.map((s) => (
<button
key={s.label}
type="button"
onClick={() => jumpTo(s.jump)}
aria-controls={qa[s.jump].id}
className="group relative flex h-full w-full flex-col bg-white px-4 py-4 text-left focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600 sm:px-5 sm:py-5"
>
{/* hover/focus でアンバーの罫線が左から伸びる(transform のみ) */}
<span
aria-hidden="true"
className="absolute top-0 left-0 h-[2px] w-full origin-left scale-x-0 bg-amber-600 transition-transform duration-300 ease-out group-hover:scale-x-100 group-focus-visible:scale-x-100 motion-reduce:transition-none"
/>
<span className="text-[12px] font-bold tracking-[0.04em] text-amber-700">
{s.label}
</span>
<span className="mt-2 text-[13.5px] leading-[1.85] font-medium text-gray-800">
{s.body}
</span>
<span
className={`${montserrat.className} mt-3 text-[10px] font-bold tracking-[0.08em] text-gray-300 tabular-nums`}
>
{qa[s.jump].no}
</span>
</button>
))}
</div>
{/* 話し手(インタビュー記事のクレジット・カードで囲まない) */}
<div className="mt-11 flex flex-wrap items-baseline gap-x-4 gap-y-1 border-t border-gray-200 pt-4">
<span aria-hidden="true" className="h-px w-6 self-center bg-amber-600" />
<span className="text-[14px] font-bold text-gray-900">{speaker.name}</span>
<span className="text-[13px] text-gray-500">
{speaker.attr}・{speaker.scale}
</span>
<span
className={`${montserrat.className} ml-auto text-[11px] font-semibold text-gray-400 tabular-nums`}
>
{speaker.when}
</span>
</div>
{/* Q&A 本文(出現アニメは掛けない=読み物として最初から全文が読める) */}
{qa.map((item, i) => (
<div key={item.id}>
<div
id={item.id}
ref={(el) => {
qaRefs.current[i] = el;
}}
tabIndex={-1}
className="relative border-b border-gray-200 py-8 pl-4 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 sm:pl-5"
>
{/* 着地の合図(opacity のみ・レイアウトは動かさない) */}
<span
aria-hidden="true"
className={`absolute top-8 bottom-8 left-0 w-[3px] bg-amber-500 transition-opacity duration-500 ease-out motion-reduce:transition-none ${
landed?.index === i ? "opacity-100" : "opacity-0"
}`}
/>
<div className="grid grid-cols-[2.25rem_minmax(0,1fr)] gap-x-3 gap-y-3 sm:grid-cols-[3.25rem_minmax(0,1fr)] sm:gap-x-6">
{/* 質問ラベル: 小さな英字+アンバーの角(角丸は使わない) */}
<span className="relative block self-start pt-2 pl-2">
<span
aria-hidden="true"
className="absolute top-0 left-0 h-3 w-3 border-t-2 border-l-2 border-amber-600"
/>
<span
className={`${montserrat.className} block text-[12px] font-bold tracking-[0.06em] text-amber-700 tabular-nums`}
>
{item.no}
</span>
</span>
<h3 className="self-start text-[16px] leading-[1.75] font-bold text-gray-900 sm:text-[17px]">
{item.q}
</h3>
<span
aria-hidden="true"
className={`${montserrat.className} block pl-2 text-[12px] font-bold tracking-[0.06em] text-gray-300`}
>
A
</span>
<div>
{item.a.map((p, j) => (
<p
key={j}
className={`text-[14.5px] leading-[2] text-gray-700 ${j > 0 ? "mt-5" : ""}`}
>
{p}
</p>
))}
</div>
</div>
</div>
{/* プルクオートは1箇所だけ・Q2 の回答の直後に特大で置く */}
{i === 1 ? (
<figure ref={quoteRef} className="my-10 sm:my-14">
{/*
マーカーの基準は行ごとの inline-block。relative な inline 要素に absolute+w-full を
掛けると、折り返した2行目以降で幅の基準(最初のフラグメント)がずれて崩れる。
inline-block なら shrink-to-fit で文字幅ぴったり、狭幅で内部折り返しした時だけ
コンテナ幅いっぱいに伸びる(どちらでもマーカーが文字からはみ出さない)。
*/}
<blockquote className="text-[clamp(1.9rem,5.6vw,3.1rem)] leading-[1.5] font-bold tracking-tight text-gray-900">
{pullQuote.map((line, k) => (
<span key={line} className="block">
<span className="relative inline-block">
<span
aria-hidden="true"
style={{ transitionDelay: `${k * 160}ms` }}
className={`absolute inset-x-0 bottom-[0.26em] h-[0.34em] origin-left bg-amber-500/40 transition-transform duration-700 ease-out motion-reduce:transition-none ${
quoteIn ? "scale-x-100" : "scale-x-0 motion-reduce:scale-x-100"
}`}
/>
<span className="relative">{line}</span>
</span>
</span>
))}
</blockquote>
<figcaption className="mt-5 text-[12px] font-semibold text-gray-400">
{speaker.name}({speaker.attr})
</figcaption>
</figure>
) : null}
</div>
))}
{/* 記事の脚注(末尾に誘導リンクは置かない=記事はここで閉じる) */}
<p className="mt-8 text-[12px] leading-[1.95] text-gray-500">
掲載はご本人の確認のうえ、社名と一部の数値を伏せています。この記事はデモ用に構成した架空のインタビューです。
</p>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/voice-interview-feature.json