Перейти к содержимому

Галереи

Аккордеонная галерея

Интерактивная галерея с раскрывающимися панелями, параллаксом и подписями.


Аккордеонная галерея

Интерактивная галерея, в которой панели раскрываются при наведении или клике. Компонент поддерживает параллакс изображений, наклон в перспективе, подписи, чёрно-белый режим и горизонтальную или вертикальную ориентацию.

Интерактивное демо

Пропсы

Высота (px)
460
Отступ (px)
10
Раскрытие
0.520
Наклон (град.)
8
Активация
Ориентация
<script lang="ts">
  import { onMount, onDestroy } from 'svelte';
  import gsap from 'gsap';

  export type AccordionGalleryItem = {
    image: string;
    label?: string;
    link?: string;
    alt?: string;
  }

  // ----- props -----
  export let items: AccordionGalleryItem[] = [
    { image: 'https://picsum.photos/id/1015/900/1200', label: 'Canyon', link: '#' },
    { image: 'https://picsum.photos/id/1018/900/1200', label: 'Ridgeline', link: '#' },
    { image: 'https://picsum.photos/id/1039/900/1200', label: 'Falls', link: '#' },
    { image: 'https://picsum.photos/id/1043/900/1200', label: 'Harbour', link: '#' },
    { image: 'https://picsum.photos/id/1044/900/1200', label: 'Skyline', link: '#' }
  ];
  export let defaultIndex = 2;
  export let accentColor = '#ffffff';
  export let overlayColor = '#060010';
  export let textColor = '#ffffff';
  export let height = 460;
  export let gap = 10;
  export let radius = 16;
  export let expandRatio = 0.52;
  export let orientation: 'horizontal' | 'vertical' = 'horizontal';
  export let duration = 0.6;
  export let ease = 'power3.out';
  export let parallax = 0.5;
  export let tilt = 8;
  export let stagger = 0.06;
  export let trigger: 'hover' | 'click' = 'hover';
  export let showLabels = true;
  export let grayscale = true;
  export let className = '';

  $: vertical = orientation === 'vertical';
  $: count = items.length;

  let active = Math.min(Math.max(defaultIndex, 0), count - 1);

  let rootEl: HTMLDivElement;
  let panelEls: (HTMLElement | null)[] = [];
  let mediaEls: (HTMLElement | null)[] = [];
  let barEls: (HTMLElement | null)[] = [];
  let textEls: (HTMLElement | null)[] = [];

  let tl: gsap.core.Timeline | null = null;
  let firstRun = true;
  let mediaSize = 320;
  let ro: ResizeObserver | null = null;

  let prefersReduced = false;

  $: overlayBg = `linear-gradient(180deg, transparent 45%, color-mix(in srgb, ${overlayColor} 78%, transparent) 100%), color-mix(in srgb, ${overlayColor} calc(var(--ag-dim, 0.35) * 100%), transparent)`;

  function applyLayout(animate: boolean) {
    const panels = panelEls;
    if (!panels.length) return;

    const r = Math.min(Math.max(expandRatio, 0.2), 0.9);
    const grow = count > 1 ? (r * (count - 1)) / (1 - r) : 1;

    tl?.kill();
    const dur = animate && !prefersReduced ? duration : 0;
    const timeline = gsap.timeline();

    panels.forEach((panel, i) => {
      if (!panel) return;
      const isActive = i === active;
      const media = mediaEls[i];
      const bar = barEls[i];
      const text = textEls[i];

      const rot = isActive ? 0 : i < active ? tilt : -tilt;
      const rotProp = vertical ? { rotateX: -rot } : { rotateY: rot };

      timeline.to(panel, { flexGrow: isActive ? grow : 1, ...rotProp, duration: dur, ease }, 0);

      if (media) {
        const drift = Math.max(-1.5, Math.min(1.5, active - i));
        const shift = drift * parallax * mediaSize * 0.06;
        const gray = grayscale ? (isActive ? 0 : 1) : 0;
        timeline.to(
          media,
          {
            xPercent: -50,
            yPercent: -50,
            x: vertical ? 0 : isActive ? 0 : shift,
            y: vertical ? (isActive ? 0 : shift) : 0,
            '--ag-gray': gray,
            '--ag-dim': isActive ? 0 : 0.35,
            duration: dur,
            ease
          },
          0
        );
      }

      if (showLabels && bar && text) {
        if (isActive) {
          timeline.to(
            [bar, text],
            { opacity: 1, x: 0, duration: dur, ease, stagger: prefersReduced ? 0 : stagger },
            0
          );
        } else {
          timeline.to([bar, text], { opacity: 0, x: -14, duration: dur * 0.6, ease }, 0);
        }
      }
    });

    tl = timeline;
  }

  function measure() {
    if (!rootEl) return;
    const rect = rootEl.getBoundingClientRect();
    const total = vertical ? rect.height : rect.width;
    const usable = Math.max(total - gap * (count - 1), 120);
    const size = Math.max(140, usable * Math.min(Math.max(expandRatio, 0.2), 0.9) * 1.22);
    mediaSize = size;
    rootEl.style.setProperty('--ag-media-size', `${size}px`);
    applyLayout(!firstRun);
  }

  function handleEnter(i: number) {
    if (trigger === 'hover') active = i;
  }

  function handleClick(i: number, e: MouseEvent) {
    if (i !== active) {
      e.preventDefault();
      active = i;
    }
  }

  function handleFocus(i: number) {
    active = i;
  }

  function handleKeyDown(i: number, e: KeyboardEvent) {
    if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
      e.preventDefault();
      active = (i + 1) % count;
    } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
      e.preventDefault();
      active = (i - 1 + count) % count;
    }
  }

  onMount(() => {
    prefersReduced =
      typeof window !== 'undefined' && !!window.matchMedia
        ? window.matchMedia('(prefers-reduced-motion: reduce)').matches
        : false;

    measure();
    ro = new ResizeObserver(measure);
    ro.observe(rootEl);

    firstRun = false;
  });

  onDestroy(() => {
    tl?.kill();
    ro?.disconnect();
  });

  // re-run layout whenever active index or any layout-affecting prop changes
  $: if (rootEl) {
    // touch dependencies explicitly so Svelte tracks them
    void (active, count, expandRatio, duration, ease, vertical, tilt, parallax, grayscale, showLabels, stagger);
    applyLayout(!firstRun);
  }

  // re-measure whenever gap/expandRatio/orientation/count changes the usable space
  $: if (rootEl) {
    void (gap, expandRatio, vertical, count);
    measure();
  }
