First CH Designs
BLK-40Blocks

折り屏風カルーセル

板を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 学習塾 体験申込を、保護者の夜の時間に合わせる

Fold
rotateY: (i - 3) × 42degtranslateX: -3 × 100/7%visible: 5 / 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