Customer Voices — Stacked Polaroids
A testimonials block that presents each voice as a printed photo laid on a desk. White-framed, shadowed cards each get their own tilt (-3.4° to +2.9°) and are offset and overlapped with an explicit stacking order. The difference from the aligned ledger of testimonial-voices (BLK-07) is precisely the absence of alignment: this one carries warmth and a handmade feel. No face photos — the print window holds an initial badge and the quote itself, while the thick bottom border carries a handwritten-style signature (Yomogi) over an amber pen stroke. Because the resting pose is itself a transform, reveal and hover would collide on one element; it is solved by splitting into two layers (outer li = translate-y/opacity, inner = rotate) plus a keyframe with animation-fill-mode: backwards whose final keyframe equals the resting pose. Tilted text is harder to read, so each card is focusable (tabIndex=0) and straightens on hover/focus-visible. Zero image assets; reduced motion falls back to instant display.
- Added:
- 2026-08-10
- Dependencies:
- None
- tags
- #block #testimonial #voices #polaroid #scrapbook #tilt #stacked-cards #handwriting #no-image #recruit-reference
Preview
BLK-18 — Voices / お客様の声
いただいた言葉を、一枚ずつ。
かしこまったレビューではなく、打ち合わせの終わりにこぼれた一言を。First CH に届いた声を、机に並べた写真のまま置いておきます。
写真も文章も、こちらが用意できないところからの相談でした。それでも、店の空気がちゃんと伝わるページになった。
R.T 様飲食店 2店舗 オーナー2025.11 求人に応募が来ないのが当たり前でした。今は面接の席で「サイトを見て」と言われます。
K.N 様訪問介護事業所 管理者2026.01 パソコンは今も苦手なままです。それでも更新のたびに電話一本で済む。そこがいちばん助かっています。
S.H 様造園業(個人事業)2025.09 スマホで見たときの分かりやすさが違います。予約の電話が、迷いなくかかってくるようになりました。
Y.M 様歯科医院 受付2026.02 社内で「これは自分たちの会社だ」と言えるサイトになりました。名刺代わりに、堂々と渡せます。
A.F 様建材商社 営業部2025.12
"use client";
import { useEffect, useRef, useState } from "react";
import { Montserrat, Yomogi } from "next/font/google";
const montserrat = Montserrat({
weight: ["600", "700"],
subsets: ["latin"],
display: "swap",
});
// preload: false は和文フォント(Yomogi 等)では必須。
// 理由と実測は src/app/[lang]/layout.tsx のコメント参照。
const yomogi = Yomogi({
weight: ["400"],
subsets: ["latin"],
display: "swap",
preload: false,
});
/**
* お客様の声(ポラロイド風・重ね置き)
* 参考: 採用サイト等で見かける「傾き重なりの印画紙カード(スクラップブック演出)」の構造だけを借り、
* 白基調+アンバーの自社ブランドで再構築(テキスト・配色・構図はすべてオリジナル。コピーではない)。
* 顔写真は使わない方針のため、印画紙の窓には「イニシャル円+声そのもの」を入れ、
* フチ(下の太い余白)に手書き風の署名を置いて人格を出す。画像素材ゼロ。
*
* 汎用技法メモ(web-design-playbook 還流用):
* - 静止姿勢が transform のとき、reveal と hover を1要素に載せると衝突する。
* → 2層に割る: 外側<li>が reveal(translate-y/opacity の transition)、内側が姿勢(rotate)。
* さらに内側の「置かれる」演出は transition ではなく keyframe + animation-fill-mode: backwards で行う。
* 終了キーフレームを静止姿勢(rotate(var(--tilt)))と一致させておけば終了時に跳ねず、
* かつ hover の transition に delay を残さずに済む(transition-delay は hover にも効いてしまうため)。
* - 傾きはカードごとの CSS変数 --tilt をインラインで与え、Tailwind 側は [transform:rotate(var(--tilt))] の
* 1クラスで受ける。hover/focus-visible では rotate(0deg) に戻す=「写真を手に取ってまっすぐ見る」メタファー。
* - 重なりの前後関係は z-[40]/z-[34]… と段階指定し、hover:z-50 / focus-within:z-50 で最前面へ。
* z-index は「行1が上・行2が下」に固定し、重なりが食うのは下段カードの空白フチだけにする(本文を隠さない)。
* - 傾いた文字は読みづらいので、カード自体を tabIndex={0} でフォーカス可能にし、
* focus-visible でまっすぐに戻す(装飾ではなく可読性のための到達手段)。
* このとき outline-none は付けない: Tailwind v4 の outline-none は --tw-outline-style: none を立てるため、
* 後段の focus-visible:outline-2 が outline-style: none に解決されフォーカスリングが消える。
* - 手書き署名の「書かれる」感じは overflow-hidden の枠+内側 translateX(-102%)→0 のワイプで作る
* (stroke-dashoffset を使わずに transform だけで済む)。
* - すべて transform/opacity のみ。prefers-reduced-motion では animate-none / transition-none で即時表示。
*/
type Voice = {
/** イニシャル(顔写真の代わりに人格を示す) */
initial: string;
/** 手書き風の署名 */
sign: string;
attr: string;
quote: string;
/** 印画紙の日付スタンプ */
date: string;
/** 傾き角(CSS変数 --tilt に渡す) */
tilt: string;
/** グリッド上の位置とオフセット(散らばりを作る) */
place: string;
/** 重なりの前後関係 */
z: string;
/** マスキングテープを貼るか */
tape: boolean;
};
const voices: Voice[] = [
{
initial: "RT",
sign: "R.T 様",
attr: "飲食店 2店舗 オーナー",
quote:
"写真も文章も、こちらが用意できないところからの相談でした。それでも、店の空気がちゃんと伝わるページになった。",
date: "2025.11",
tilt: "-3.4deg",
place: "sm:col-start-1 sm:mt-0",
z: "z-[40]",
tape: true,
},
{
initial: "KN",
sign: "K.N 様",
attr: "訪問介護事業所 管理者",
quote:
"求人に応募が来ないのが当たり前でした。今は面接の席で「サイトを見て」と言われます。",
date: "2026.01",
tilt: "2.3deg",
place: "sm:col-start-3 sm:mt-12",
z: "z-[34]",
tape: false,
},
{
initial: "SH",
sign: "S.H 様",
attr: "造園業(個人事業)",
quote:
"パソコンは今も苦手なままです。それでも更新のたびに電話一本で済む。そこがいちばん助かっています。",
date: "2025.09",
tilt: "-1.5deg",
place: "sm:col-start-5 sm:mt-4",
z: "z-[28]",
tape: true,
},
{
initial: "YM",
sign: "Y.M 様",
attr: "歯科医院 受付",
quote:
"スマホで見たときの分かりやすさが違います。予約の電話が、迷いなくかかってくるようになりました。",
date: "2026.02",
tilt: "2.9deg",
place: "sm:col-start-2 sm:-mt-4",
z: "z-[16]",
tape: true,
},
{
initial: "AF",
sign: "A.F 様",
attr: "建材商社 営業部",
quote:
"社内で「これは自分たちの会社だ」と言えるサイトになりました。名刺代わりに、堂々と渡せます。",
date: "2025.12",
tilt: "-2.5deg",
place: "sm:col-start-4 sm:-mt-9",
z: "z-[10]",
tape: false,
},
];
export default function TestimonialPolaroid() {
const rootRef = useRef<HTMLElement>(null);
const [active, setActive] = useState(false);
// 画面内に入ったら reveal を開始(IO 非対応環境は即開始)
// reduced-motion 対応は 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.12 },
);
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-polaroid-heading"
className="w-full max-w-4xl 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-18 — Voices / お客様の声
</p>
<h2
id="testimonial-polaroid-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 のグルーピング見出し(箱で囲まず罫線と余白で束ねる) */}
<div
style={{ transitionDelay: "240ms" }}
className={`mt-10 flex items-baseline justify-between gap-4 border-t border-gray-200 pt-4 ${reveal}`}
>
<span
className={`${montserrat.className} text-[11px] font-semibold tracking-[0.22em] text-gray-500 uppercase`}
>
Guest Voices
</span>
<span
className={`${montserrat.className} text-[11px] font-semibold tracking-[0.22em] text-gray-300 tabular-nums uppercase`}
>
{String(voices.length).padStart(2, "0")} Photos
</span>
</div>
{/* 印画紙の散らばり(傾き+段差+前後関係で「置かれた写真」に見せる) */}
{/* isolate: カードの z-index をこのリスト内に閉じ込め、ホスト側の重ね順を壊さない */}
<ul
role="list"
className="isolate mt-8 grid grid-cols-1 gap-y-5 sm:mt-10 sm:grid-cols-6 sm:gap-x-0 sm:gap-y-0"
>
{voices.map((v, i) => {
const delay = 320 + i * 110;
return (
<li
key={v.sign}
style={{ transitionDelay: `${delay}ms` }}
className={`relative ${v.z} ${v.place} w-[min(100%,19rem)] sm:col-span-2 sm:w-auto ${
i % 2 === 0 ? "mr-auto" : "ml-auto"
} sm:mx-0 ${reveal} hover:z-50 focus-within:z-50`}
>
{/* 姿勢レイヤー: 傾き・置かれる演出・手に取る(reveal とは層を分ける) */}
<div
tabIndex={0}
style={
{
"--tilt": v.tilt,
animationDelay: `${delay}ms`,
} as React.CSSProperties
}
className={`relative rounded-[2px] border border-gray-200 bg-white p-2.5 pb-0 shadow-[0_1px_2px_rgba(17,24,39,0.06),0_12px_26px_-16px_rgba(17,24,39,0.45)] transition-[transform,box-shadow] duration-500 ease-out [transform:rotate(var(--tilt))] hover:shadow-[0_2px_4px_rgba(17,24,39,0.06),0_22px_40px_-20px_rgba(17,24,39,0.5)] hover:[transform:rotate(0deg)_translateY(-10px)_scale(1.03)] focus-visible:[transform:rotate(0deg)_translateY(-10px)_scale(1.03)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 motion-reduce:transition-none motion-reduce:animate-none ${
active
? "[animation:pola-settle_0.7s_cubic-bezier(0.2,0.8,0.25,1)_backwards]"
: ""
}`}
>
{/* マスキングテープ(装飾) */}
{v.tape && (
<span
aria-hidden="true"
className="absolute -top-3 left-7 z-10 block h-6 w-16 -rotate-6 border-y border-amber-600/20 bg-amber-500/20"
/>
)}
<figure>
{/* 印画紙の窓: 顔写真の代わりにイニシャル円+声そのものを入れる */}
<div className="flex min-h-[9.5rem] flex-col justify-between bg-stone-50 px-4 py-4">
<div className="flex items-center justify-between gap-3">
<span
aria-hidden="true"
className={`${montserrat.className} flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-amber-600/25 bg-white text-[13px] font-bold tracking-[0.06em] text-amber-700`}
>
{v.initial}
</span>
<span
aria-hidden="true"
className="h-px w-8 shrink-0 bg-amber-600/40"
/>
</div>
<blockquote className="mt-4 text-[13.5px] leading-[1.95] text-gray-800">
{v.quote}
</blockquote>
</div>
{/* 印画紙の下フチ: 手書き風の署名(ワイプで「書かれる」) */}
<figcaption className="px-1 pt-3.5 pb-5">
<span className="block overflow-hidden py-0.5">
<span
style={{ animationDelay: `${delay + 260}ms` }}
className={`inline-block ${
active
? "[animation:pola-sign_0.65s_cubic-bezier(0.22,1,0.36,1)_backwards]"
: ""
} motion-reduce:animate-none`}
>
<span
className={`${yomogi.className} block text-[17px] leading-tight text-gray-900`}
>
{v.sign}
</span>
<svg
aria-hidden="true"
viewBox="0 0 120 8"
fill="none"
className="mt-0.5 block h-2 w-24 text-amber-600/70"
>
<path
d="M1.5 5.4C19 2.6 40 1.7 62.5 3.2c15 1 26 2.7 40 1.2"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
</span>
</span>
<span className="mt-2 flex items-baseline justify-between gap-2">
<span className="text-[11px] leading-snug text-gray-500">
{v.attr}
</span>
<span
className={`${montserrat.className} shrink-0 text-[10px] tracking-[0.18em] text-gray-300 tabular-nums`}
>
{v.date}
</span>
</span>
</figcaption>
</figure>
</div>
</li>
);
})}
</ul>
{/* テキストCTA(下線ワイプ・focus-visible) */}
<div
style={{ transitionDelay: `${320 + voices.length * 110}ms` }}
className={`mt-12 border-t border-gray-200 pt-6 sm:mt-16 ${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(このブロック内で完結。transform のみ) */}
<style>{`
@keyframes pola-settle {
from { transform: rotate(0deg) translateY(-14px) scale(1.02); }
to { transform: rotate(var(--tilt)); }
}
@keyframes pola-sign {
from { transform: translateX(-102%); }
to { transform: translateX(0); }
}
`}</style>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/testimonial-polaroid.json