</script>

<div
  bind:this={rootEl}
  class={`accordion-gallery-root ${vertical ? 'is-vertical' : 'is-horizontal'} ${className}`.trim()}
  style={`gap:${gap}px; height:${vertical ? Math.round(height * 1.6) : height}px; --ag-accent:${accentColor};`}
  role="list"
  aria-label="Image accordion gallery"
>
  {#each items as item, i (i)}
    {@const isActive = i === active}
    <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
    <svelte:element
      this={item.link ? 'a' : 'div'}
      bind:this={panelEls[i]}
      class="accordion-gallery-panel"
      style={`border-radius:${radius}px;`}
      href={item.link || undefined}
      on:click={(e: MouseEvent) => { handleClick(i, e); }}
      on:mouseenter={() => { handleEnter(i); }}
      on:focus={() => { handleFocus(i); }}
      on:keydown={(e: KeyboardEvent) => { handleKeyDown(i, e); }}
      role="listitem"
      tabindex={0}
      aria-current={isActive ? 'true' : undefined}
      aria-label={item.label}
    >
      <span class="accordion-gallery-clip">
        <span
          bind:this={mediaEls[i]}
          class="accordion-gallery-media"
          style={`width:${vertical ? '100%' : 'var(--ag-media-size, 320px)'};height:${
            vertical ? 'var(--ag-media-size, 320px)' : '100%'
          };`}
        >
          <img
            src={item.image}
            alt={item.alt || item.label || ''}
            draggable="false"
            class="accordion-gallery-img"
          />
        </span>
        <span class="accordion-gallery-overlay" style={`background:${overlayBg};`} aria-hidden="true"
        ></span>
      </span>
      {#if showLabels}
        <span class="accordion-gallery-caption" aria-hidden="true">
          <span
            bind:this={barEls[i]}
            class="accordion-gallery-bar"
            style={`background:${accentColor}; box-shadow: 0 0 12px color-mix(in srgb, ${accentColor} 60%, transparent);`}
          ></span>
          <span bind:this={textEls[i]} class="accordion-gallery-label" style={`color:${textColor};`}>
            {item.label}
          </span>
        </span>
      {/if}
    </svelte:element>
  {/each}
</div>

<style>
  .accordion-gallery-root {
    display: flex;
    width: 100%;
    max-width: 100%;
    perspective: 1400px;
  }
  .accordion-gallery-root.is-horizontal {
    flex-direction: row;
  }
  .accordion-gallery-root.is-vertical {
    flex-direction: column;
  }
  @media (max-width: 520px) {
    .accordion-gallery-root {
      flex-direction: column !important;
      perspective: none !important;
    }
  }

  .accordion-gallery-panel {
    position: relative;
    display: block;
    min-width: 0;
    min-height: 0;
    flex: 1 1 0;
    cursor: pointer;
    overflow: hidden;
    background: #0a0713;
    text-decoration: none;
    outline: none;
    transform-style: preserve-3d;
    transform-origin: center;
    box-shadow: 0 10px 30px -18px rgba(0, 0, 0, 0.8);
    will-change: flex-grow, transform;
  }
  .accordion-gallery-panel:focus-visible {
    box-shadow:
      0 0 0 2px var(--ag-accent),
      0 10px 30px -18px rgba(0, 0, 0, 0.8);
  }
  @media (max-width: 520px) {
    .accordion-gallery-panel {
      min-height: 84px;
      transform: none !important;
    }
  }

  .accordion-gallery-clip {
    position: absolute;
    inset: 0;
    overflow: hidden;
    border-radius: inherit;
  }

  .accordion-gallery-media {
    position: absolute;
    top: 50%;
    left: 50%;
    filter: grayscale(var(--ag-gray, 1));
    will-change: transform, filter;
  }

  .accordion-gallery-img {
    display: block;
    height: 100%;
    width: 100%;
    -webkit-user-select: none;
    user-select: none;
    object-fit: cover;
    -webkit-user-drag: none;
  }

  .accordion-gallery-overlay {
    pointer-events: none;
    position: absolute;
    inset: 0;
  }

  .accordion-gallery-caption {
    pointer-events: none;
    position: absolute;
    bottom: 20px;
    left: 20px;
    right: 20px;
    z-index: 2;
    display: flex;
    align-items: center;
    gap: 12px;
  }

  .accordion-gallery-bar {
    height: 26px;
    width: 3px;
    flex: none;
    border-radius: 3px;
    opacity: 0;
  }

  .accordion-gallery-label {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: clamp(1rem, 1.4vw, 1.4rem);
    font-weight: 600;
    letter-spacing: 0.01em;
    opacity: 0;
    text-shadow: 0 2px 14px rgba(0, 0, 0, 0.55);
  }
</style>
<script lang="ts">
  import { onMount, onDestroy } from 'svelte';
  import gsap from 'gsap';

  export type AccordionGalleryItem = {
    image: string;
    label?: string;
    link?: string;
    alt?: string;
  }

  // ----- props -----
  export let items: AccordionGalleryItem[] = [
    { image: 'https://picsum.photos/id/1015/900/1200', label: 'Canyon', link: '#' },
    { image: 'https://picsum.photos/id/1018/900/1200', label: 'Ridgeline', link: '#' },
    { image: 'https://picsum.photos/id/1039/900/1200', label: 'Falls', link: '#' },
    { image: 'https://picsum.photos/id/1043/900/1200', label: 'Harbour', link: '#' },
    { image: 'https://picsum.photos/id/1044/900/1200', label: 'Skyline', link: '#' }
  ];
  export let defaultIndex = 2;
  export let accentColor = '#ffffff';
  export let overlayColor = '#060010';
  export let textColor = '#ffffff';
  export let height = 460;
  export let gap = 10;
  export let radius = 16;
  export let expandRatio = 0.52;
  export let orientation: 'horizontal' | 'vertical' = 'horizontal';
  export let duration = 0.6;
  export let ease = 'power3.out';
  export let parallax = 0.5;
  export let tilt = 8;
  export let stagger = 0.06;
  export let trigger: 'hover' | 'click' = 'hover';
  export let showLabels = true;
  export let grayscale = true;
  export let className = '';

  $: vertical = orientation === 'vertical';
  $: count = items.length;

  let active = Math.min(Math.max(defaultIndex, 0), count - 1);

  let rootEl: HTMLDivElement;
  let panelEls: (HTMLElement | null)[] = [];
  let mediaEls: (HTMLElement | null)[] = [];
  let barEls: (HTMLElement | null)[] = [];
  let textEls: (HTMLElement | null)[] = [];

  let tl: gsap.core.Timeline | null = null;
  let firstRun = true;
  let mediaSize = 320;
  let ro: ResizeObserver | null = null;

  let prefersReduced = false;

  $: overlayBg = `linear-gradient(180deg, transparent 45%, color-mix(in srgb, ${overlayColor} 78%, transparent) 100%), color-mix(in srgb, ${overlayColor} calc(var(--ag-dim, 0.35) * 100%), transparent)`;

  function applyLayout(animate: boolean) {
    const panels = panelEls;
    if (!panels.length) return;

    const r = Math.min(Math.max(expandRatio, 0.2), 0.9);
    const grow = count > 1 ? (r * (count - 1)) / (1 - r) : 1;

    tl?.kill();
    const dur = animate && !prefersReduced ? duration : 0;
    const timeline = gsap.timeline();

    panels.forEach((panel, i) => {
      if (!panel) return;
      const isActive = i === active;
      const media = mediaEls[i];
      const bar = barEls[i];
      const text = textEls[i];

      const rot = isActive ? 0 : i < active ? tilt : -tilt;
      const rotProp = vertical ? { rotateX: -rot } : { rotateY: rot };

      timeline.to(panel, { flexGrow: isActive ? grow : 1, ...rotProp, duration: dur, ease }, 0);

      if (media) {
        const drift = Math.max(-1.5, Math.min(1.5, active - i));
        const shift = drift * parallax * mediaSize * 0.06;
        const gray = grayscale ? (isActive ? 0 : 1) : 0;
        timeline.to(
          media,
          {
            xPercent: -50,
            yPercent: -50,
            x: vertical ? 0 : isActive ? 0 : shift,
            y: vertical ? (isActive ? 0 : shift) : 0,
            '--ag-gray': gray,
            '--ag-dim': isActive ? 0 : 0.35,
            duration: dur,
            ease
          },
          0
        );
      }

      if (showLabels && bar && text) {
        if (isActive) {
          timeline.to(
            [bar, text],
            { opacity: 1, x: 0, duration: dur, ease, stagger: prefersReduced ? 0 : stagger },
            0
          );
        } else {
          timeline.to([bar, text], { opacity: 0, x: -14, duration: dur * 0.6, ease }, 0);
        }
      }
    });

    tl = timeline;
  }

  function measure() {
    if (!rootEl) return;
    const rect = rootEl.getBoundingClientRect();
    const total = vertical ? rect.height : rect.width;
    const usable = Math.max(total - gap * (count - 1), 120);
    const size = Math.max(140, usable * Math.min(Math.max(expandRatio, 0.2), 0.9) * 1.22);
    mediaSize = size;
    rootEl.style.setProperty('--ag-media-size', `${size}px`);
    applyLayout(!firstRun);
  }

  function handleEnter(i: number) {
    if (trigger === 'hover') active = i;
  }

  function handleClick(i: number, e: MouseEvent) {
    if (i !== active) {
      e.preventDefault();
      active = i;
    }
  }

  function handleFocus(i: number) {
    active = i;
  }

  function handleKeyDown(i: number, e: KeyboardEvent) {
    if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
      e.preventDefault();
      active = (i + 1) % count;
    } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
      e.preventDefault();
      active = (i - 1 + count) % count;
    }
  }

  onMount(() => {
    prefersReduced =
      typeof window !== 'undefined' && !!window.matchMedia
        ? window.matchMedia('(prefers-reduced-motion: reduce)').matches
        : false;

    measure();
    ro = new ResizeObserver(measure);
    ro.observe(rootEl);

    firstRun = false;
  });

  onDestroy(() => {
    tl?.kill();
    ro?.disconnect();
  });

  // re-run layout whenever active index or any layout-affecting prop changes
  $: if (rootEl) {
    // touch dependencies explicitly so Svelte tracks them
    void (active, count, expandRatio, duration, ease, vertical, tilt, parallax, grayscale, showLabels, stagger);
    applyLayout(!firstRun);
  }

  // re-measure whenever gap/expandRatio/orientation/count changes the usable space
  $: if (rootEl) {
    void (gap, expandRatio, vertical, count);
    measure();
  }
