カルーセル
ExpandingPanels
CSS
アコーディオン型の画像カルーセル — アクティブなパネルが伸びてアートとキャプションを見せ、他は縦書きタイトルの細い帯に。ホバー / クリック / キーボード、進捗バー付き自動再生、狭いコンテナでは縦積み。
京都
朱の鳥居と紅葉の山並み。夕暮れの古都を、ゆっくり歩く。
AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約44 KB)。
# ExpandingPanels をこのプロジェクトに追加してください
このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。
## コンポーネントについて
- 名前: ExpandingPanels
- 説明: アコーディオン型の画像カルーセル — アクティブなパネルが伸びてアートとキャプションを見せ、他は縦書きタイトルの細い帯に。ホバー / クリック / キーボード、進捗バー付き自動再生、狭いコンテナでは縦積み。
- 実行時に必要な npm パッケージ: lucide-react
- 構成ファイル: `components/expanding-panels/index.tsx`, `components/expanding-panels/ExpandingPanels.module.css`
## 前提と注意
- React 19 / TypeScript / Next.js(App Router)のプロジェクトを想定しています。Next.js 以外の React プロジェクト(Vite など)でもそのまま動きます。
- スタイルは同梱の CSS Modules(ある場合)で自己完結しており、Tailwind CSS の有無や設定には依存しません。Tailwind の設定変更やグローバル CSS の追加は、この依頼文に明記された分だけにしてください。
- ブラウザ API やインタラクションを使うクライアントコンポーネントです。ファイル先頭の "use client" を維持してください。
- コンポーネント自体の背景は透過です。配置先ページの背景がそのまま透けて見える前提で作られているので、白背景や単色の `div` などで囲わず、コンポーネントだけをそのまま配置してください。
- ソースコードの内容は変更しないでください。例外は、`@/` パスエイリアスが無いプロジェクトで import パスを相対パスに直す場合だけです。
## 手順
### 1. 依存パッケージを追加する
pnpm / yarn / bun を使っているプロジェクトでは、そのパッケージマネージャーのコマンドに置き換えてください。
```bash
npm install lucide-react
```
### 2. ファイルを作成する
`@/components` が指すディレクトリ(多くは `src/components/` か `components/`。shadcn の `components.json` があれば `aliases.components` の指す場所)に、以下の 2 ファイルを**一字一句そのまま**作成してください。
#### `components/expanding-panels/index.tsx`
```tsx
"use client";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
useSyncExternalStore,
type CSSProperties,
type FocusEvent as ReactFocusEvent,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import { Pause, Play, type LucideIcon } from "lucide-react";
import styles from "./ExpandingPanels.module.css";
/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;
export type ExpandingPanelItem = {
id?: string;
title?: string;
subtitle?: string;
/**
* A URL (rendered as an <img>) or any CSS background value — gradients,
* `url(...)`, several comma-separated layers. Omitted → generated art.
*/
image?: string;
/** Alt text for a URL image. Read as part of the expanded caption. */
alt?: string;
/** Shown in the collapsed strip and the caption chip. */
icon?: LucideIcon;
};
export type ExpandingPanelState = {
index: number;
total: number;
active: boolean;
};
export type ExpandingPanelsProps = {
items: ExpandingPanelItem[];
/** Controlled active panel. Pair with `onIndexChange`. */
activeIndex?: number;
/** Initial panel when uncontrolled. */
defaultIndex?: number;
onIndexChange?: (index: number) => void;
/**
* "hover": a mouse hovering a strip expands it (click, tap and keyboard
* focus always work too). "click": only click / tap / focus.
*/
trigger?: "hover" | "click";
/** Cycle through panels. Off under prefers-reduced-motion. */
autoplay?: boolean;
/** Autoplay dwell per panel in ms. */
interval?: number;
/** Width (or height, when stacked) of a collapsed strip in px. */
collapsedSize?: number;
/** Space between panels in px. */
gap?: number;
/** Panel corner radius in px. */
radius?: number;
/** Row height in px for the horizontal layout. */
height?: number;
/**
* "auto" stacks the panels vertically when the container is narrower
* than 520px (a container query, so it follows the box, not the window).
*/
orientation?: "auto" | "horizontal" | "vertical";
/** Replaces the default strip + caption content of every panel. */
renderItem?: (item: ExpandingPanelItem, state: ExpandingPanelState) => ReactNode;
className?: string;
"aria-label"?: string;
};
// Mirror the stylesheet defaults: inline vars are only written when a prop
// differs, so the default render is exactly the stylesheet.
const DEFAULT_INTERVAL = 5000;
const DEFAULT_COLLAPSED = 64;
const DEFAULT_GAP = 10;
const DEFAULT_RADIUS = 24;
const DEFAULT_HEIGHT = 400;
/** Hover intent: sweeping the pointer across strips shouldn't open each one. */
const HOVER_DELAY_MS = 90;
type ChangeSource = "focus" | "pointer" | "keyboard" | "autoplay";
/** Deterministic fallback art per index (golden-angle hues). */
function fallbackArt(index: number): string {
const hue = Math.round((index * 137.5 + 220) % 360);
const hue2 = (hue + 48) % 360;
return `radial-gradient(120% 80% at 30% 20%, hsl(${hue} 70% 62% / 0.9), transparent 60%), linear-gradient(165deg, hsl(${hue} 55% 38%), hsl(${hue2} 60% 14%))`;
}
/**
* Anything that already reads as a CSS background value is used verbatim;
* everything else is treated as an image URL.
*/
function isCssBackground(value: string): boolean {
return (
value.includes("gradient(") ||
/^(url\(|#|rgb|hsl|oklch|oklab|lab\(|lch\(|color\(|var\()/i.test(value.trim())
);
}
function subscribeReducedMotion(onChange: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
const getReducedMotion = () =>
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// SSR renders the animated variant; the client corrects after hydration.
const getReducedMotionServer = () => false;
const pad = (n: number) => String(n).padStart(2, "0");
export function ExpandingPanels({
items,
activeIndex,
defaultIndex = 0,
onIndexChange,
trigger = "hover",
autoplay = false,
interval = DEFAULT_INTERVAL,
collapsedSize = DEFAULT_COLLAPSED,
gap = DEFAULT_GAP,
radius = DEFAULT_RADIUS,
height = DEFAULT_HEIGHT,
orientation = "auto",
renderItem,
className,
"aria-label": ariaLabel = "画像ギャラリー",
}: ExpandingPanelsProps) {
const baseId = useId();
const rootRef = useRef<HTMLElement | null>(null);
const triggerRefs = useRef<(HTMLButtonElement | null)[]>([]);
const hoverTimerRef = useRef<number | undefined>(undefined);
const total = items.length;
const [innerIndex, setInnerIndex] = useState(defaultIndex);
const isControlled = activeIndex !== undefined;
const rawIndex = isControlled ? activeIndex : innerIndex;
const index = total ? Math.min(Math.max(rawIndex, 0), total - 1) : 0;
const reducedMotion = useSyncExternalStore(
subscribeReducedMotion,
getReducedMotion,
getReducedMotionServer,
);
const [hovered, setHovered] = useState(false);
const [focusWithin, setFocusWithin] = useState(false);
const [userPaused, setUserPaused] = useState(false);
const [visible, setVisible] = useState(true);
// Only changes a sighted keyboard/AT user wouldn't otherwise hear are
// announced: focus changes are already read via the focused button.
const [announcement, setAnnouncement] = useState("");
const canAutoplay = autoplay && !reducedMotion && total > 1;
const running = canAutoplay && !userPaused;
const paused = !running || hovered || focusWithin || !visible;
const select = useCallback(
(next: number, source: ChangeSource) => {
if (next === index) return;
if (!isControlled) setInnerIndex(next);
onIndexChange?.(next);
if (source !== "focus") {
const item = items[next];
setAnnouncement(`${next + 1} / ${total}${item?.title ? `: ${item.title}` : ""}`);
}
},
[index, isControlled, onIndexChange, items, total],
);
// Offscreen carousels don't advance (and the paused progress bar costs
// nothing). Updates arrive from the observer callback, not the effect body.
useEffect(() => {
const el = rootRef.current;
if (!el || !canAutoplay || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));
io.observe(el);
return () => io.disconnect();
}, [canAutoplay]);
useEffect(() => () => window.clearTimeout(hoverTimerRef.current), []);
const clearHoverTimer = () => window.clearTimeout(hoverTimerRef.current);
const handlePanelEnter = (i: number, e: ReactPointerEvent) => {
// Touch and pen fire pointerenter right before the click; let the click
// handle those so a tap never double-fires through the timer.
if (trigger !== "hover" || e.pointerType !== "mouse") return;
clearHoverTimer();
hoverTimerRef.current = window.setTimeout(() => select(i, "pointer"), HOVER_DELAY_MS);
};
const handleKeyDown = (e: ReactKeyboardEvent) => {
if (!total) return;
let next: number | null = null;
switch (e.key) {
// Both axes are accepted: "auto" picks the axis in CSS, so the
// component can't know which pair the user expects.
case "ArrowRight":
case "ArrowDown":
next = (index + 1) % total;
break;
case "ArrowLeft":
case "ArrowUp":
next = (index - 1 + total) % total;
break;
case "Home":
next = 0;
break;
case "End":
next = total - 1;
break;
}
if (next === null) return;
e.preventDefault();
select(next, "keyboard");
triggerRefs.current[next]?.focus();
};
const handleBlur = (e: ReactFocusEvent) => {
if (!rootRef.current?.contains(e.relatedTarget as Node | null)) setFocusWithin(false);
};
const vars: CSSVars = { "--ep-count": total };
if (interval !== DEFAULT_INTERVAL) vars["--ep-interval"] = `${interval}ms`;
if (collapsedSize !== DEFAULT_COLLAPSED) vars["--ep-collapsed"] = `${collapsedSize}px`;
if (gap !== DEFAULT_GAP) vars["--ep-gap"] = `${gap}px`;
if (radius !== DEFAULT_RADIUS) vars["--ep-radius"] = `${radius}px`;
if (height !== DEFAULT_HEIGHT) vars["--ep-height"] = `${height}px`;
return (
<section
ref={rootRef}
className={className ? `${styles.root} ${className}` : styles.root}
style={vars}
aria-roledescription="カルーセル"
aria-label={ariaLabel}
data-orientation={orientation}
data-paused={paused ? "" : undefined}
onPointerEnter={(e) => e.pointerType === "mouse" && setHovered(true)}
onPointerLeave={() => {
setHovered(false);
clearHoverTimer();
}}
onFocus={() => setFocusWithin(true)}
onBlur={handleBlur}
>
<div className={styles.track} onKeyDown={handleKeyDown}>
{items.map((item, i) => {
const active = i === index;
const captionId = `${baseId}-caption-${i}`;
const Icon = item.icon;
const image = item.image ?? fallbackArt(i);
const cssArt = isCssBackground(image);
const state: ExpandingPanelState = { index: i, total, active };
return (
<div
key={item.id ?? i}
className={styles.panel}
data-active={active ? "" : undefined}
role="group"
aria-roledescription="スライド"
aria-label={`${i + 1} / ${total}`}
onPointerEnter={(e) => handlePanelEnter(i, e)}
onPointerLeave={clearHoverTimer}
>
{/* Sized to the expanded panel, not the live one, so the art
never rescales while the flex track animates — a
collapsed strip is simply a centred slice of it. */}
{cssArt ? (
<div className={styles.art} style={{ background: image }} aria-hidden="true" />
) : (
// Plain <img>: registry components stay framework-agnostic,
// so next/image is not an option here.
// eslint-disable-next-line @next/next/no-img-element
<img
className={styles.art}
src={image}
alt=""
aria-hidden="true"
loading="lazy"
decoding="async"
draggable={false}
/>
)}
<div className={styles.shade} aria-hidden="true" />
{/* One full-bleed button per panel: it is both the disclosure
trigger and the roving tab stop. Content layers sit above
it with pointer-events off, so the whole panel clicks. */}
<button
ref={(el) => {
triggerRefs.current[i] = el;
}}
type="button"
className={styles.trigger}
tabIndex={active ? 0 : -1}
aria-expanded={active}
aria-controls={captionId}
aria-label={item.title || `${i + 1} / ${total}`}
onClick={() => select(i, "pointer")}
onFocus={() => select(i, "focus")}
/>
{renderItem ? (
<div className={styles.custom} id={captionId} inert={!active}>
{renderItem(item, state)}
</div>
) : (
<>
<div className={styles.strip} aria-hidden="true">
{Icon ? <Icon className={styles.stripIcon} strokeWidth={1.75} /> : <span />}
<span className={styles.stripLabel}>
<span className={styles.stripTitle}>{item.title}</span>
<span className={styles.stripIndex}>{pad(i + 1)}</span>
</span>
</div>
<div
className={styles.caption}
id={captionId}
inert={!active}
aria-hidden={active ? undefined : true}
>
<div className={styles.meta}>
{Icon ? (
<span className={styles.chip} aria-hidden="true">
<Icon strokeWidth={1.75} />
</span>
) : null}
<span className={styles.count}>
{pad(i + 1)} <span aria-hidden="true">/</span> {pad(total)}
</span>
</div>
{item.title ? <p className={styles.title}>{item.title}</p> : null}
{item.subtitle ? <p className={styles.subtitle}>{item.subtitle}</p> : null}
{!cssArt && item.alt ? <span className={styles.srOnly}>{item.alt}</span> : null}
</div>
</>
)}
{active && canAutoplay ? (
<>
<div className={styles.progress} aria-hidden="true">
{/* The fill's CSS animation is the timer: its end
advances the carousel, and animation-play-state
pauses it, so no JS clock can drift from the bar.
Keyed by index so each panel restarts at zero. */}
<span
key={index}
className={styles.progressFill}
onAnimationEnd={(e) => {
if (e.target === e.currentTarget) select((index + 1) % total, "autoplay");
}}
/>
</div>
<button
type="button"
className={styles.pause}
aria-label={userPaused ? "自動再生を再開" : "自動再生を一時停止"}
onClick={() => setUserPaused((p) => !p)}
>
{userPaused ? (
<Play aria-hidden="true" strokeWidth={2} />
) : (
<Pause aria-hidden="true" strokeWidth={2} />
)}
</button>
</>
) : null}
</div>
);
})}
</div>
{/* APG carousel rule: silent while rotating on its own, polite otherwise. */}
<div className={styles.srOnly} aria-live={running ? "off" : "polite"} aria-atomic="true">
{announcement}
</div>
</section>
);
}
export default ExpandingPanels;
```
#### `components/expanding-panels/ExpandingPanels.module.css`
```css
/* ==========================================================================
Expanding panels — accordion-style image carousel
A row of tall rounded panels: the active one grows (flex-grow) to show
its art and caption, the rest collapse to strips with a vertical title.
Narrow containers stack the panels and the active one grows in height.
Jank budget: the only thing that reflows is the flex track itself. The
art and caption are laid out at the *expanded* size from the start, so
their contents never re-wrap or rescale mid-animation — the panel just
reveals more of them (overflow: hidden).
========================================================================== */
.root {
/* Spec values. The component only sets these inline for non-default
props, so the default render is exactly this stylesheet. */
--ep-collapsed: 64px;
--ep-gap: 10px;
--ep-radius: 24px;
--ep-height: 400px;
--ep-interval: 5000ms;
--ep-count: 5; /* always set inline from items.length */
--ep-duration: 720ms;
/* One duration + easing for every panel: all panels restart their
flex-grow transition in the same frame, so the grow factors always sum
to 1 and the track never shows a gap — even when interrupted. */
--ep-ease: cubic-bezier(0.22, 1, 0.36, 1);
--ep-focus: #fff;
/* The query container for "auto" orientation and for the cqi units
below. Descendants only — the root itself can't use cqi. */
container: ep / inline-size;
position: relative;
width: 100%;
font-family:
"Inter", -apple-system, BlinkMacSystemFont, "Hiragino Sans",
"Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto,
sans-serif;
color: #fff;
-webkit-tap-highlight-color: transparent;
}
.track {
/* Width of the expanded panel: the row minus every collapsed strip and
gap. Art and caption are sized to this so they never reflow. */
--ep-expanded: calc(
100cqi - (var(--ep-count) - 1) * (var(--ep-collapsed) + var(--ep-gap))
);
/* Stacked layout: expanded height follows the container width. */
--ep-expanded-v: clamp(220px, 72cqi, 380px);
display: flex;
gap: var(--ep-gap);
height: var(--ep-height);
}
/* --- Panel ------------------------------------------------------------- */
.panel {
position: relative;
flex: 0 0 var(--ep-collapsed);
min-width: 0;
min-height: 0;
overflow: hidden;
border-radius: var(--ep-radius);
background: #111015;
/* Keeps each panel's paint and layout work to itself while the track
redistributes space. */
contain: layout paint;
isolation: isolate;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.07),
0 12px 32px -18px rgba(0, 0, 0, 0.8);
/* flex-grow only — Safari has animated the `flex` shorthand poorly. */
transition:
flex-grow var(--ep-duration) var(--ep-ease),
box-shadow 400ms ease;
}
.panel[data-active] {
flex-grow: 1;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.16),
0 24px 60px -24px rgba(0, 0, 0, 0.9);
}
/* Film grain over the art: keeps big gradients from banding and gives the
panels a printed, photographic finish. */
.panel::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
opacity: 0.14;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* --- Art: expanded-size layer, centred; collapsed strips show a slice --- */
.art {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: var(--ep-expanded);
height: 100%;
max-width: none;
object-fit: cover;
background-position: center;
background-size: cover;
/* A slight push-in on collapsed strips that settles as the panel opens:
the motion reads as "focusing", and it is transform-only. */
/* Individual translate/scale properties: orientation swaps only the
centring, reduced motion only the scale. */
translate: -50% 0;
scale: 1.08;
transition: scale calc(var(--ep-duration) * 1.4) var(--ep-ease);
user-select: none;
}
.panel[data-active] .art {
scale: 1;
}
/* Legibility + depth: a bottom scrim for the caption, and a flat dim on
collapsed strips that lifts as the panel becomes active. */
.shade {
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(to top, rgba(6, 5, 10, 0.82), rgba(6, 5, 10, 0.25) 42%, transparent 70%),
rgba(6, 5, 10, 0.38);
transition: background-color 500ms ease;
}
.panel[data-active] .shade {
background:
linear-gradient(to top, rgba(6, 5, 10, 0.78), rgba(6, 5, 10, 0.18) 40%, transparent 62%),
rgba(6, 5, 10, 0);
}
@media (hover: hover) {
.panel:not([data-active]):hover .shade {
background-color: rgba(6, 5, 10, 0.18);
}
}
/* --- Trigger: full-bleed transparent button ---------------------------- */
.trigger {
position: absolute;
inset: 0;
z-index: 2;
margin: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
color: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.panel[data-active] .trigger {
cursor: default;
}
.trigger:focus {
outline: none;
}
.trigger:focus-visible {
outline: 2px solid var(--ep-focus);
outline-offset: -4px;
}
/* --- Collapsed strip ---------------------------------------------------- */
.strip {
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 3;
width: var(--ep-collapsed);
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: 18px 0;
pointer-events: none;
opacity: 1;
/* Fades back in only once the panel has mostly closed. */
transition: opacity 300ms ease calc(var(--ep-duration) * 0.45);
}
.panel[data-active] .strip {
opacity: 0;
transition: opacity 160ms ease;
}
.stripIcon {
width: 20px;
height: 20px;
flex: none;
color: rgba(255, 255, 255, 0.92);
filter: drop-shadow(0 1px 6px rgba(0, 0, 0, 0.4));
}
.stripLabel {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
min-height: 0;
}
.stripTitle {
/* Upright CJK, sideways Latin — the native vertical-text convention. */
writing-mode: vertical-rl;
text-orientation: mixed;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.08em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-height: calc(var(--ep-height) - 120px);
text-shadow: 0 1px 10px rgba(0, 0, 0, 0.45);
}
.stripIndex {
font-size: 11px;
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: 0.06em;
color: rgba(255, 255, 255, 0.55);
}
/* --- Caption (default content) ---------------------------------------- */
.caption,
.custom {
position: absolute;
left: 0;
bottom: 0;
z-index: 3;
width: var(--ep-expanded);
pointer-events: none;
}
.caption {
padding: 0 28px 40px;
opacity: 0;
transform: translateY(10px);
/* Collapse: leave immediately, before the panel narrows. */
transition:
opacity 160ms ease,
transform 160ms ease;
}
.panel[data-active] .caption {
opacity: 1;
transform: none;
/* Expand: wait until most of the growth has happened. */
transition:
opacity 420ms ease calc(var(--ep-duration) * 0.5),
transform 560ms var(--ep-ease) calc(var(--ep-duration) * 0.5);
}
/* Custom content fills the panel; interactive children stay clickable. */
.custom {
top: 0;
}
.custom :where(a, button, input, select, textarea, [tabindex]) {
pointer-events: auto;
}
.meta {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
.chip {
display: inline-grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.28),
inset 0 0 0 1px rgba(255, 255, 255, 0.12);
backdrop-filter: blur(12px);
}
.chip svg {
width: 16px;
height: 16px;
}
.count {
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: 0.12em;
color: rgba(255, 255, 255, 0.7);
}
.title {
margin: 0;
font-size: clamp(26px, 5.2cqi, 44px);
font-weight: 650;
line-height: 1.05;
letter-spacing: -0.025em;
text-wrap: balance;
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.35);
}
.subtitle {
margin: 10px 0 0;
max-width: 38ch;
font-size: 14.5px;
line-height: 1.55;
color: rgba(255, 255, 255, 0.74);
text-wrap: pretty;
}
/* --- Autoplay progress + pause ---------------------------------------- */
.progress {
position: absolute;
left: 28px;
right: 28px;
bottom: 22px;
z-index: 3;
height: 2px;
overflow: hidden;
border-radius: 2px;
background: rgba(255, 255, 255, 0.18);
pointer-events: none;
animation: ep-fade-in 400ms ease calc(var(--ep-duration) * 0.5) both;
}
.progressFill {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.6), #fff);
box-shadow: 0 0 10px rgba(255, 255, 255, 0.6);
transform: scaleX(0);
transform-origin: left center;
/* Starts with the bar's fade-in, so no dwell time is spent invisible. */
animation: ep-progress var(--ep-interval) linear calc(var(--ep-duration) * 0.5) both;
}
.root[data-paused] .progressFill {
animation-play-state: paused;
}
.pause {
position: absolute;
top: 16px;
right: 16px;
z-index: 4;
display: grid;
place-items: center;
width: 34px;
height: 34px;
margin: 0;
padding: 0;
border: 0;
border-radius: 999px;
color: #fff;
background: rgba(10, 10, 14, 0.38);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.22),
inset 0 0 0 1px rgba(255, 255, 255, 0.12);
backdrop-filter: blur(14px);
cursor: pointer;
animation: ep-fade-in 400ms ease calc(var(--ep-duration) * 0.5) both;
transition: background-color 200ms ease;
}
.pause:hover {
background: rgba(10, 10, 14, 0.58);
}
.pause:focus {
outline: none;
}
.pause:focus-visible {
outline: 2px solid var(--ep-focus);
outline-offset: 2px;
}
.pause svg {
width: 14px;
height: 14px;
}
@keyframes ep-progress {
to {
transform: scaleX(1);
}
}
@keyframes ep-fade-in {
from {
opacity: 0;
}
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* --- Stacked (vertical) layout ---------------------------------------
Written twice on purpose: once for an explicit orientation, once inside
the container query for "auto". Keep both blocks in sync. */
.root[data-orientation="vertical"] .track {
flex-direction: column;
height: calc(
(var(--ep-count) - 1) * (var(--ep-collapsed) + var(--ep-gap)) +
var(--ep-expanded-v)
);
}
.root[data-orientation="vertical"] .art {
top: 50%;
bottom: auto;
left: 0;
width: 100%;
height: var(--ep-expanded-v);
translate: 0 -50%;
}
.root[data-orientation="vertical"] .strip {
right: 0;
bottom: auto;
width: auto;
height: var(--ep-collapsed);
flex-direction: row;
padding: 0 20px;
}
.root[data-orientation="vertical"] .stripLabel {
flex-direction: row-reverse;
gap: 12px;
}
.root[data-orientation="vertical"] .stripTitle {
writing-mode: horizontal-tb;
max-height: none;
}
.root[data-orientation="vertical"] .caption,
.root[data-orientation="vertical"] .custom {
width: 100%;
}
.root[data-orientation="vertical"] .caption {
padding: 0 22px 38px;
}
.root[data-orientation="vertical"] .custom {
height: var(--ep-expanded-v);
top: auto;
}
@container ep (max-width: 519px) {
.root[data-orientation="auto"] .track {
flex-direction: column;
height: calc(
(var(--ep-count) - 1) * (var(--ep-collapsed) + var(--ep-gap)) +
var(--ep-expanded-v)
);
}
.root[data-orientation="auto"] .art {
top: 50%;
bottom: auto;
left: 0;
width: 100%;
height: var(--ep-expanded-v);
translate: 0 -50%;
}
.root[data-orientation="auto"] .strip {
right: 0;
bottom: auto;
width: auto;
height: var(--ep-collapsed);
flex-direction: row;
padding: 0 20px;
}
.root[data-orientation="auto"] .stripLabel {
flex-direction: row-reverse;
gap: 12px;
}
.root[data-orientation="auto"] .stripTitle {
writing-mode: horizontal-tb;
max-height: none;
}
.root[data-orientation="auto"] .caption,
.root[data-orientation="auto"] .custom {
width: 100%;
}
.root[data-orientation="auto"] .caption {
padding: 0 22px 38px;
}
.root[data-orientation="auto"] .custom {
height: var(--ep-expanded-v);
top: auto;
}
}
/* --- Reduced motion: same layout, no travel --------------------------- */
@media (prefers-reduced-motion: reduce) {
.panel,
.art,
.shade,
.strip,
.caption,
.panel[data-active] .strip,
.panel[data-active] .caption {
transition: none;
}
.art {
scale: 1;
}
.caption {
transform: none;
}
/* Autoplay is disabled in JS under reduced motion; this is a backstop. */
.progress,
.pause {
animation: none;
}
}
```
代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/expanding-panels.json
```
### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { ExpandingPanels } from "@/components/expanding-panels";
import { Landmark, Snowflake, Sailboat, MountainSnow, TreePalm } from "lucide-react";
// TODO: image には写真の URL(/images/kyoto.jpg など)か CSS の背景値(グラデーション可)を渡してください。プレビューの山並みシルエットはデモ専用で、ここでは背景の空模様のグラデーションだけを出力しています
<ExpandingPanels
orientation="auto"
collapsedSize={64}
gap={10}
radius={24}
trigger="hover"
autoplay={true}
interval={5000}
items={[
{
id: "kyoto",
title: "京都",
subtitle: "朱の鳥居と紅葉の山並み。夕暮れの古都を、ゆっくり歩く。",
icon: Landmark,
image: "radial-gradient(40% 30% at 62% 60%, #ffd9a0 0%, rgba(255,180,110,0.55) 35%, transparent 70%), linear-gradient(180deg, #24173a 0%, #6e2a4f 36%, #d75f4a 62%, #f3a866 80%)",
},
{
id: "reykjavik",
title: "Reykjavík",
subtitle: "北極圏の夜空に揺れるオーロラ。静寂と光の街へ。",
icon: Snowflake,
image: "radial-gradient(55% 22% at 38% 30%, rgba(90,255,190,0.55), transparent 70%), radial-gradient(45% 20% at 70% 20%, rgba(130,140,255,0.45), transparent 70%), linear-gradient(180deg, #040814 0%, #0a1f33 55%, #12384a 100%)",
},
{
id: "santorini",
title: "Santorini",
subtitle: "白い街並みとエーゲ海の青。崖の上で迎える、世界一の夕日。",
icon: Sailboat,
image: "radial-gradient(35% 25% at 30% 38%, #fff4d6 0%, rgba(255,230,180,0.5) 40%, transparent 70%), linear-gradient(180deg, #3a8bd6 0%, #7cc4f2 52%, #cfe9f7 72%, #1d5fa6 72.5%, #123f78 100%)",
},
{
id: "patagonia",
title: "Patagonia",
subtitle: "氷河を抱く尖峰と、風の大地。地の果てのトレッキング。",
icon: MountainSnow,
image: "radial-gradient(60% 30% at 50% 78%, rgba(255,190,150,0.6), transparent 70%), linear-gradient(180deg, #18213b 0%, #3f5683 45%, #b98aa0 72%, #f0b48e 88%)",
},
{
id: "okinawa",
title: "沖縄",
subtitle: "珊瑚礁のターコイズと、白い砂浜。南の島で過ごす休日。",
icon: TreePalm,
image: "radial-gradient(30% 22% at 72% 26%, #fffbe6 0%, rgba(255,250,220,0.5) 40%, transparent 70%), linear-gradient(180deg, #1aa7d6 0%, #68d3f0 46%, #c8f3fb 62%)",
},
]}
aria-label="旅先ギャラリー"
/>
```
### 4. 組み込み手順
1. `<ExpandingPanels items={[…]} />` は **`"use client"` のコンポーネントの中に置く**。`icon`(コンポーネント参照)・`renderItem`・`onIndexChange` は関数なので、サーバーコンポーネントから直接渡せない(生成コードは `icon` を必ず含む)。ページがサーバーコンポーネントなら、小さなクライアントラッパーを作ってそこで描画する。`items` は `{ id?, title?, subtitle?, image?, alt?, icon? }` の配列。
2. `image` は写真の URL(`public/` 配下のパスや CDN)か、CSS の背景値(`linear-gradient(…)` など、カンマ区切りの複数レイヤー可)。省略すると自動のグラデーションになる。写真の場合は `alt` も渡す(展開時のキャプションの一部として読み上げられる)。
3. `icon` には `lucide-react` のアイコンコンポーネントをそのまま渡す(例: `import { Landmark } from "lucide-react"` → `icon: Landmark`)。
4. 幅は親要素に従う(`width: 100%`)。横並びの高さは `height`(既定 400px)。親の幅が 520px 未満だと `orientation="auto"` は縦積みに切り替わる。判定は**ウィンドウではなく親要素の幅**(コンテナクエリ)なので、サイドバー内などでも正しく切り替わる。
5. アクティブなパネルを外から制御する場合は `activeIndex` と `onIndexChange` を組で渡す。初期位置だけなら `defaultIndex`。
6. 自動再生(`autoplay`)は、ホバー中・フォーカス中・画面外では止まり、OS の「視差効果を減らす」設定ではオフになる。アクティブなパネル右上に一時停止ボタンが出る(WCAG 2.2.2)。
7. `npm run build` が通ることを確認し、「ホバーで滑らかに伸びる」「伸びている最中に文字が折り返し直さない」「Tab で 1 回だけ止まり、矢印キーで移動できる」「スマホ幅で縦積みになる」を実機で確認する。
#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| 伸縮の途中でトラックの端に隙間が出る / ガタつく | パネルごとに `transition` の時間やイージングを変えた、または `flex` ショートハンドをアニメーションさせた | `flex-grow` だけを、全パネル同じ `--ep-duration` / `--ep-ease` で遷移させる。同時に始まる限り grow の合計が常に 1 になり、隙間が出ない |
| 伸びている間にキャプションが何度も折り返す・画像が拡大縮小する | アートやキャプションの幅をパネル幅(`100%`)にした | 幅は `--ep-expanded`(展開後の幅を `100cqi` から算出)に固定し、パネルの `overflow: hidden` で見せる範囲だけ変える |
| 縦積みに切り替わらない / `cqi` が効かない | ルートの `container: ep / inline-size` を外した、またはルート自身に `cqi` を使った | コンテナはルート、`cqi` を使うのはその子孫だけ。`vw` に置き換えない(プレビューやサイドバーで壊れる) |
| スマホでタップすると 2 回切り替わる / 違うパネルが開く | ホバー判定をタッチにも効かせている | ホバーは `pointerType === "mouse"` のときだけ。タッチはクリックで処理する |
| 「Functions cannot be passed directly to Client Components」エラー | サーバーコンポーネントから `icon` / `renderItem` / `onIndexChange` を渡している | `"use client"` のラッパーコンポーネント内で `items` を組み立てて描画する |
| 自動再生が進まない | 進捗バーの CSS アニメーションを消した / `display: none` にした | 進捗バーの `animationend` がタイマー。見た目を変えるなら色や太さだけにする |
| 視差効果オフでも切り替わり続ける | CSS 側だけで reduced-motion を処理している | JS の `useSyncExternalStore` による判定で自動再生自体を止めている。外さない |
### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる
## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- Next.js(App Router)、React 19、TypeScript、CSS Modules、`lucide-react`(一時停止 / 再生アイコンと `icon` の型)。アニメーションライブラリは使わない(CSS の transition / animation のみ)
- ファイル: `components/expanding-panels/ExpandingPanels.tsx`(`"use client"`)、`ExpandingPanels.module.css`、`index.ts`
- Props: `items`、`activeIndex` / `defaultIndex` / `onIndexChange`、`trigger`(`"hover"` | `"click"`、既定 hover)、`autoplay`(既定 false)、`interval`(ms、既定 5000)、`collapsedSize`(px、既定 64)、`gap`(px、既定 10)、`radius`(px、既定 24)、`height`(px、既定 400)、`orientation`(`"auto"` | `"horizontal"` | `"vertical"`)、`renderItem(item, { index, total, active })`、`className`、`aria-label`
- 既定値は CSS カスタムプロパティ(`--ep-*`)としてルートに持ち、props が既定と違うときだけインラインで上書きする。`--ep-count` だけは常にインラインで渡す
### 見た目
- 暗い背景向け。角丸の縦長パネルが横一列に並び、アクティブな 1 枚が残りの幅をすべて使う。閉じたパネルは `collapsedSize` 幅の帯
- 各パネル: アート(写真 or グラデーション)→ 下からの暗いスクリム → 薄いフィルムグレイン(SVG ノイズ、`mix-blend-mode: overlay`、不透明度 0.14)→ 内側 1px の白いハイライト線。閉じたパネルは全体を少し暗くし、ホバーで少し明るくする
- 閉じた帯: 上にアイコン、下に縦書きタイトル(`writing-mode: vertical-rl`、日本語は正立・英字は横倒し)と「01」形式の番号
- 展開時のキャプション(左下): ガラス調の丸いアイコンチップ + 「01 / 05」、大きな見出し(`clamp(26px, 5.2cqi, 44px)`、字間 -0.025em)、2 行程度のサブタイトル(白 74%)
- 自動再生時: キャプションの下に 2px の進捗バー(白のグラデーション + 淡い発光)、右上にガラス調の一時停止 / 再開ボタン
- 縦積み(コンテナ幅 520px 未満で自動、または `orientation="vertical"`): パネルが縦に並び、アクティブが高さ方向に伸びる(展開高さ `clamp(220px, 72cqi, 380px)`)。帯のタイトルは横書きに戻る
### モーション
- 伸縮は `flex-grow` の transition(0 ↔ 1、`flex-basis` は `collapsedSize`)。720ms、`cubic-bezier(0.22, 1, 0.36, 1)`。全パネル同じ時間・イージングにして grow の合計を常に 1 に保つ
- アートとキャプションは展開後の幅 `calc(100cqi - (n - 1) × (collapsedSize + gap))` で最初からレイアウトし、アートは中央寄せ。閉じた帯はその中央の切り抜きに見える。パネルが伸びても中身は再レイアウトされない
- アートは閉じている間 `scale(1.08)`、開くと `scale(1)` へ(transform のみ)
- キャプションは展開の半分が過ぎてから フェード + 10px 浮き上がり(遅延 360ms)。閉じるときは遅延なしで 160ms で消える。帯のラベルはその逆
- ホバーは 90ms のインテント遅延つき(帯の上をなぞっただけで全部開かない)。マウスのときだけ
- 自動再生: 進捗バーの CSS アニメーション(`scaleX` 0→1、`interval` ms、linear)の `animationend` で次へ。ホバー・フォーカス・画面外(IntersectionObserver)・一時停止ボタンで `animation-play-state: paused`
- `prefers-reduced-motion: reduce`: 伸縮・フェードは即時、自動再生は JS で無効化(静的でも同じレイアウトで美しく見える)
### アクセシビリティ
- ルート: `<section aria-roledescription="カルーセル" aria-label>`。各パネル: `role="group" aria-roledescription="スライド" aria-label="n / total"`
- **パターンの選択: タブではなく `aria-expanded` 付きボタン(ディスクロージャー)**。各パネルはトリガーと内容を兼ね、閉じたパネルも帯として見え続ける。タブ(tab と別要素の tabpanel)ではこの形を正しく表せないため。パネル全面を覆う透明な `<button aria-expanded aria-controls>` がトリガーで、ラベルはタイトル
- ロービング tabindex: アクティブなボタンだけ `tabIndex=0`。←→↑↓ で前後(端で循環)、Home / End で先頭 / 末尾。フォーカスしたパネルが開く
- 閉じたパネルのキャプションは `inert` + `aria-hidden`。アートは装飾扱い(`aria-hidden`)で、写真の `alt` はキャプション内の視覚的に隠したテキストとして読ませる
- ライブリージョン: 自動再生中は `aria-live="off"`、それ以外は `polite`。フォーカスによる切り替えはボタン自体が読まれるので通知しない
- フォーカスリングは白 2px、パネル内側に `outline-offset: -4px`。マウント時にフォーカスを奪わない
### 受け入れ条件
- ホバー(hover 指定時)・クリック・タップ・キーボードで開くパネルが切り替わり、`onIndexChange` が呼ばれる
- 伸縮中にキャプションの折り返しやアートの拡縮が起きない。トラック端に隙間が出ない
- 自動再生で進捗バーが満ちると次のパネルへ。ホバー / フォーカス / 画面外 / 一時停止ボタンで止まり、再開すると続きから進む
- 520px 未満のコンテナで縦積みになり、縦方向でも同じ操作ができる
- 視差効果を減らす設定で自動再生が止まり、切り替えは即時
- Tab での停止はカルーセル全体で 1 か所(+ 自動再生時の一時停止ボタン)インストール
npx shadcn@latest add https://anima-js.vercel.app/r/expanding-panels.json生成コード
import { ExpandingPanels } from "@/components/expanding-panels";
import { Landmark, Snowflake, Sailboat, MountainSnow, TreePalm } from "lucide-react";
// TODO: image には写真の URL(/images/kyoto.jpg など)か CSS の背景値(グラデーション可)を渡してください。プレビューの山並みシルエットはデモ専用で、ここでは背景の空模様のグラデーションだけを出力しています
<ExpandingPanels
orientation="auto"
collapsedSize={64}
gap={10}
radius={24}
trigger="hover"
autoplay={true}
interval={5000}
items={[
{
id: "kyoto",
title: "京都",
subtitle: "朱の鳥居と紅葉の山並み。夕暮れの古都を、ゆっくり歩く。",
icon: Landmark,
image: "radial-gradient(40% 30% at 62% 60%, #ffd9a0 0%, rgba(255,180,110,0.55) 35%, transparent 70%), linear-gradient(180deg, #24173a 0%, #6e2a4f 36%, #d75f4a 62%, #f3a866 80%)",
},
{
id: "reykjavik",
title: "Reykjavík",
subtitle: "北極圏の夜空に揺れるオーロラ。静寂と光の街へ。",
icon: Snowflake,
image: "radial-gradient(55% 22% at 38% 30%, rgba(90,255,190,0.55), transparent 70%), radial-gradient(45% 20% at 70% 20%, rgba(130,140,255,0.45), transparent 70%), linear-gradient(180deg, #040814 0%, #0a1f33 55%, #12384a 100%)",
},
{
id: "santorini",
title: "Santorini",
subtitle: "白い街並みとエーゲ海の青。崖の上で迎える、世界一の夕日。",
icon: Sailboat,
image: "radial-gradient(35% 25% at 30% 38%, #fff4d6 0%, rgba(255,230,180,0.5) 40%, transparent 70%), linear-gradient(180deg, #3a8bd6 0%, #7cc4f2 52%, #cfe9f7 72%, #1d5fa6 72.5%, #123f78 100%)",
},
{
id: "patagonia",
title: "Patagonia",
subtitle: "氷河を抱く尖峰と、風の大地。地の果てのトレッキング。",
icon: MountainSnow,
image: "radial-gradient(60% 30% at 50% 78%, rgba(255,190,150,0.6), transparent 70%), linear-gradient(180deg, #18213b 0%, #3f5683 45%, #b98aa0 72%, #f0b48e 88%)",
},
{
id: "okinawa",
title: "沖縄",
subtitle: "珊瑚礁のターコイズと、白い砂浜。南の島で過ごす休日。",
icon: TreePalm,
image: "radial-gradient(30% 22% at 72% 26%, #fffbe6 0%, rgba(255,250,220,0.5) 40%, transparent 70%), linear-gradient(180deg, #1aa7d6 0%, #68d3f0 46%, #c8f3fb 62%)",
},
]}
aria-label="旅先ギャラリー"
/>