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

Галереи

Карусель с глубиной

3D-карусель с эффектом глубины, инерцией и автопрокруткой.


Карусель с глубиной

3D-карусель с эффектом глубины: карты расходятся по оси Z с перспективой, затуханием яркости и размытием дальних слоёв. Поддерживает drag-прокрутку с инерцией, колесо мыши, клавиатуру и автопрокрутку.

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

Пропсы

Ширина (px)
300
Высота (px)
380
Глубина (px)
220
Разнос (px)
90
Видимых карт
4
Наклон
<script lang="ts">
  import { onMount, onDestroy, createEventDispatcher } from 'svelte';
  import gsap from 'gsap';

  export type DepthCarouselItem = string | { image: string; alt?: string };
  type TiltDirection = 'left' | 'right';

  // ----- props -----
  export let items: DepthCarouselItem[] = [
    { image: 'https://picsum.photos/seed/depth1/800/1000', alt: 'Slide 1' },
    { image: 'https://picsum.photos/seed/depth2/800/1000', alt: 'Slide 2' },
    { image: 'https://picsum.photos/seed/depth3/800/1000', alt: 'Slide 3' },
    { image: 'https://picsum.photos/seed/depth4/800/1000', alt: 'Slide 4' },
    { image: 'https://picsum.photos/seed/depth5/800/1000', alt: 'Slide 5' },
    { image: 'https://picsum.photos/seed/depth6/800/1000', alt: 'Slide 6' }
  ];
  export let cardWidth = 300;
  export let cardHeight = 380;
  export let radius = 18;
  export let tint = '#05060a';
  export let depth = 220;
  export let spread = 90;
  export let tilt = 22;
  export let tiltDirection: TiltDirection = 'right';
  export let perspective = 1400;
  export let visibleCards = 4;
  export let falloff = 0.2;
  export let blur = 6;
  export let duration = 700;
  export let ease = 'power3.out';
  export let autoplay = false;
  export let autoplayDelay = 3200;
  export let loop = true;
  export let showControls = true;
  export let showIndicators = true;
  export let className = '';

  const dispatch = createEventDispatcher<{
    change: { index: number; item: { image: string; alt?: string } };
  }>();

  // ----- helpers -----
  const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);
  const normalizeItem = (it: DepthCarouselItem) =>
    typeof it === 'string' ? { image: it, alt: '' } : it;

  $: data = (Array.isArray(items) ? items : []).map(normalizeItem);
  $: count = data.length;

  // ----- refs -----
  let rootEl: HTMLDivElement;
  let cardEls: (HTMLDivElement | null)[] = [];
  let overlayEls: (HTMLSpanElement | null)[] = [];

  // ----- mutable state kept outside Svelte reactivity (mirrors React refs) -----
  let posRef = 0;
  let focusRef = 0;
  let tween: gsap.core.Tween | null = null;
  let scaleRef = 1;
  let dragState: {
    x: number;
    startPos: number;
    lastX: number;
    lastT: number;
    v: number;
    moved: boolean;
    id: number;
  } | null = null;
  let wheelTimer: ReturnType<typeof setTimeout> | null = null;
  let autoTimer: ReturnType<typeof setInterval> | null = null;
  let reduced = false;
  let ro: ResizeObserver | null = null;

  let active = 0;

  // config snapshot object, refreshed reactively whenever a relevant prop changes
  $: cfg = {
    count,
    depth,
    spread,
    tilt,
    tiltDirection,
    visibleCards,
    falloff,
    blur,
    duration,
    ease,
    loop,
    cardWidth,
    autoplayDelay
  };

  function layout(pos: number) {
    const n = cfg.count;
    if (!n) return;
    const dir = cfg.tiltDirection === 'left' ? -1 : 1;
    const sc = scaleRef;

    for (let i = 0; i < n; i++) {
      const el = cardEls[i];
      if (!el) continue;

      let d = i - pos;
      if (cfg.loop && n > 1) {
        d = ((d % n) + n) % n;
        if (d > n / 2) d -= n;
      }

      const back = Math.max(0, d);
      const az = Math.abs(d);
      const shown = az <= cfg.visibleCards + 0.5;

      const tz = -cfg.depth * d;
      const tx = dir * cfg.spread * d;
      const ry = dir * cfg.tilt * clamp(d, 0, 1);

      let opacity = d < 0 ? Math.max(0, 1 + d) : 1;
      if (!shown) opacity = 0;

      const brightness = Math.max(0.15, 1 - back * cfg.falloff);
      const blurPx =
        cfg.blur > 0 ? Math.min(cfg.blur, (back / Math.max(1, cfg.visibleCards)) * cfg.blur) : 0;
      const zi = Math.round(2000 - d * 20);

      el.style.transform = `translate(-50%, -50%) scale(${sc}) translateX(${tx.toFixed(
        2
      )}px) translateZ(${tz.toFixed(2)}px) rotateY(${ry.toFixed(3)}deg)`;
      el.style.opacity = opacity.toFixed(3);
      el.style.filter = `brightness(${brightness.toFixed(3)}) blur(${blurPx.toFixed(2)}px)`;
      el.style.zIndex = String(zi);
      el.style.pointerEvents = shown && opacity > 0.05 ? 'auto' : 'none';

      const ov = overlayEls[i];
      if (ov) ov.style.opacity = clamp(back * cfg.falloff * 1.25, 0, 0.86).toFixed(3);
    }
  }

  function notify(idx: number) {
    active = idx;
    dispatch('change', { index: idx, item: data[idx] });
  }

  function tweenTo(target: number, animate: boolean) {
    tween?.kill();
    const proxy = { p: posRef };
    const dur = animate && !reduced ? cfg.duration / 1000 : 0;
    tween = gsap.to(proxy, {
      p: target,
      duration: dur,
      ease: cfg.ease,
      onUpdate: () => {
        posRef = proxy.p;
        layout(proxy.p);
      },
      onComplete: () => {
        const n = cfg.count;
        if (n > 0) posRef = ((posRef % n) + n) % n;
        layout(posRef);
      }
    });
  }

  function setFocus(rawIndex: number, animate = true) {
    const n = cfg.count;
    if (!n) return;
    const idx = cfg.loop ? ((rawIndex % n) + n) % n : clamp(rawIndex, 0, n - 1);
    let delta = idx - posRef;
    if (cfg.loop && n > 1) {
      delta = ((delta % n) + n) % n;
      if (delta > n / 2) delta -= n;
    }
    tweenTo(posRef + delta, animate);
    if (idx !== focusRef) {
      focusRef = idx;
      notify(idx);
    }
  }

  function navigateBy(step: number) {
    setFocus(focusRef + step, true);
  }

  // ----- wheel -----
  function onWheel(e: WheelEvent) {
    if (cfg.count < 2) return;
    e.preventDefault();
    tween?.kill();
    const raw = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
    const delta = e.deltaMode === 1 ? raw * 24 : raw;
    const step = clamp(delta / (cfg.cardWidth * 0.9), -0.6, 0.6);
    posRef += step;
    layout(posRef);
    if (wheelTimer) clearTimeout(wheelTimer);
    wheelTimer = setTimeout(() => { setFocus(Math.round(posRef), true); }, 130);
  }

  // ----- pointer / drag -----
  function onPointerDown(e: PointerEvent) {
    if (cfg.count < 2) return;
    tween?.kill();
    dragState = {
      x: e.clientX,
      startPos: posRef,
      lastX: e.clientX,
      lastT: performance.now(),
      v: 0,
      moved: false,
      id: e.pointerId
    };
  }

  function onPointerMove(e: PointerEvent) {
    const drag = dragState;
    if (!drag) return;
    const stepPx = Math.max(cfg.cardWidth * 0.55 * scaleRef, 40);
    const dx = e.clientX - drag.x;
    if (!drag.moved && Math.abs(dx) > 4) {
      drag.moved = true;
      rootEl?.setPointerCapture(drag.id);
    }
    if (!drag.moved) return;
    const now = performance.now();
    const dt = Math.max(now - drag.lastT, 1);
    drag.v = (e.clientX - drag.lastX) / dt;
    drag.lastX = e.clientX;
    drag.lastT = now;
    posRef = drag.startPos - dx / stepPx;
    layout(posRef);
  }

  function onPointerEnd() {
    const drag = dragState;
    if (!drag) return;
    dragState = null;
    if (!drag.moved) return;
    const stepPx = Math.max(cfg.cardWidth * 0.55 * scaleRef, 40);
    const projected = posRef - (drag.v * 180) / stepPx;
    setFocus(Math.round(projected), true);
  }

  function onKeyDown(e: KeyboardEvent) {
    if (e.key === 'ArrowLeft') {
      e.preventDefault();
      navigateBy(-1);
    } else if (e.key === 'ArrowRight') {
      e.preventDefault();
      navigateBy(1);
    }
  }

  function onCardClick(index: number) {
    if (dragState?.moved) return;
    setFocus(index, true);
  }

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

    ro = new ResizeObserver(entries => {
      const w = entries[0].contentRect.width;
      const needed = cfg.cardWidth + Math.abs(cfg.spread) * 2 + 120;
      scaleRef = clamp(w / needed, 0.4, 1);
      layout(posRef);
    });
    ro.observe(rootEl);

    rootEl.addEventListener('wheel', onWheel, { passive: false });

    let hovered = false;
    let focused = false;
    const stopAuto = () => {
      if (autoTimer) clearInterval(autoTimer);
      autoTimer = null;
    };
    const startAuto = () => {
      stopAuto();
      autoTimer = setInterval(
        () => {
          if (!hovered && !focused) navigateBy(1);
        },
        Math.max(cfg.autoplayDelay, 600)
      );
    };
    const onEnter = () => (hovered = true);
    const onLeave = () => (hovered = false);
    const onFocusIn = () => (focused = true);
    const onFocusOut = () => (focused = false);

    if (autoplay && !reduced && count >= 2) {
      rootEl.addEventListener('mouseenter', onEnter);
      rootEl.addEventListener('mouseleave', onLeave);
      rootEl.addEventListener('focusin', onFocusIn);
      rootEl.addEventListener('focusout', onFocusOut);
      startAuto();
    }

    layout(posRef);

    return () => {
      stopAuto();
      rootEl.removeEventListener('wheel', onWheel);
      rootEl.removeEventListener('mouseenter', onEnter);
      rootEl.removeEventListener('mouseleave', onLeave);
      rootEl.removeEventListener('focusin', onFocusIn);
      rootEl.removeEventListener('focusout', onFocusOut);
    };
  });

  onDestroy(() => {
    tween?.kill();
    if (wheelTimer) clearTimeout(wheelTimer);
    if (autoTimer) clearInterval(autoTimer);
    ro?.disconnect();
  });

  // re-layout whenever visual config props change
  $: if (rootEl) {
    // touch every dependency explicitly so Svelte tracks them
    void (depth, spread, tilt, tiltDirection, visibleCards, falloff, blur, cardWidth, cardHeight, radius, count);
    layout(posRef);
  }
