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
"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