Folding-Panel Carousel
A horizontal carousel where each panel folds around its own vanishing point. Placing perspective on every slide box instead of the track means each panel rotates about its own centre, so no trapezoidal distortion appears and the row reads as a folding screen rather than a perspective corridor. Because no shared 3D context exists, the stage can simply use overflow-hidden (which would flatten preserve-3d), at the cost of paint order: z-index has to be distributed by distance from the centre. Travel is expressed as a percentage — a flex w-fit track is exactly one panel wide times N, so translateX(-active * 100/N %) advances one panel and no dimension is ever measured, even when the panel width changes per breakpoint. Panels rotated past ±90° are removed by backface-visibility, so the number of visible panels falls out of the fold angle itself. Angle and scale are passed as CSS custom properties while the transform lives in a class, since an inline-style transform outranks media queries and would break the reduced-motion fallback. Only transform and opacity animate; zero dependencies; under prefers-reduced-motion it degrades to a plain flat filmstrip.
- Added:
- 2026-08-25
- Dependencies:
- None
- tags
- #carousel #3d #perspective #fold #gallery #works #keyboard #swipe #no-dependency #no-image #reduced-motion
Preview
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>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/tilted-panel-carousel.json