First CH Designs
BLK-64Blocks

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導入事例インタビュー

一社だけを、最後まで聞く

短い感想を並べる代わりに、一社の担当者に四つだけ質問しました。うまくいった話より、うまくいっていなかった頃の話を長めに聞いています。

K.M 様建設業・専務従業員 18名2026.03
Q1

サイトを作り直そうと思ったきっかけは何でしたか。

見た目が古いから、ではなかったです。問い合わせの数はそれなりにあって、でも半分くらいは最初の電話でお断りしていました。工期も予算も、うちがやっている仕事と合っていない。それが毎週続くと、さすがに何かがずれているなと。

社内では「広告を増やそう」という話になっていました。増やしたら、合わない相談も同じ比率で増えるだけじゃないかと言ったら、場が止まりまして。そこから、サイトの話になりました。

Q2

制作中、いちばん時間をかけた議論はどこでしたか。

最初のヒアリングで「御社の強みを3つ挙げてください」と聞かれると思っていたんです。実際に聞かれたのは、直近で断った仕事とその理由でした。12件ぜんぶ、理由を言葉にさせられました。

並べてみたら、断る理由はほぼ2種類しかなかった。それを裏返したものが、うちが引き受けたい仕事の条件そのものでした。トップページに何を書くかは、その日にほぼ決まりました。

強みを並べず、断った仕事の理由から書いた
K.M 様建設業・専務
Q3

公開してから、社内で変わったことはありますか。

問い合わせの件数は、正直そんなに変わっていません。月に20件前後のまま。ただ、初回の打ち合わせから見積もりに進む割合が、体感で倍くらいになりました。先方がサイトを読んだうえで、条件を分かって連絡してくださる。

現場の若い社員が、サイトの言葉をそのまま営業で使うようになったのも大きいです。自分の言葉で説明しづらかったことが、書いてあるので指させる。社内向けの文章にもなっていたんだな、と後から気づきました。

Q4

これから同じ悩みを持つ会社に、伝えるとしたら。

デザインをきれいにする話だと思わないほうがいいです。うちの場合は、断ってきた仕事の理由を人に説明する作業でした。地味だし、正直やりたくなかった。でもあれをやらずに見た目だけ変えていたら、同じ電話を受け続けていたと思います。

掲載はご本人の確認のうえ、社名と一部の数値を伏せています。この記事はデモ用に構成した架空のインタビューです。

"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