折り屏風カルーセル
板を1枚ずつ別々の消失点で回す横送りカルーセル。perspective をトラックではなくスライドの箱ごとに置くと、板は各自の中心を軸に回るため台形の歪みが出ず、「同じ板が角度だけ違う」=屏風の折りとして読める。3D空間を共有しないので台紙側を普通に overflow-hidden でクリップでき(preserve-3d との併用と違って潰れない)、代わりに重なり順は DOM 順のままなので中央からの距離で z-index を配る。送り量は flex w-fit のトラック幅が板1枚×N枚である性質を使い translateX(-active * 100/N %) と%で書くため、板幅をブレークポイントで変えても寸法の測定が一切要らない。±90°を超えて回り込んだ板は backface-visibility が自動的に消すので「何枚見せるか」の計算も持たない(折り角を深くすると見える枚数がひとりでに減る)。角度と拡大率は CSS 変数として style で渡し transform 自体はクラスで書く(inline style の transform は media query より強く、モーション降格が効かなくなるため)。動かすのは transform と opacity だけ・依存ゼロ・prefers-reduced-motion では回転と遷移を捨てた素の横並びへ降格・2026-08-25。
- 追加日:
- 2026-08-25
- 依存:
- なし
- tags
- #carousel #3d #perspective #fold #gallery #works #keyboard #swipe #no-dependency #no-image #reduced-motion
プレビュー
Specimen BLK-40
手がけた仕事を、屏風のように折って見せる。
板を1枚ずつ別々の消失点で回すため、遠近の歪みが出ないまま角度だけが変わります。折り角を深くすると回り込んだ板は自動的に消え、見える枚数がひとりでに減ります。
04 / 07|学習塾
体験申込を、保護者の夜の時間に合わせる
入力項目を4つに絞り、翌営業日の折り返し時刻をその場で提示する形にしました。
4 / 7 学習塾 体験申込を、保護者の夜の時間に合わせる
"use client";
import { useCallback, useId, useRef, useState } from "react";
/**
* 折り屏風カルーセル(BLK-40)
*
* 汎用技法メモ(web-design-playbook へ還流する要点)
*
* 1. perspective は「トラック」ではなく「スライド1枚ごと」に置く
* 共通の親に perspective を置くと消失点が1つになり、中央から離れた板ほど台形に歪んで
* 遠近が付く=ふつうの3Dカルーセルになる。スライドの箱それぞれに perspective を持たせると
* 板は各自の中心を消失点として回るので、歪みが出ず「同じ板が角度だけ違う」=屏風の折りに見える。
* 書くのは `[perspective:900px]` を li 側に置くだけで、JS は1行も増えない。
*
* 2. 個別 perspective は 3D 空間を共有しない = 親に overflow-hidden を掛けられる
* preserve-3d と overflow:hidden は共存できない(flat に潰れる)が、この構成では
* 親は 2D のまま各スライドが独立して 3D を持つため、台紙側を普通に `overflow-hidden`
* `rounded-2xl` でクリップできる。カード内クリップとの両立に悩まなくなる。
* 代償は前後関係で、3D 空間を共有しないので重なり順は DOM 順のまま=
* 中央からの距離で `z-index` を配る必要がある(近いほど手前)。
*
* 3. トラックの送り量は「%」で書くと寸法を1つも測らなくていい
* N枚を `flex w-fit` で横に並べると、トラック幅 = 板1枚 × N。
* つまり `translateX(-active * 100 / N %)` が板ちょうど1枚分の送りになる。
* 板幅をブレークポイントで変えても(レスポンシブでも)式は変わらないので、
* ResizeObserver も offsetWidth の読み取りも不要になる。
*
* 4. 「回り込んだ板」は backface-visibility で自動的に消す
* 回転角が ±90° を超えた板は裏返って中身が鏡像で見える。板に
* `[backface-visibility:hidden]` を置けば、その瞬間に消えるので
* 「何枚まで見せるか」を JS で数える必要がない。折り角を深くすると見える枚数が減り、
* 浅くすると増える、という挙動がそのまま手に入る(角度=奥行きの唯一のつまみになる)。
*
* 5. 角度・拡大率は inline style で「値」だけ渡し、transform 自体はクラスで書く
* inline style の transform は media query より強く、`motion-reduce:[transform:none]` で
* 上書きできない=モーション降格が効かなくなる。style へ渡すのは `--fold` `--sc` の
* CSS 変数だけにし、`[transform:rotateY(var(--fold))_scale(var(--sc))]` をクラスで当てると、
* reduced-motion のときだけ回転を捨てて素の横並びへ落とせる。
*
* 6. 動かすのは transform と opacity だけ
* 非選択の板を blur で落としたくなるが、filter はレイヤ合成のたびに再描画が要る。
* 不透明度と縮小だけでも「奥にある」は充分に読める。ばね感は
* cubic-bezier(0.22, 1.1, 0.32, 1) の僅かなオーバーシュートで作る(ライブラリ不要)。
*
* 依存ゼロ・prefers-reduced-motion では回転と遷移を捨てた素の横並びへ降格する。
*
* 着想: 「板を1枚ずつ別々の消失点で折る横送りカルーセル」という表現の仕組みのみ。
* 文言・配色・素材・実装はすべて First CH のオリジナル(外部コード・画像の取り込みは無し)。
*/
type Panel = {
no: string;
kind: string;
field: string;
title: string;
note: string;
date: string;
grain: 0 | 1 | 2 | 3;
};
const panels: Panel[] = [
{
no: "01",
kind: "SITE",
field: "内科・小児科",
title: "予約の導線を、診療時間の内側に置く",
note: "受付が閉まっている時間帯だけ、電話番号より先に予約フォームが出る構成にしました。",
date: "2026.06",
grain: 0,
},
{
no: "02",
kind: "TOOL",
field: "精密板金",
title: "図面のやり取りを、1本の履歴にまとめる",
note: "改訂のたびに増えていた添付ファイルを、案件ごとの1画面へ束ねました。",
date: "2026.04",
grain: 1,
},
{
no: "03",
kind: "SITE",
field: "旅館",
title: "空いている日から、先に見せる",
note: "料金表より前に空室カレンダーを置き、決められる人が最短で決めきれる順番に直しました。",
date: "2026.02",
grain: 2,
},
{
no: "04",
kind: "FORM",
field: "学習塾",
title: "体験申込を、保護者の夜の時間に合わせる",
note: "入力項目を4つに絞り、翌営業日の折り返し時刻をその場で提示する形にしました。",
date: "2025.12",
grain: 1,
},
{
no: "05",
kind: "WORKS",
field: "写真スタジオ",
title: "作品の順番を、撮った人が並べ替えられるように",
note: "更新依頼で止まっていた掲載順の入れ替えを、管理画面のドラッグだけで終わるようにしました。",
date: "2025.10",
grain: 3,
},
{
no: "06",
kind: "SHOP",
field: "製パン",
title: "焼き上がりの時刻を、そのまま棚の並びにする",
note: "時間帯で商品の並び順が変わる棚にし、夕方に来た人へ残っているものだけを見せます。",
date: "2025.08",
grain: 0,
},
{
no: "07",
kind: "RECRUIT",
field: "運送",
title: "採用の入口を、免許区分から分ける",
note: "応募前の不安が職種で違うため、区分ごとに読む順番と載せる数字を変えました。",
date: "2025.06",
grain: 2,
},
];
// 地紋。板ごとに角度だけ変え、色数は増やさない(白基調を保つ)
const grains = [
"repeating-linear-gradient(45deg, rgba(17,24,39,0.055) 0 1px, transparent 1px 9px)",
"repeating-linear-gradient(-45deg, rgba(17,24,39,0.055) 0 1px, transparent 1px 9px)",
"repeating-linear-gradient(90deg, rgba(17,24,39,0.05) 0 1px, transparent 1px 11px)",
"repeating-linear-gradient(0deg, rgba(17,24,39,0.05) 0 1px, transparent 1px 11px)",
];
// 折り角=この表現の唯一のつまみ。深いほど1枚あたりの回り込みが早く、見える枚数が減る
const folds = [
{ label: "浅い", deg: 24 },
{ label: "標準", deg: 42 },
{ label: "深い", deg: 64 },
];
const SWIPE_PX = 44;
export default function TiltedPanelCarousel() {
const uid = useId();
const headingId = `${uid}-heading`;
const pointer = useRef<{ id: number; x: number } | null>(null);
const [index, setIndex] = useState(3);
const [fold, setFold] = useState(1);
const step = folds[fold].deg;
const active = panels[index];
const goTo = useCallback((i: number) => {
setIndex(Math.max(0, Math.min(panels.length - 1, i)));
}, []);
const onKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const map: Record<string, number> = {
ArrowLeft: index - 1,
ArrowRight: index + 1,
Home: 0,
End: panels.length - 1,
};
const next = map[e.key];
if (next === undefined) return;
e.preventDefault();
goTo(next);
},
[index, goTo],
);
// スワイプ: ドラッグ追従はせず「離した位置」だけで1枚送る(状態が増えずSSRとも一致する)
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (e.pointerType === "mouse") return;
pointer.current = { id: e.pointerId, x: e.clientX };
}, []);
const onPointerUp = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
const start = pointer.current;
pointer.current = null;
if (!start || start.id !== e.pointerId) return;
const dx = e.clientX - start.x;
if (Math.abs(dx) < SWIPE_PX) return;
goTo(index + (dx < 0 ? 1 : -1));
},
[index, goTo],
);
const chipBase =
"rounded-full border px-3 py-1 text-xs font-semibold transition-colors duration-200 motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600";
const arrowBase =
"grid size-9 place-items-center rounded-full border border-gray-200 bg-white text-gray-700 transition-colors duration-200 hover:border-gray-300 hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:border-gray-200 disabled:hover:text-gray-700 motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600";
return (
<section
aria-labelledby={headingId}
className="@container w-full max-w-3xl overflow-hidden rounded-2xl border border-gray-200 bg-white"
>
{/* 見出し */}
<div className="px-5 pt-8 @2xl:px-10 @2xl:pt-10">
<p className="font-display text-[11px] font-bold tracking-[0.25em] text-amber-600 uppercase">
Specimen BLK-40
</p>
<h2
id={headingId}
className="font-display mt-3 text-[clamp(1.5rem,3.4vw,2rem)] leading-tight font-bold tracking-tight text-gray-900"
>
手がけた仕事を、屏風のように折って見せる。
</h2>
<p className="mt-3 max-w-lg text-[13px] leading-[1.95] text-gray-600">
板を1枚ずつ別々の消失点で回すため、遠近の歪みが出ないまま角度だけが変わります。折り角を深くすると回り込んだ板は自動的に消え、見える枚数がひとりでに減ります。
</p>
</div>
{/* 台紙。個別 perspective なので 3D 空間を共有せず、ここを overflow-hidden でクリップできる */}
<div
role="group"
aria-labelledby={headingId}
tabIndex={0}
onKeyDown={onKeyDown}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerCancel={() => (pointer.current = null)}
className="relative mt-8 touch-pan-y overflow-hidden border-y border-gray-200 bg-gray-50/60 py-10 [--card:132px] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600 @xl:[--card:168px] @2xl:py-12"
>
{/* 中央のガイド罫。板が戻る位置=この線の上だと分かる */}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-1/2 h-px bg-gray-900/[0.06]"
/>
{/* ビューポートは板1枚ぶんの幅だけ。左右へはみ出した板がそのまま隣として見える */}
<div className="relative mx-auto w-[var(--card)]">
<ul
style={{ ["--shift" as string]: `${(-index * 100) / panels.length}%` }}
className="flex w-fit [transform:translate3d(var(--shift),0,0)] transition-transform duration-[820ms] [transition-timing-function:cubic-bezier(0.22,1.1,0.32,1)] will-change-transform motion-reduce:transition-none"
>
{panels.map((panel, i) => {
const offset = i - index;
const angle = offset * step;
const folded = Math.abs(angle) >= 90; // 回り込んだ板(backface で消える)
const distance = Math.abs(offset);
const isActive = offset === 0;
return (
// ① perspective は「板1枚ごと」。② 重なり順は距離から配る(3D空間を共有しないため)
<li
key={panel.no}
style={{ zIndex: panels.length - distance }}
className="relative w-[var(--card)] [perspective:900px]"
>
<div
style={{
["--fold" as string]: `${angle}deg`,
["--sc" as string]: isActive ? "1" : "0.86",
["--op" as string]: folded ? "0" : (1 - distance * 0.16).toFixed(2),
// 降格時は回り込みが起きない=板が消える理由がないので、下限を持つ不透明度に置き換える
["--op-flat" as string]: Math.max(0.3, 1 - distance * 0.16).toFixed(2),
}}
className="[transform:rotateY(var(--fold))_scale(var(--sc))] [backface-visibility:hidden] opacity-[var(--op)] transition-[transform,opacity] duration-[820ms] [transition-timing-function:cubic-bezier(0.22,1.1,0.32,1)] will-change-transform motion-reduce:[transform:none] motion-reduce:opacity-[var(--op-flat)] motion-reduce:transition-none"
>
<button
type="button"
onClick={() => goTo(i)}
tabIndex={folded ? -1 : 0}
aria-current={isActive || undefined}
aria-label={`${panel.no} ${panel.field} ${panel.title}`}
className={`relative block aspect-[3/4] w-full cursor-pointer overflow-hidden rounded-[10px] border bg-white text-left transition-colors duration-300 motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 ${
isActive ? "border-amber-600/45" : "border-gray-200"
}`}
>
{/* 地紋 */}
<span
aria-hidden="true"
style={{ backgroundImage: grains[panel.grain] }}
className="pointer-events-none absolute inset-0"
/>
{/* ゴースト番号 */}
<span
aria-hidden="true"
className="font-display pointer-events-none absolute -right-1 bottom-0 text-[clamp(4rem,9vw,5.5rem)] leading-none font-bold tracking-tight text-gray-900/[0.055]"
>
{panel.no}
</span>
{/* 選択中だけ立つアンバーの小口 */}
<span
aria-hidden="true"
className={`pointer-events-none absolute top-0 left-0 h-[3px] w-full origin-left bg-amber-600 transition-transform duration-500 ease-out motion-reduce:transition-none ${
isActive ? "scale-x-100" : "scale-x-0"
}`}
/>
<span className="relative flex h-full flex-col justify-between p-3 @xl:p-4">
<span className="flex items-baseline justify-between gap-2">
<span className="font-display text-[10px] font-bold tracking-[0.18em] text-amber-700">
{panel.kind}
</span>
<span className="font-mono text-[10px] text-gray-400">{panel.date}</span>
</span>
<span className="block">
<span className="block h-px w-6 bg-amber-600" />
<span className="mt-2 block text-[11px] leading-[1.7] font-semibold text-gray-900 @xl:text-xs">
{panel.field}
</span>
</span>
</span>
</button>
</div>
</li>
);
})}
</ul>
</div>
</div>
{/* キャプションは板の外へ出す(狭い板の中で和文を折り返さない)。重ねて opacity だけ入れ替える */}
<div className="relative mx-5 mt-6 min-h-[104px] @2xl:mx-10">
{panels.map((panel, i) => (
<div
key={panel.no}
aria-hidden={i !== index}
className={`transition-[opacity,transform] duration-500 ease-out motion-reduce:transition-none ${
i === index
? "translate-y-0 opacity-100"
: "pointer-events-none absolute inset-0 translate-y-2 opacity-0 motion-reduce:translate-y-0"
}`}
>
<p className="font-mono text-[11px] tracking-[0.08em] text-gray-400">
{panel.no} / {String(panels.length).padStart(2, "0")}
<span className="mx-2 text-gray-300">|</span>
<span className="text-amber-700">{panel.field}</span>
</p>
<h3 className="mt-2 text-[15px] leading-[1.7] font-bold text-gray-900 @xl:text-base">
{panel.title}
</h3>
<p className="mt-2 max-w-lg text-[13px] leading-[1.9] text-gray-600">{panel.note}</p>
</div>
))}
</div>
{/* 現在位置の読み上げ(操作は本人なので polite で1回だけ) */}
<p role="status" aria-live="polite" className="sr-only">
{`${index + 1} / ${panels.length} ${active.field} ${active.title}`}
</p>
{/* 送り。ドットは幅だけを動かす(transition-[width] は再レイアウトを伴わない絶対配置の中で完結) */}
<div className="mt-8 flex flex-wrap items-center justify-between gap-4 border-t border-gray-200 px-5 py-5 @2xl:px-10">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => goTo(index - 1)}
disabled={index === 0}
aria-label="前の板へ"
className={arrowBase}
>
<svg viewBox="0 0 24 24" aria-hidden="true" className="size-4">
<path
d="M15 5 8 12l7 7"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<ul className="flex items-center gap-1.5">
{panels.map((panel, i) => (
<li key={panel.no} className="grid place-items-center">
<button
type="button"
onClick={() => goTo(i)}
aria-label={`${i + 1}枚目 ${panel.field}`}
aria-current={i === index || undefined}
className={`h-2 rounded-full transition-[width,background-color] duration-300 motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 ${
i === index ? "w-6 bg-amber-600" : "w-2 bg-gray-300 hover:bg-gray-400"
}`}
/>
</li>
))}
</ul>
<button
type="button"
onClick={() => goTo(index + 1)}
disabled={index === panels.length - 1}
aria-label="次の板へ"
className={arrowBase}
>
<svg viewBox="0 0 24 24" aria-hidden="true" className="size-4">
<path
d="m9 5 7 7-7 7"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold tracking-[0.22em] text-gray-400 uppercase">
Fold
</span>
{folds.map((f, i) => (
<button
key={f.deg}
type="button"
onClick={() => setFold(i)}
aria-pressed={i === fold}
className={`${chipBase} ${
i === fold
? "border-amber-600 bg-amber-600 text-white"
: "border-gray-200 bg-white text-gray-500 hover:border-gray-300 hover:text-gray-900"
}`}
>
{f.label}
</button>
))}
</div>
</div>
{/* いま効いている値を見せる(技法の説明そのものになる) */}
<div className="flex flex-wrap gap-x-4 gap-y-1 border-t border-gray-200 bg-gray-50/70 px-5 py-4 font-mono text-[11px] text-gray-500 @2xl:px-10">
<span className="whitespace-nowrap">
rotateY: <span className="text-amber-700">{`(i - ${index}) × ${step}deg`}</span>
</span>
<span className="whitespace-nowrap">
translateX: <span className="text-amber-700">{`-${index} × 100/${panels.length}%`}</span>
</span>
<span className="whitespace-nowrap">
visible: <span className="text-amber-700">{`${panels.filter((_, i) => Math.abs((i - index) * step) < 90).length} / ${panels.length}`}</span>
</span>
</div>
</section>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/tilted-panel-carousel.json