</script>

<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
  bind:this={rootEl}
  class={`depth-carousel-root ${className}`.trim()}
  style={`perspective: ${perspective}px;`}
  role="group"
  aria-roledescription="carousel"
  aria-label="Depth carousel"
  tabindex="0"
  on:pointerdown={onPointerDown}
  on:pointermove={onPointerMove}
  on:pointerup={onPointerEnd}
  on:pointercancel={onPointerEnd}
  on:keydown={onKeyDown}
>
  <div class="depth-carousel-stage">
    {#each data as item, i (i)}
      <div
        class="depth-carousel-card"
        bind:this={cardEls[i]}
        style={`width:${cardWidth}px;height:${cardHeight}px;border-radius:${radius}px;`}
        aria-roledescription="slide"
        aria-label={`${i + 1} of ${count}`}
        aria-hidden={active !== i}
        on:click={() => { onCardClick(i); }}
      >
        <img class="depth-carousel-img" src={item.image} alt={item.alt || ''} draggable="false" />
        <span
          class="depth-carousel-overlay"
          bind:this={overlayEls[i]}
          style={`background:${tint};`}
        ></span>
      </div>
    {/each}
  </div>

  {#if showControls && count > 1}
    <button
      type="button"
      class="depth-carousel-btn depth-carousel-btn-prev"
      aria-label="Previous slide"
      on:click={() => { navigateBy(-1); }}
    >
      <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
        <path
          d="M15 5l-7 7 7 7"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>
    </button>
    <button
      type="button"
      class="depth-carousel-btn depth-carousel-btn-next"
      aria-label="Next slide"
      on:click={() => { navigateBy(1); }}
    >
      <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
        <path
          d="M9 5l7 7-7 7"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>
    </button>
  {/if}

  {#if showIndicators && count > 1}
    <div class="depth-carousel-indicators" role="tablist" aria-label="Slides">
      {#each data as _, i (i)}
        <button
          type="button"
          role="tab"
          aria-selected={active === i}
          aria-label={`Go to slide ${i + 1}`}
          class="depth-carousel-dot"
          class:is-active={active === i}
          on:click={() => { setFocus(i, true); }}
        ></button>
      {/each}
    </div>
  {/if}
</div>

<style>
  .depth-carousel-root {
    position: relative;
    display: flex;
    height: 100%;
    min-height: 320px;
    width: 100%;
    cursor: grab;
    touch-action: pan-y;
    -webkit-user-select: none;
    user-select: none;
    align-items: center;
    justify-content: center;
    outline: none;
    perspective-origin: 50% 50%;
  }
  .depth-carousel-root:active {
    cursor: grabbing;
  }
  .depth-carousel-root:focus-visible {
    border-radius: 0.75rem;
    outline: 2px solid rgba(255, 255, 255, 0.5);
    outline-offset: 4px;
  }

  .depth-carousel-stage {
    position: absolute;
    inset: 0;
    transform-style: preserve-3d;
  }

  .depth-carousel-card {
    position: absolute;
    left: 50%;
    top: 50%;
    cursor: pointer;
    overflow: hidden;
    background: #0b0d12;
    box-shadow:
      0 30px 60px -20px rgba(0, 0, 0, 0.65),
      0 8px 20px -10px rgba(0, 0, 0, 0.5);
    transform: translate(-50%, -50%);
    transform-origin: center;
    will-change: transform, opacity, filter;
  }

  .depth-carousel-img {
    display: block;
    height: 100%;
    width: 100%;
    -webkit-user-select: none;
    user-select: none;
    object-fit: cover;
    pointer-events: none;
    -webkit-user-drag: none;
  }

  .depth-carousel-overlay {
    pointer-events: none;
    position: absolute;
    inset: 0;
    opacity: 0;
    mix-blend-mode: multiply;
  }

  .depth-carousel-btn {
    position: absolute;
    top: 50%;
    z-index: 3000;
    display: grid;
    height: 42px;
    width: 42px;
    transform: translateY(-50%);
    place-items: center;
    border-radius: 9999px;
    border: 1px solid rgba(255, 255, 255, 0.2);
    background: rgba(18, 20, 26, 0.55);
    color: #fff;
    backdrop-filter: blur(12px);
    transition:
      background 0.2s,
      border-color 0.2s,
      transform 0.2s;
    cursor: pointer;
  }
  .depth-carousel-btn:hover {
    border-color: rgba(255, 255, 255, 0.4);
    background: rgba(28, 31, 40, 0.85);
  }
  .depth-carousel-btn:active {
    transform: translateY(-50%) scale(0.95);
  }
  .depth-carousel-btn-prev {
    left: 1rem;
  }
  .depth-carousel-btn-next {
    right: 1rem;
  }

  .depth-carousel-indicators {
    position: absolute;
    bottom: 1rem;
    left: 50%;
    z-index: 3000;
    display: flex;
    transform: translateX(-50%);
    gap: 0.5rem;
    border-radius: 9999px;
    background: rgba(14, 16, 22, 0.4);
    padding: 0.5rem 0.75rem;
    backdrop-filter: blur(4px);
  }

  .depth-carousel-dot {
    height: 7px;
    width: 7px;
    cursor: pointer;
    border: none;
    border-radius: 9999px;
    background: rgba(255, 255, 255, 0.3);
    padding: 0;
    transition:
      width 0.25s,
      background 0.25s;
  }
  .depth-carousel-dot.is-active {
    width: 20px;
    background: #fff;
  }
</style>
<script lang="ts">
  import { onMount, onDestroy, createEventDispatcher } from 'svelte';
  import gsap from 'gsap';

  export type DepthCarouselItem = string | { image: string; alt?: string };
  type TiltDirection = 'left' | 'right';

  // ----- props -----
  export let items: DepthCarouselItem[] = [
    { image: 'https://picsum.photos/seed/depth1/800/1000', alt: 'Slide 1' },
    { image: 'https://picsum.photos/seed/depth2/800/1000', alt: 'Slide 2' },
    { image: 'https://picsum.photos/seed/depth3/800/1000', alt: 'Slide 3' },
    { image: 'https://picsum.photos/seed/depth4/800/1000', alt: 'Slide 4' },
    { image: 'https://picsum.photos/seed/depth5/800/1000', alt: 'Slide 5' },
    { image: 'https://picsum.photos/seed/depth6/800/1000', alt: 'Slide 6' }
  ];
  export let cardWidth = 300;
  export let cardHeight = 380;
  export let radius = 18;
  export let tint = '#05060a';
  export let depth = 220;
  export let spread = 90;
  export let tilt = 22;
  export let tiltDirection: TiltDirection = 'right';
  export let perspective = 1400;
  export let visibleCards = 4;
  export let falloff = 0.2;
  export let blur = 6;
  export let duration = 700;
  export let ease = 'power3.out';
  export let autoplay = false;
  export let autoplayDelay = 3200;
  export let loop = true;
  export let showControls = true;
  export let showIndicators = true;
  export let className = '';

  const dispatch = createEventDispatcher<{
    change: { index: number; item: { image: string; alt?: string } };
  }>();

  // ----- helpers -----
  const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);
  const normalizeItem = (it: DepthCarouselItem) =>
    typeof it === 'string' ? { image: it, alt: '' } : it;

  $: data = (Array.isArray(items) ? items : []).map(normalizeItem);
  $: count = data.length;

  // ----- refs -----
  let rootEl: HTMLDivElement;
  let cardEls: (HTMLDivElement | null)[] = [];
  let overlayEls: (HTMLSpanElement | null)[] = [];

  // ----- mutable state kept outside Svelte reactivity (mirrors React refs) -----
  let posRef = 0;
  let focusRef = 0;
  let tween: gsap.core.Tween | null = null;
  let scaleRef = 1;
  let dragState: {
    x: number;
    startPos: number;
    lastX: number;
    lastT: number;
    v: number;
    moved: boolean;
    id: number;
  } | null = null;
  let wheelTimer: ReturnType<typeof setTimeout> | null = null;
  let autoTimer: ReturnType<typeof setInterval> | null = null;
  let reduced = false;
  let ro: ResizeObserver | null = null;

  let active = 0;

  // config snapshot object, refreshed reactively whenever a relevant prop changes
  $: cfg = {
    count,
    depth,
    spread,
    tilt,
    tiltDirection,
    visibleCards,
    falloff,
    blur,
    duration,
    ease,
    loop,
    cardWidth,
    autoplayDelay
  };

  function layout(pos: number) {
    const n = cfg.count;
    if (!n) return;
    const dir = cfg.tiltDirection === 'left' ? -1 : 1;
    const sc = scaleRef;

    for (let i = 0; i < n; i++) {
      const el = cardEls[i];
      if (!el) continue;

      let d = i - pos;
      if (cfg.loop && n > 1) {
        d = ((d % n) + n) % n;
        if (d > n / 2) d -= n;
      }

      const back = Math.max(0, d);
      const az = Math.abs(d);
      const shown = az <= cfg.visibleCards + 0.5;

      const tz = -cfg.depth * d;
      const tx = dir * cfg.spread * d;
      const ry = dir * cfg.tilt * clamp(d, 0, 1);

      let opacity = d < 0 ? Math.max(0, 1 + d) : 1;
      if (!shown) opacity = 0;

      const brightness = Math.max(0.15, 1 - back * cfg.falloff);
      const blurPx =
        cfg.blur > 0 ? Math.min(cfg.blur, (back / Math.max(1, cfg.visibleCards)) * cfg.blur) : 0;
      const zi = Math.round(2000 - d * 20);

      el.style.transform = `translate(-50%, -50%) scale(${sc}) translateX(${tx.toFixed(
        2
      )}px) translateZ(${tz.toFixed(2)}px) rotateY(${ry.toFixed(3)}deg)`;
      el.style.opacity = opacity.toFixed(3);
      el.style.filter = `brightness(${brightness.toFixed(3)}) blur(${blurPx.toFixed(2)}px)`;
      el.style.zIndex = String(zi);
      el.style.pointerEvents = shown && opacity > 0.05 ? 'auto' : 'none';

      const ov = overlayEls[i];
      if (ov) ov.style.opacity = clamp(back * cfg.falloff * 1.25, 0, 0.86).toFixed(3);
    }
  }

  function notify(idx: number) {
    active = idx;
    dispatch('change', { index: idx, item: data[idx] });
  }

  function tweenTo(target: number, animate: boolean) {
    tween?.kill();
    const proxy = { p: posRef };
    const dur = animate && !reduced ? cfg.duration / 1000 : 0;
    tween = gsap.to(proxy, {
      p: target,
      duration: dur,
      ease: cfg.ease,
      onUpdate: () => {
        posRef = proxy.p;
        layout(proxy.p);
      },
      onComplete: () => {
        const n = cfg.count;
        if (n > 0) posRef = ((posRef % n) + n) % n;
        layout(posRef);
      }
    });
  }

  function setFocus(rawIndex: number, animate = true) {
    const n = cfg.count;
    if (!n) return;
    const idx = cfg.loop ? ((rawIndex % n) + n) % n : clamp(rawIndex, 0, n - 1);
    let delta = idx - posRef;
    if (cfg.loop && n > 1) {
      delta = ((delta % n) + n) % n;
      if (delta > n / 2) delta -= n;
    }
    tweenTo(posRef + delta, animate);
    if (idx !== focusRef) {
      focusRef = idx;
      notify(idx);
    }
  }

  function navigateBy(step: number) {
    setFocus(focusRef + step, true);
  }

  // ----- wheel -----
  function onWheel(e: WheelEvent) {
    if (cfg.count < 2) return;
    e.preventDefault();
    tween?.kill();
    const raw = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
    const delta = e.deltaMode === 1 ? raw * 24 : raw;
    const step = clamp(delta / (cfg.cardWidth * 0.9), -0.6, 0.6);
    posRef += step;
    layout(posRef);
    if (wheelTimer) clearTimeout(wheelTimer);
    wheelTimer = setTimeout(() => { setFocus(Math.round(posRef), true); }, 130);
  }

  // ----- pointer / drag -----
  function onPointerDown(e: PointerEvent) {
    if (cfg.count < 2) return;
    tween?.kill();
    dragState = {
      x: e.clientX,
      startPos: posRef,
      lastX: e.clientX,
      lastT: performance.now(),
      v: 0,
      moved: false,
      id: e.pointerId
    };
  }

  function onPointerMove(e: PointerEvent) {
    const drag = dragState;
    if (!drag) return;
    const stepPx = Math.max(cfg.cardWidth * 0.55 * scaleRef, 40);
    const dx = e.clientX - drag.x;
    if (!drag.moved && Math.abs(dx) > 4) {
      drag.moved = true;
      rootEl?.setPointerCapture(drag.id);
    }
    if (!drag.moved) return;
    const now = performance.now();
    const dt = Math.max(now - drag.lastT, 1);
    drag.v = (e.clientX - drag.lastX) / dt;
    drag.lastX = e.clientX;
    drag.lastT = now;
    posRef = drag.startPos - dx / stepPx;
    layout(posRef);
  }

  function onPointerEnd() {
    const drag = dragState;
    if (!drag) return;
    dragState = null;
    if (!drag.moved) return;
    const stepPx = Math.max(cfg.cardWidth * 0.55 * scaleRef, 40);
    const projected = posRef - (drag.v * 180) / stepPx;
    setFocus(Math.round(projected), true);
  }

  function onKeyDown(e: KeyboardEvent) {
    if (e.key === 'ArrowLeft') {
      e.preventDefault();
      navigateBy(-1);
    } else if (e.key === 'ArrowRight') {
      e.preventDefault();
      navigateBy(1);
    }
  }

  function onCardClick(index: number) {
    if (dragState?.moved) return;
    setFocus(index, true);
  }

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

    ro = new ResizeObserver(entries => {
      const w = entries[0].contentRect.width;
      const needed = cfg.cardWidth + Math.abs(cfg.spread) * 2 + 120;
      scaleRef = clamp(w / needed, 0.4, 1);
      layout(posRef);
    });
    ro.observe(rootEl);

    rootEl.addEventListener('wheel', onWheel, { passive: false });

    let hovered = false;
    let focused = false;
    const stopAuto = () => {
      if (autoTimer) clearInterval(autoTimer);
      autoTimer = null;
    };
    const startAuto = () => {
      stopAuto();
      autoTimer = setInterval(
        () => {
          if (!hovered && !focused) navigateBy(1);
        },
        Math.max(cfg.autoplayDelay, 600)
      );
    };
    const onEnter = () => (hovered = true);
    const onLeave = () => (hovered = false);
    const onFocusIn = () => (focused = true);
    const onFocusOut = () => (focused = false);

    if (autoplay && !reduced && count >= 2) {
      rootEl.addEventListener('mouseenter', onEnter);
      rootEl.addEventListener('mouseleave', onLeave);
      rootEl.addEventListener('focusin', onFocusIn);
      rootEl.addEventListener('focusout', onFocusOut);
      startAuto();
    }

    layout(posRef);

    return () => {
      stopAuto();
      rootEl.removeEventListener('wheel', onWheel);
      rootEl.removeEventListener('mouseenter', onEnter);
      rootEl.removeEventListener('mouseleave', onLeave);
      rootEl.removeEventListener('focusin', onFocusIn);
      rootEl.removeEventListener('focusout', onFocusOut);
    };
  });

  onDestroy(() => {
    tween?.kill();
    if (wheelTimer) clearTimeout(wheelTimer);
    if (autoTimer) clearInterval(autoTimer);
    ro?.disconnect();
  });

  // re-layout whenever visual config props change
  $: if (rootEl) {
    // touch every dependency explicitly so Svelte tracks them
    void (depth, spread, tilt, tiltDirection, visibleCards, falloff, blur, cardWidth, cardHeight, radius, count);
    layout(posRef);
  }
</script>

<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
  bind:this={rootEl}
  class={`depth-carousel-root ${className}`.trim()}
  style={`perspective: ${perspective}px;`}
  role="group"
  aria-roledescription="carousel"
  aria-label="Depth carousel"
  tabindex="0"
  on:pointerdown={onPointerDown}
  on:pointermove={onPointerMove}
  on:pointerup={onPointerEnd}
  on:pointercancel={onPointerEnd}
  on:keydown={onKeyDown}
>
  <div class="depth-carousel-stage">
    {#each data as item, i (i)}
      <div
        class="depth-carousel-card"
        bind:this={cardEls[i]}
        style={`width:${cardWidth}px;height:${cardHeight}px;border-radius:${radius}px;`}
        aria-roledescription="slide"
        aria-label={`${i + 1} of ${count}`}
        aria-hidden={active !== i}
        on:click={() => { onCardClick(i); }}
      >
        <img class="depth-carousel-img" src={item.image} alt={item.alt || ''} draggable="false" />
        <span
          class="depth-carousel-overlay"
          bind:this={overlayEls[i]}
          style={`background:${tint};`}
        ></span>
      </div>
    {/each}
  </div>

  {#if showControls && count > 1}
    <button
      type="button"
      class="depth-carousel-btn depth-carousel-btn-prev"
      aria-label="Previous slide"
      on:click={() => { navigateBy(-1); }}
    >
      <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
        <path
          d="M15 5l-7 7 7 7"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>
    </button>
    <button
      type="button"
      class="depth-carousel-btn depth-carousel-btn-next"
      aria-label="Next slide"
      on:click={() => { navigateBy(1); }}
    >
      <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
        <path
          d="M9 5l7 7-7 7"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>
    </button>
  {/if}

  {#if showIndicators && count > 1}
    <div class="depth-carousel-indicators" role="tablist" aria-label="Slides">
      {#each data as _, i (i)}
        <button
          type="button"
          role="tab"
          aria-selected={active === i}
          aria-label={`Go to slide ${i + 1}`}
          class="depth-carousel-dot"
          class:is-active={active === i}
          on:click={() => { setFocus(i, true); }}
        ></button>
      {/each}
    </div>
  {/if}
</div>

<style>
  .depth-carousel-root {
    position: relative;
    display: flex;
    height: 100%;
    min-height: 320px;
    width: 100%;
    cursor: grab;
    touch-action: pan-y;
    -webkit-user-select: none;
    user-select: none;
    align-items: center;
    justify-content: center;
    outline: none;
    perspective-origin: 50% 50%;
  }
  .depth-carousel-root:active {
    cursor: grabbing;
  }
  .depth-carousel-root:focus-visible {
    border-radius: 0.75rem;
    outline: 2px solid rgba(255, 255, 255, 0.5);
    outline-offset: 4px;
  }

  .depth-carousel-stage {
    position: absolute;
    inset: 0;
    transform-style: preserve-3d;
  }

  .depth-carousel-card {
    position: absolute;
    left: 50%;
    top: 50%;
    cursor: pointer;
    overflow: hidden;
    background: #0b0d12;
    box-shadow:
      0 30px 60px -20px rgba(0, 0, 0, 0.65),
      0 8px 20px -10px rgba(0, 0, 0, 0.5);
    transform: translate(-50%, -50%);
    transform-origin: center;
    will-change: transform, opacity, filter;
  }

  .depth-carousel-img {
    display: block;
    height: 100%;
    width: 100%;
    -webkit-user-select: none;
    user-select: none;
    object-fit: cover;
    pointer-events: none;
    -webkit-user-drag: none;
  }

  .depth-carousel-overlay {
    pointer-events: none;
    position: absolute;
    inset: 0;
    opacity: 0;
    mix-blend-mode: multiply;
  }

  .depth-carousel-btn {
    position: absolute;
    top: 50%;
    z-index: 3000;
    display: grid;
    height: 42px;
    width: 42px;
    transform: translateY(-50%);
    place-items: center;
    border-radius: 9999px;
    border: 1px solid rgba(255, 255, 255, 0.2);
    background: rgba(18, 20, 26, 0.55);
    color: #fff;
    backdrop-filter: blur(12px);
    transition:
      background 0.2s,
      border-color 0.2s,
      transform 0.2s;
    cursor: pointer;
  }
  .depth-carousel-btn:hover {
    border-color: rgba(255, 255, 255, 0.4);
    background: rgba(28, 31, 40, 0.85);
  }
  .depth-carousel-btn:active {
    transform: translateY(-50%) scale(0.95);
  }
  .depth-carousel-btn-prev {
    left: 1rem;
  }
  .depth-carousel-btn-next {
    right: 1rem;
  }

  .depth-carousel-indicators {
    position: absolute;
    bottom: 1rem;
    left: 50%;
    z-index: 3000;
    display: flex;
    transform: translateX(-50%);
    gap: 0.5rem;
    border-radius: 9999px;
    background: rgba(14, 16, 22, 0.4);
    padding: 0.5rem 0.75rem;
    backdrop-filter: blur(4px);
  }

  .depth-carousel-dot {
    height: 7px;
    width: 7px;
    cursor: pointer;
    border: none;
    border-radius: 9999px;
    background: rgba(255, 255, 255, 0.3);
    padding: 0;
    transition:
      width 0.25s,
      background 0.25s;
  }
  .depth-carousel-dot.is-active {
    width: 20px;
    background: #fff;
  }
</style>

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

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

<script>
	import DepthCarousel from '$lib/components/ui-registry/depth-carousel.svelte';

	const items = [
		{ image: '/photo1.jpg', alt: 'Photo 1' },
		{ image: '/photo2.jpg', alt: 'Photo 2' },
		{ image: '/photo3.jpg', alt: 'Photo 3' }
	];
</script>

<div class="h-[400px] w-full">
	<DepthCarousel items={items} />
</div>
<script>
	import DepthCarousel from '$lib/components/ui-registry/depth-carousel.svelte';

	const items = [
		{ image: '/photo1.jpg', alt: 'Photo 1' },
		{ image: '/photo2.jpg', alt: 'Photo 2' },
		{ image: '/photo3.jpg', alt: 'Photo 3' }
	];
</script>

<div class="h-[400px] w-full">
	<DepthCarousel items={items} />
</div>

С внешними ссылками

<script>
	import DepthCarousel from '$lib/components/ui-registry/depth-carousel.svelte';

	const items = [
		{ image: 'ссылка1', alt: 'Image 1' },
		{ image: 'ссылка2', alt: 'Image 2' },
		{ image: 'ссылка3', alt: 'Image 3' }
	];
</script>

<div class="h-[400px] w-full">
	<DepthCarousel items={items} cardWidth={300} cardHeight={380} />
</div>
<script>
	import DepthCarousel from '$lib/components/ui-registry/depth-carousel.svelte';

	const items = [
		{ image: 'ссылка1', alt: 'Image 1' },
		{ image: 'ссылка2', alt: 'Image 2' },
		{ image: 'ссылка3', alt: 'Image 3' }
	];
</script>

<div class="h-[400px] w-full">
	<DepthCarousel items={items} cardWidth={300} cardHeight={380} />
</div>

Простые строки

<script>
	import DepthCarousel from '$lib/components/ui-registry/depth-carousel.svelte';

	const items = [
		'ссылка1',
		'ссылка2',
		'ссылка3'
	];
</script>

<DepthCarousel items={items} />
<script>
	import DepthCarousel from '$lib/components/ui-registry/depth-carousel.svelte';

	const items = [
		'ссылка1',
		'ссылка2',
		'ссылка3'
	];
</script>

<DepthCarousel items={items} />

С автопрокруткой

<DepthCarousel
	items={items}
	autoplay={true}
	autoplayDelay={3000}
	loop={true}
	showControls={true}
	showIndicators={true}
/>
<DepthCarousel
	items={items}
	autoplay={true}
	autoplayDelay={3000}
	loop={true}
	showControls={true}
	showIndicators={true}
/>

Пропсы

Пропс Тип По умолчанию Описание
items DepthCarouselItem[] [...] Массив элементов карусели
cardWidth number 300 Ширина карты (px)
cardHeight number 380 Высота карты (px)
radius number 18 Скругление углов карты (px)
tint string '#05060a' Цвет затемнения дальних карт
depth number 220 Смещение по оси Z на шаг (px)
spread number 90 Горизонтальный разнос карт (px)
tilt number 22 Угол наклона карты по Y (град)
tiltDirection 'left' 'right' 'right' Направление наклона
perspective number 1400 Перспектива 3D-сцены (px)
visibleCards number 4 Кол-во видимых карт по сторонам
falloff number 0.2 Коэффициент затухания яркости
blur number 6 Макс. размытие дальних карт (px)
duration number 700 Длительность анимации (мс)
ease string 'power3.out' Функция плавности GSAP
autoplay boolean false Автопрокрутка
autoplayDelay number 3200 Интервал автопрокрутки (мс)
loop boolean true Бесконечная прокрутка
showControls boolean true Показывать стрелки навигации
showIndicators boolean true Показывать точки-индикаторы
className string '' Дополнительные классы

DepthCarouselItem

type DepthCarouselItem = string | { image: string; alt?: string };
type DepthCarouselItem = string | { image: string; alt?: string };

Элемент может быть строкой (только URL изображения) или объектом с полями image и необязательным alt.

События

Событие Тип Описание
change { index: number; item: { image: string; alt?: string } } Срабатывает при смене активной карты

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

  • Drag & Drop — перетаскивание для прокрутки с инерцией
  • Колёсико мыши — прокрутка с плавной анимацией
  • Клавиатура — стрелки влево/вправо для навигации
  • Click — клик по карте переводит её в центр
  • Touch — поддержка сенсорных устройств
  • Autoplay — автопрокрутка с паузой при наведении

Доступность

  • Роль group с aria-roledescription="carousel"
  • Карты имеют aria-roledescription="slide" и aria-hidden для неактивных
  • Кнопки навигации с aria-label
  • Индикаторы с role="tablist" и role="tab"
  • Поддержка prefers-reduced-motion