PGE-12Pages

Full-bleed Photo Hero (slow Ken Burns slideshow with pause control)

A hero where four full-bleed photos cross-fade while slowly zooming and panning. The headline stays fixed above the photos, and a dark bottom gradient keeps the white copy legible whatever the photo brightness. A white control strip below the photos shows the slide number and caption, progress bars that count down to the next slide, previous and next buttons, and an always-visible pause and play button (WCAG 2.2.2). Hovering pauses it temporarily; keyboard focus entering the hero stops it until the visitor presses play. Hidden slides are removed from screen readers and tab order with aria-hidden and inert. The next slide is triggered by the end of the progress bar's CSS animation, so pausing only flips animation-play-state and the zoom, the progress and the timer all stop together. Under prefers-reduced-motion the autoplay and zoom are off and only manual switching remains, and it also stops while off screen or in a hidden tab. The photos are text-free generated images that can be swapped in the SLIDES array. No dependencies.

Added:
2026-10-15
Dependencies:
None
tags
#hero #page #fullbleed #slideshow #carousel #ken-burns #image #a11y #reduced-motion

Preview

コーポレート・採用サイトのトップに

会社の雰囲気は、
言葉より先に写真で伝わる

エントランスや仕事場、会議室の写真を画面いっぱいに大きく見せます。来社したことのない方にも、働く場所の様子がトップページで分かります。

01/04エントランス来社した方を最初に迎える吹き抜け

"use client";

import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";

/**
 * 全面写真ヒーロー(ゆっくり寄るスライドショー・一時停止つき)
 *
 * 差別化: 既存のヒーロー(hero-fullbleed-visual ほか)は1枚のビジュアルか静的な構成で、
 * 時間で入れ替わる複数枚と再生制御を持たない。本ブロックは「複数枚の自動送り+一時停止・前後送り・
 * 番号送り」という操作を主役にし、英字キッカー・「。」止め見出し・末尾の矢印リンク・角丸を外した。
 *
 * 汎用技法メモ:
 * 1) 進捗バーが時計を兼ねる: 自動送りの残り時間を setInterval で数えず、進捗バーの CSS アニメ
 *    (scaleX 0→1)の animationend で次へ進める。一時停止は animation-play-state: paused を
 *    当てるだけで、寄りの動き・進捗・送りの3つが同時に止まり、再開すると続きから進む。
 * 2) 寄りの再始動: 各スライドの写真レイヤーの key を「表示された回数」で変え、表示のたびに
 *    寄りのアニメを最初からやり直す。去っていく側は key が変わらないので、クロスフェード中も
 *    動きが途切れない。始点と終点の transform は CSS 変数で渡し、keyframes を1本で共有する。
 * 3) WCAG 2.2.2 の一時停止: ホバー中は一時的に止め、キーボードでフォーカスが入ったら
 *    (:focus-visible のときだけ)完全に停止して、再生ボタンを押すまで再開しない。
 *    表示していない写真は aria-hidden と inert で読み上げとタブ移動から外し、停止中だけ
 *    番号表示を aria-live="polite" にする。prefers-reduced-motion では自動送りと寄りを止め、
 *    手動の切り替えだけを残す。画面外とタブ非表示のあいだも止める。
 */

const SLIDES = [
  {
    src: "/designs/hero-kenburns-slideshow-1.webp",
    alt: "高い吹き抜けのある白いコンクリートのエントランス。窓際に観葉植物が並ぶ",
    place: "エントランス",
    note: "来社した方を最初に迎える吹き抜け",
    from: "scale(1.04) translate3d(0, 0, 0)",
    to: "scale(1.16) translate3d(-3%, -2%, 0)",
  },
  {
    src: "/designs/hero-kenburns-slideshow-2.webp",
    alt: "夕方の光が差し込む窓辺の木の机。スケッチの紙とノートパソコンが置かれている",
    place: "仕事場",
    note: "設計の下書きを広げる窓辺の机",
    from: "scale(1.04) translate3d(1%, 0, 0)",
    to: "scale(1.15) translate3d(3%, 1.5%, 0)",
  },
  {
    src: "/designs/hero-kenburns-slideshow-3.webp",
    alt: "夕暮れの街角に建つコンクリートとガラスの社屋。窓に明かりが灯っている",
    place: "社屋",
    note: "明かりが灯りはじめる夕方の外観",
    from: "scale(1.06) translate3d(2%, -1%, 0)",
    to: "scale(1.18) translate3d(-2%, 2%, 0)",
  },
  {
    src: "/designs/hero-kenburns-slideshow-4.webp",
    alt: "木々に面した大きな窓のある会議室。長い木のテーブルと白い椅子が並ぶ",
    place: "会議室",
    note: "打ち合わせに使う、森に面した部屋",
    from: "scale(1.05) translate3d(0, 1%, 0)",
    to: "scale(1.16) translate3d(-2.5%, -1%, 0)",
  },
];

