Expanding Panels
Аккордеон-галерея: полосы кадров, раскрывающиеся по наведению и клику — классика фотопортфолио. Вся механика — flex-grow с transition (никаких замеров); панель — кнопка с aria-expanded, ховер только ускоряет то же действие и живёт под (hover: hover).
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| items? | ExpandingPanel[] | ITEMS | |
| height? | number | 440 | Высота ленты, px. |
| grow? | number | 3.4 | Во сколько раз раскрытая панель шире сжатой. |
| initial? | number | 0 | Какая панель раскрыта изначально (индекс). |
ExpandingPanel — что внутри
- title: string
- tag?: string
Установка
npx shadcn@latest add https://motiva.pages.dev/r/expanding-panels.jsonЧто нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useState } from "react";
import type { CSSProperties } from "react";
const css = `.exp {
display: flex;
gap: 8px;
width: 100%;
height: var(--exp-h, 440px);
}
/* панель — КНОПКА: раскрытие работает и с клавиатуры, и на таче (клик),
ховер — только ускоряет то же самое. Рост — flex-grow: единственное
свойство, которое честно делит освободившееся место между соседями */
.exp-p {
position: relative;
flex: 1;
min-width: 0;
padding: 0;
border: 0;
border-radius: var(--ds-radius-lg, 18px);
overflow: hidden;
cursor: pointer;
background: var(--ds-surface, oklch(0.17 0.02 273));
transition: flex-grow 0.55s var(--ds-ease-out, cubic-bezier(0.22, 1, 0.36, 1));
}
.exp-p img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
/* кадр чуть шире панели: при росте не «доезжает», а раскрывается */
transition: scale 0.55s var(--ds-ease-out, cubic-bezier(0.22, 1, 0.36, 1));
scale: 1.06;
}
.exp-p:focus-visible {
outline: 2px solid var(--ds-accent, oklch(0.72 0.16 300));
outline-offset: 3px;
}
.exp-p[data-active] { flex-grow: var(--exp-grow, 3.4); }
.exp-p[data-active] img { scale: 1; }
/* ховер ведёт себя как активная — но только там, где ховер существует */
@media (hover: hover) and (pointer: fine) {
.exp:has(.exp-p:hover) .exp-p[data-active]:not(:hover) { flex-grow: 1; }
.exp .exp-p:hover { flex-grow: var(--exp-grow, 3.4); }
.exp .exp-p:hover img { scale: 1; }
}
/* подпись видна только на раскрытой: на узкой полосе ей нет места,
а обрезанный текст хуже отсутствующего */
.exp-cap {
position: absolute;
inset: auto 0 0;
padding: 40px 16px 14px;
text-align: start;
color: oklch(0.97 0.005 265);
background: linear-gradient(transparent, oklch(0.08 0.02 265 / 0.75));
opacity: 0;
translate: 0 8px;
transition: opacity 0.35s ease 0.12s, translate 0.35s ease 0.12s;
pointer-events: none;
}
.exp-p[data-active] .exp-cap { opacity: 1; translate: 0 0; }
@media (hover: hover) and (pointer: fine) {
.exp .exp-p:hover .exp-cap { opacity: 1; translate: 0 0; }
.exp:has(.exp-p:hover) .exp-p[data-active]:not(:hover) .exp-cap { opacity: 0; }
}
.exp-cap b {
display: block;
font: 700 var(--ds-text-md, 1.02rem) / 1.25 var(--ds-font, system-ui, sans-serif);
letter-spacing: var(--ds-tracking-tight, -0.02em);
white-space: nowrap;
}
.exp-cap span {
font: 400 var(--ds-text-xs, 0.74rem) / 1.4 var(--ds-font-mono, ui-monospace, monospace);
color: oklch(0.92 0.01 265 / 0.8);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.exp-p, .exp-p img, .exp-cap { transition: none; }
}
html[data-shot] .exp-p, html[data-shot] .exp-p img, html[data-shot] .exp-cap { transition: none; }
`;
export interface ExpandingPanel {
/** Кадр; путь вида `https://motiva.pages.dev/img/<имя>.jpg`. */
src: string;
title: string;
tag?: string;
}
export interface ExpandingPanelsProps {
items?: ExpandingPanel[];
/** Высота ленты, px. */
height?: number;
/** Во сколько раз раскрытая панель шире сжатой. */
grow?: number;
/** Какая панель раскрыта изначально (индекс). */
initial?: number;
}
const ITEMS: ExpandingPanel[] = [
{ src: "https://motiva.pages.dev/img/1015-800x600.jpg", title: "Фьорд", tag: "Норвегия · 2025" },
{ src: "https://motiva.pages.dev/img/1016-900x675.jpg", title: "Красные скалы", tag: "закатная серия · 2024" },
{ src: "https://motiva.pages.dev/img/1039-900x700.jpg", title: "Зелёный каньон", tag: "Орегон · 2026" },
{ src: "https://motiva.pages.dev/img/1074-900x700.jpg", title: "Хищник", tag: "заповедник · 2025" },
{ src: "https://motiva.pages.dev/img/1082-1600x1000.jpg", title: "Ноктюрн", tag: "закулисье · 2024" },
];
/**
* АККОРДЕОН-ГАЛЕРЕЯ: полосы кадров, раскрывающиеся по наведению — классика
* фотопортфолио. Вся механика — `flex-grow` с transition: единственное
* свойство, которое честно делит освободившееся место между соседями,
* никаких замеров ширин.
*
* Панель — КНОПКА, а не div с ховером: раскрытие работает кликом (тач,
* клавиатура), наведение лишь ускоряет то же действие и существует только
* под `(hover: hover)`. Кадр стоит с запасом (scale 1.06) и «доезжает»
* до 1 при раскрытии — панель именно раскрывается, а не растягивает фото.
* Подпись живёт только на раскрытой: на узкой полосе обрезанный текст
* хуже отсутствующего.
*/
export function ExpandingPanels({
items = ITEMS,
height = 440,
grow = 3.4,
initial = 0,
}: ExpandingPanelsProps) {
const [active, setActive] = useState(Math.min(initial, items.length - 1));
return (
<div
className="exp"
style={{
["--exp-h" as string]: `${height}px`,
["--exp-grow" as string]: grow,
} as CSSProperties}
>
<style href="motiva-expanding-panels" precedence="medium">{css}</style>
{items.map((p, i) => (
<button
key={p.src}
type="button"
className="exp-p"
data-active={active === i ? "" : undefined}
aria-expanded={active === i}
aria-label={p.title}
onClick={() => setActive(i)}
>
<img src={p.src} alt="" loading="lazy" decoding="async" />
<span className="exp-cap">
<b>{p.title}</b>
{p.tag && <span>{p.tag}</span>}
</span>
</button>
))}
</div>
);
}