</script>

<div
  bind:this={rootEl}
  class={`accordion-gallery-root ${vertical ? 'is-vertical' : 'is-horizontal'} ${className}`.trim()}
  style={`gap:${gap}px; height:${vertical ? Math.round(height * 1.6) : height}px; --ag-accent:${accentColor};`}
  role="list"
  aria-label="Image accordion gallery"
>
  {#each items as item, i (i)}
    {@const isActive = i === active}
    <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
    <svelte:element
      this={item.link ? 'a' : 'div'}
      bind:this={panelEls[i]}
      class="accordion-gallery-panel"
      style={`border-radius:${radius}px;`}
      href={item.link || undefined}
      on:click={(e: MouseEvent) => { handleClick(i, e); }}
      on:mouseenter={() => { handleEnter(i); }}
      on:focus={() => { handleFocus(i); }}
      on:keydown={(e: KeyboardEvent) => { handleKeyDown(i, e); }}
      role="listitem"
      tabindex={0}
      aria-current={isActive ? 'true' : undefined}
      aria-label={item.label}
    >
      <span class="accordion-gallery-clip">
        <span
          bind:this={mediaEls[i]}
          class="accordion-gallery-media"
          style={`width:${vertical ? '100%' : 'var(--ag-media-size, 320px)'};height:${
            vertical ? 'var(--ag-media-size, 320px)' : '100%'
          };`}
        >
          <img
            src={item.image}
            alt={item.alt || item.label || ''}
            draggable="false"
            class="accordion-gallery-img"
          />
        </span>
        <span class="accordion-gallery-overlay" style={`background:${overlayBg};`} aria-hidden="true"
        ></span>
      </span>
      {#if showLabels}
        <span class="accordion-gallery-caption" aria-hidden="true">
          <span
            bind:this={barEls[i]}
            class="accordion-gallery-bar"
            style={`background:${accentColor}; box-shadow: 0 0 12px color-mix(in srgb, ${accentColor} 60%, transparent);`}
          ></span>
          <span bind:this={textEls[i]} class="accordion-gallery-label" style={`color:${textColor};`}>
            {item.label}
          </span>
        </span>
      {/if}
    </svelte:element>
  {/each}
</div>

<style>
  .accordion-gallery-root {
    display: flex;
    width: 100%;
    max-width: 100%;
    perspective: 1400px;
  }
  .accordion-gallery-root.is-horizontal {
    flex-direction: row;
  }
  .accordion-gallery-root.is-vertical {
    flex-direction: column;
  }
  @media (max-width: 520px) {
    .accordion-gallery-root {
      flex-direction: column !important;
      perspective: none !important;
    }
  }

  .accordion-gallery-panel {
    position: relative;
    display: block;
    min-width: 0;
    min-height: 0;
    flex: 1 1 0;
    cursor: pointer;
    overflow: hidden;
    background: #0a0713;
    text-decoration: none;
    outline: none;
    transform-style: preserve-3d;
    transform-origin: center;
    box-shadow: 0 10px 30px -18px rgba(0, 0, 0, 0.8);
    will-change: flex-grow, transform;
  }
  .accordion-gallery-panel:focus-visible {
    box-shadow:
      0 0 0 2px var(--ag-accent),
      0 10px 30px -18px rgba(0, 0, 0, 0.8);
  }
  @media (max-width: 520px) {
    .accordion-gallery-panel {
      min-height: 84px;
      transform: none !important;
    }
  }

  .accordion-gallery-clip {
    position: absolute;
    inset: 0;
    overflow: hidden;
    border-radius: inherit;
  }

  .accordion-gallery-media {
    position: absolute;
    top: 50%;
    left: 50%;
    filter: grayscale(var(--ag-gray, 1));
    will-change: transform, filter;
  }

  .accordion-gallery-img {
    display: block;
    height: 100%;
    width: 100%;
    -webkit-user-select: none;
    user-select: none;
    object-fit: cover;
    -webkit-user-drag: none;
  }

  .accordion-gallery-overlay {
    pointer-events: none;
    position: absolute;
    inset: 0;
  }

  .accordion-gallery-caption {
    pointer-events: none;
    position: absolute;
    bottom: 20px;
    left: 20px;
    right: 20px;
    z-index: 2;
    display: flex;
    align-items: center;
    gap: 12px;
  }

  .accordion-gallery-bar {
    height: 26px;
    width: 3px;
    flex: none;
    border-radius: 3px;
    opacity: 0;
  }

  .accordion-gallery-label {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: clamp(1rem, 1.4vw, 1.4rem);
    font-weight: 600;
    letter-spacing: 0.01em;
    opacity: 0;
    text-shadow: 0 2px 14px rgba(0, 0, 0, 0.55);
  }
</style>

Использование

Базовый пример

<script>
	import AccordionGallery from '$lib/components/ui-registry/accordiongallery.svelte';

	const items = [
		{ image: '/photo1.jpg', label: 'Canyon', alt: 'Canyon' },
		{ image: '/photo2.jpg', label: 'Ridgeline', alt: 'Ridgeline' },
		{ image: '/photo3.jpg', label: 'Falls', alt: 'Falls' }
	];
</script>

<div class="w-full">
	<AccordionGallery items={items} />
</div>
<script>
	import AccordionGallery from '$lib/components/ui-registry/accordiongallery.svelte';

	const items = [
		{ image: '/photo1.jpg', label: 'Canyon', alt: 'Canyon' },
		{ image: '/photo2.jpg', label: 'Ridgeline', alt: 'Ridgeline' },
		{ image: '/photo3.jpg', label: 'Falls', alt: 'Falls' }
	];
</script>

<div class="w-full">
	<AccordionGallery items={items} />
</div>

С переходами по ссылкам

<script>
	const items = [
		{ image: '/photo1.jpg', label: 'Документация', link: '/docs' },
		{ image: '/photo2.jpg', label: 'Статьи', link: '/article' },
		{ image: '/photo3.jpg', label: 'Компоненты', link: '/components' }
	];
</script>

<AccordionGallery items={items} trigger="click" />
<script>
	const items = [
		{ image: '/photo1.jpg', label: 'Документация', link: '/docs' },
		{ image: '/photo2.jpg', label: 'Статьи', link: '/article' },
		{ image: '/photo3.jpg', label: 'Компоненты', link: '/components' }
	];
</script>

<AccordionGallery items={items} trigger="click" />

Вертикальная ориентация

<AccordionGallery
	items={items}
	orientation="vertical"
	height={520}
	gap={12}
/>
<AccordionGallery
	items={items}
	orientation="vertical"
	height={520}
	gap={12}
/>

Настройка внешнего вида

<AccordionGallery
	items={items}
	accentColor="#ffffff"
	overlayColor="#060010"
	textColor="#ffffff"
	radius={20}
	showLabels={true}
	grayscale={true}
/>
<AccordionGallery
	items={items}
	accentColor="#ffffff"
	overlayColor="#060010"
	textColor="#ffffff"
	radius={20}
	showLabels={true}
	grayscale={true}
/>

Пропсы

Пропс Тип По умолчанию Описание
items AccordionGalleryItem[] [...] Массив изображений и метаданных
defaultIndex number 2 Индекс панели, открытой по умолчанию
accentColor string '#ffffff' Цвет акцентной полосы
overlayColor string '#060010' Цвет затемняющего слоя
textColor string '#ffffff' Цвет подписей
height number 460 Высота горизонтальной галереи (px)
gap number 10 Расстояние между панелями (px)
radius number 16 Скругление углов (px)
expandRatio number 0.52 Доля ширины активной панели
orientation 'horizontal' 'vertical' 'horizontal' Направление раскладки
duration number 0.6 Длительность анимации (с)
ease string 'power3.out' Функция плавности GSAP
parallax number 0.5 Сила параллакс-смещения
tilt number 8 Угол наклона неактивных панелей
stagger number 0.06 Задержка появления частей подписи (с)
trigger 'hover' 'click' 'hover' Способ активации панели
showLabels boolean true Показывать подписи
grayscale boolean true Обесцвечивать неактивные панели
className string '' Дополнительные классы

AccordionGalleryItem

interface AccordionGalleryItem {
	image: string;
	label?: string;
	link?: string;
	alt?: string;
}
interface AccordionGalleryItem {
	image: string;
	label?: string;
	link?: string;
	alt?: string;
}

image принимает локальный путь или внешний URL. Если передан link, панель становится ссылкой и открывает указанный адрес после активации.

Взаимодействие

  • Наведение — раскрывает панель при trigger="hover"
  • Клик — активирует панель и, если задан link, переходит по ссылке
  • Клавиатура — стрелки переключают активную панель
  • Tab — позволяет перейти к любой панели
  • Touch — на узких экранах панели автоматически переходят в вертикальный режим

Доступность

  • Галерея размечена ролью list
  • Панели размечены ролью listitem
  • Активная панель получает aria-current="true"
  • Поддерживаются фокус с клавиатуры и видимый focus-visible
  • Изображения используют переданный alt