Motiva
Мои коллекции
← каталог

Expanding Panels

Аккордеон-галерея: полосы кадров, раскрывающиеся по наведению и клику — классика фотопортфолио. Вся механика — flex-grow с transition (никаких замеров); панель — кнопка с aria-expanded, ховер только ускоряет то же действие и живёт под (hover: hover).

csshoverclickmorphbasic~2 КБreduced-motion ✓
↗
🎛 Настройки — крути и копируй

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
items?ExpandingPanel[]ITEMS
height?number440Высота ленты, px.
grow?number3.4Во сколько раз раскрытая панель шире сжатой.
initial?number0Какая панель раскрыта изначально (индекс).
ExpandingPanel — что внутри
  • title: string
  • tag?: string

Установка

npx shadcn@latest add https://motiva.pages.dev/r/expanding-panels.json
Что нужно в проекте до установки
  1. Файл components.json в корне

    Из него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:

    "aliases": { "components": "@/components", "lib": "@/lib" }

    Нет файла — установка не начнётся вовсе.

  2. Алиас @ объявлен дважды

    В корневом tsconfig.json и в конфиге сборщика. В Vite корневой tsconfig часто содержит только references — этого мало.

    "paths": { "@/*": ["./src/*"] }        // tsconfig.json
    resolve.alias = { "@": "/src" }        // vite.config.ts

    Без paths CLI молча создаёт папку с именем @.

  3. Токены подключены один раз на приложение

    Они приезжают вместе с записью — достаточно импортировать их в точке входа:

    import "@/styles/motiva-tokens.css";

    Без них блок потеряет цвета, ритм и тему.

  4. Своя типографика не спорит с блоком

    Наследуемые свойства протекают внутрь: text-align: center на обёртке отцентрует содержимое секции. То же касается font-familyи line-height.

  5. Проект без TypeScript? всё равно нужен tsconfig

    CLI грузит tsconfig.json безусловно, а при "tsx": false идёт за jsconfig.json и падает так же. Минимум — файл с pathsи "tsx": true в components.json.

    Приехавший рядом .tsx можно не трогать — вам нужен .vanilla.js.

Экспорт

↓ Скачать .zip

Код

"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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); приём «expanding panels» — классика фотопортфолио, реализация свояv1.0.0