/** 1枚を見せる時間(ms)。寄りは切り替えのクロスフェード分だけ長く動かす */
const INTERVAL = 7000;
const FADE = 1400;

const pad = (n: number) => String(n).padStart(2, "0");

function subscribeReducedMotion(cb: () => void) {
  const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
  mq.addEventListener("change", cb);
  return () => mq.removeEventListener("change", cb);
}

export default function HeroKenburnsSlideshow({
  headingTag: Heading = "h1",
}: {
  /**
   * 見出しのタグ。実案件ではそのまま(h1)。ギャラリーのプレビューは1ページに複数の標本が
   * 並ぶため "p" を渡してh1の重複を避ける(見た目はclassNameで決まるので変わらない)。
   */
  headingTag?: "h1" | "p";
}) {
  const rootRef = useRef<HTMLElement>(null);
  const [index, setIndex] = useState(0);
  // 直前に表示していた写真。来る写真が上で不透明になるまで下に残し、途中で暗く沈まないようにする
  const [prevIndex, setPrevIndex] = useState<number | null>(null);
  // 各スライドが表示された回数。写真レイヤーの key に使い、表示のたびに寄りを最初からやり直す
  const [shown, setShown] = useState<number[]>(() => SLIDES.map((_, i) => (i === 0 ? 1 : 0)));
  const [playing, setPlaying] = useState(true);
  const [hovering, setHovering] = useState(false);
  const [onScreen, setOnScreen] = useState(true);
  const [pageVisible, setPageVisible] = useState(true);

  const reducedMotion = useSyncExternalStore(
    subscribeReducedMotion,
    () => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
    () => false,
  );

  const running = playing && !hovering && onScreen && pageVisible && !reducedMotion;

  const goTo = useCallback(
    (next: number) => {
      const n = (next + SLIDES.length) % SLIDES.length;
      if (n === index) return;
      setPrevIndex(index);
      setIndex(n);
      setShown((prev) => prev.map((c, i) => (i === n ? c + 1 : c)));
    },
    [index],
  );

  // 画面外とタブ非表示のあいだは止める(ギャラリーの一覧で多数並んでも動くのは見えている分だけ)
  useEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    const io = new IntersectionObserver(([entry]) => setOnScreen(entry.isIntersecting), {
      threshold: 0.15,
    });
    io.observe(el);
    const onVis = () => setPageVisible(document.visibilityState === "visible");
    document.addEventListener("visibilitychange", onVis);
    return () => {
      io.disconnect();
      document.removeEventListener("visibilitychange", onVis);
    };
  }, []);

  // キーボードでフォーカスが外から入ったら停止し、再生を押すまで再開しない。
  // マウスで押したボタンは :focus-visible にならないので、クリック操作では止めない(ホバーで一時停止済み)
  const onFocusCapture = (e: React.FocusEvent<HTMLElement>) => {
    const from = e.relatedTarget as Node | null;
    if (from && e.currentTarget.contains(from)) return;
    const target = e.target as HTMLElement;
    if (target.matches(":focus-visible")) setPlaying(false);
  };

  const playState = running ? "running" : "paused";
  const current = SLIDES[index];

  return (
    <section
      ref={rootRef}
      aria-roledescription="carousel"
      aria-label="社内の写真"
      onFocusCapture={onFocusCapture}
      onPointerEnter={(e) => e.pointerType === "mouse" && setHovering(true)}
      onPointerLeave={(e) => e.pointerType === "mouse" && setHovering(false)}
      className="relative w-full max-w-5xl overflow-hidden border border-gray-200 bg-white"
    >
      {/* 写真の面 */}
      <div className="relative min-h-[480px] overflow-hidden bg-gray-900 sm:min-h-[600px]">
        {SLIDES.map((s, i) => {
          const active = i === index;
          const leaving = i === prevIndex && !active;
          return (
            <div
              key={s.src}
              role="group"
              aria-roledescription="slide"
              aria-label={`${i + 1} / ${SLIDES.length}`}
              aria-hidden={!active}
              inert={!active}
              className={`absolute inset-0 transition-opacity ease-in-out motion-reduce:duration-0 ${
                active ? "z-10 opacity-100" : leaving ? "z-[5] opacity-100" : "z-0 opacity-0"
              }`}
              style={{ transitionDuration: `${FADE}ms` }}
            >
              <div
                key={`${s.src}-${shown[i]}`}
                role="img"
                aria-label={s.alt}
                className="hks-zoom absolute inset-0 bg-cover bg-center will-change-transform"
                style={
                  {
                    backgroundImage: `url(${s.src})`,
                    "--hks-from": s.from,
                    "--hks-to": s.to,
                    animationDuration: `${INTERVAL + FADE}ms`,
                    animationPlayState: playState,
                  } as React.CSSProperties
                }
              />
            </div>
          );
        })}

        {/* 写真の明るさに関わらず白文字が読めるよう、下側に暗いグラデーションを敷く */}
        <span
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 z-20 bg-gradient-to-t from-black/80 via-black/35 to-black/0"
        />
        <span
          aria-hidden="true"
          className="pointer-events-none absolute inset-y-0 left-0 z-20 w-2/3 bg-gradient-to-r from-black/30 to-transparent"
        />

        {/* 見出しは写真の上に固定(スライドと一緒に入れ替えない) */}
        <div className="relative z-30 flex min-h-[480px] flex-col justify-end px-6 pt-16 pb-8 sm:min-h-[600px] sm:px-12 sm:pb-12">
          <p className="flex items-center gap-3 text-[13px] font-semibold tracking-[0.08em] text-white/85">
            <span aria-hidden="true" className="h-px w-8 bg-amber-500" />
            コーポレート・採用サイトのトップに
          </p>
          <Heading className="mt-4 max-w-2xl text-[clamp(1.6rem,4.6vw,3.1rem)] leading-[1.38] font-bold tracking-tight text-white [text-shadow:0_1px_18px_rgba(0,0,0,0.35)]">
            会社の雰囲気は、
            <br />
            {/* 狭い幅でも語の途中で折り返さないよう、意味の切れ目ごとに inline-block にする */}
            <span className="inline-block">言葉より先に</span>
            <span className="inline-block">写真で伝わる</span>
          </Heading>
          <p className="mt-5 max-w-lg text-[15px] leading-[1.95] text-white/90">
            エントランスや仕事場、会議室の写真を画面いっぱいに大きく見せます。来社したことのない方にも、働く場所の様子がトップページで分かります。
          </p>
          <div className="mt-8 flex flex-wrap items-center gap-3">
            <a
              href="#"
              className="bg-amber-600 px-6 py-3 text-sm font-semibold text-white transition-colors hover:bg-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
            >
              制作のご相談
            </a>
            <a
              href="#"
              className="border border-white/60 px-6 py-3 text-sm font-semibold text-white transition-colors hover:border-white hover:bg-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
            >
              写真撮影の進め方
            </a>
          </div>
        </div>
      </div>

      {/* 操作帯: 白地に hairline 罫線。番号と説明・番号送り・前後送りと一時停止 */}
      <div className="grid grid-cols-1 border-t border-gray-200 sm:grid-cols-[minmax(0,1fr)_auto]">
        <div className="flex min-w-0 items-center gap-4 px-6 py-4 sm:px-8">
          <p
            aria-live={running ? "off" : "polite"}
            aria-atomic="true"
            className="flex min-w-0 items-center gap-4"
          >
            <span className="font-display text-sm font-semibold tabular-nums text-gray-900">
              <span className="text-amber-600">{pad(index + 1)}</span>
              <span className="mx-1.5 text-gray-300">/</span>
              {pad(SLIDES.length)}
            </span>
            <span className="flex min-w-0 flex-col gap-0.5 text-[13px] leading-snug">
              <span className="font-semibold text-gray-900">{current.place}</span>
              <span className="text-gray-600">{current.note}</span>
            </span>
          </p>
        </div>

        <div className="flex items-stretch border-t border-gray-200 sm:border-t-0 sm:border-l">
          {/* 番号送り。進捗バーが自動送りの時計を兼ねる */}
          <div className="flex flex-1 items-center gap-1.5 px-6 sm:w-56 sm:flex-none sm:px-6">
            {SLIDES.map((s, i) => {
              const active = i === index;
              const done = i < index;
              return (
                <button
                  key={s.src}
                  type="button"
                  onClick={() => goTo(i)}
                  aria-label={`${i + 1}枚目(${s.place})を表示`}
                  aria-current={active ? "true" : undefined}
                  className="group flex h-11 flex-1 items-center focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-amber-600"
                >
                  <span className="relative block h-[2px] w-full overflow-hidden bg-gray-200 transition-colors group-hover:bg-gray-300">
                    {active ? (
                      <span
                        key={`bar-${shown[i]}`}
                        onAnimationEnd={() => goTo(index + 1)}
                        className={`absolute inset-0 origin-left bg-amber-600 ${
                          reducedMotion ? "" : "hks-progress"
                        }`}
                        style={{ animationDuration: `${INTERVAL}ms`, animationPlayState: playState }}
                      />
                    ) : (
                      <span
                        className={`absolute inset-0 origin-left bg-gray-400 ${done ? "scale-x-100" : "scale-x-0"}`}
                      />
                    )}
                  </span>
                </button>
              );
            })}
          </div>

          <div className="flex border-l border-gray-200">
            <button
              type="button"
              onClick={() => goTo(index - 1)}
              aria-label="前の写真"
              className="grid h-14 w-12 place-items-center text-gray-700 transition-colors hover:bg-gray-50 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-amber-600"
            >
              <svg aria-hidden="true" viewBox="0 0 16 16" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6">
                <path d="M10 3 5 8l5 5" />
              </svg>
            </button>
            {!reducedMotion && (
              <button
                type="button"
                onClick={() => setPlaying((p) => !p)}
                className="flex h-14 min-w-[6.5rem] items-center justify-center gap-2 border-x border-gray-200 px-4 text-[13px] font-semibold text-gray-900 transition-colors hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-amber-600"
              >
                {playing ? (
                  <svg aria-hidden="true" viewBox="0 0 16 16" className="h-3.5 w-3.5 text-amber-600" fill="currentColor">
                    <rect x="3" y="2.5" width="3.2" height="11" />
                    <rect x="9.8" y="2.5" width="3.2" height="11" />
                  </svg>
                ) : (
                  <svg aria-hidden="true" viewBox="0 0 16 16" className="h-3.5 w-3.5 text-amber-600" fill="currentColor">
                    <path d="M4 2.5v11l9-5.5z" />
                  </svg>
                )}
                {playing ? "一時停止" : "再生"}
              </button>
            )}
            <button
              type="button"
              onClick={() => goTo(index + 1)}
              aria-label="次の写真"
              className={`grid h-14 w-12 place-items-center text-gray-700 transition-colors hover:bg-gray-50 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-amber-600 ${
                reducedMotion ? "border-l border-gray-200" : ""
              }`}
            >
              <svg aria-hidden="true" viewBox="0 0 16 16" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6">
                <path d="m6 3 5 5-5 5" />
              </svg>
            </button>
          </div>
        </div>
      </div>

      {/* このブロック内で完結する keyframes と reduced-motion ガード */}
      <style>{`
        .hks-zoom {
          transform: var(--hks-from);
          animation-name: hks-zoom;
          animation-timing-function: linear;
          animation-fill-mode: forwards;
        }
        .hks-progress {
          animation-name: hks-progress;
          animation-timing-function: linear;
          animation-fill-mode: forwards;
        }
        @keyframes hks-zoom {
          from { transform: var(--hks-from); }
          to   { transform: var(--hks-to); }
        }
        @keyframes hks-progress {
          from { transform: scaleX(0); }
          to   { transform: scaleX(1); }
        }
        @media (prefers-reduced-motion: reduce) {
          .hks-zoom { animation: none; transform: scale(1.04); }
          .hks-progress { animation: none; transform: scaleX(1); }
        }
      `}</style>
    </section>
  );
}

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/hero-kenburns-slideshow.json