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

Галереи

Дрейфующая стена

Бесконечная стена изображений с параллаксом, 3D-наклоном и потоком колонок.


Дрейфующая стена

Бесконечная анимированная стена из плиток с изображениями. Колонки дрейфуют с разной скоростью и направлением, плоскость наклоняется в 3D за указателем, а плитки приподнимаются и подсвечиваются при наведении.

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

Image 1
Image 1
Image 1
Image 1
Image 1
Image 1
Image 1
Image 1
Image 2
Image 2
Image 2
Image 2
Image 2
Image 2
Image 2
Image 2
Image 3
Image 3
Image 3
Image 3
Image 3
Image 3
Image 3
Image 3
Image 4
Image 4
Image 4
Image 4
Image 4
Image 4
Image 4
Image 4
Image 1
Image 1
Image 1
Image 1
Image 1
Image 1
Image 1
Image 1

Пропсы

Колонки
5
Ширина плитки (px)
200
Высота плитки (px)
132
Отступ (px)
18
Скорость
42
Направление
<script lang="ts">
  import { onMount, onDestroy } from 'svelte';

  export type DriftWallItem = {
    image: string;
    title?: string;
    href?: string;
  }

  // ----- props -----
  export let items: DriftWallItem[] = Array.from({ length: 15 }, (_, i) => {
    const ids = [1015, 1025, 1039, 1043, 1044, 1050, 1062, 1069, 1074, 1080, 1084, 106, 110, 133, 164];
    return {
      image: `https://picsum.photos/id/${ids[i % ids.length]}/600/400`,
      title: `Tile ${i + 1}`,
      href: undefined
    };
  });
  export let columns = 5;
  export let tileWidth = 200;
  export let tileHeight = 132;
  export let gap = 18;
  export let radius = 14;
  export let tilt = 16;
  export let turn = -14;
  export let roll = 0;
  export let perspective = 1200;
  export let depth = 120;
  export let speed = 42;
  export let direction: 'up' | 'down' = 'up';
  export let variance = 0.45;
  export let parallax = 0.6;
  export let pauseOnHover = false;
  export let lift = 64;
  export let fade = 0.6;
  export let dim = 0.55;
  export let grayscale = false;
  export let overlayColor = '#060010';
  export let className = '';

  // ----- helpers -----
  const prefersReducedMotion = (): boolean =>
    typeof window !== 'undefined' && !!window.matchMedia &&
    window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  const columnFactor = (index: number, varianceVal: number): number => {
    const pseudo = ((index * 0.618033988 + 0.35) % 1) * 2 - 1;
    return 1 + varianceVal * pseudo;
  };


  // ----- refs -----
  let containerEl: HTMLDivElement;
  let planeEl: HTMLDivElement;
  let trackEls: (HTMLDivElement | null)[] = [];
  let rafId: number | null = null;
  let ro: ResizeObserver | null = null;
  let mq: MediaQueryList | null = null;

  // ----- mutable animation state (mirrors React refs) -----
  let offsets: number[] = [];
  let velocities: number[] = [];
  let hoveredCol = -1;
  let wallHovered = false;
  let pointer = { x: 0, y: 0 };
  let pointerDamped = { x: 0, y: 0 };
  let lastTs: number | null = null;
  let activeIdMutable: string | null = null;

  let containerHeight = 600;
  let activeId: string | null = null;
  let reduced = false;

  $: columnItems = (() => {
    const cols: DriftWallItem[][] = Array.from({ length: columns }, () => []);
    items.forEach((item, i) => cols[i % columns].push(item));
    return cols.map(col => (col.length ? col : items.slice(0, 1)));
  })();

  $: columnMeta = (() => {
    const unit = tileHeight + gap;
    return columnItems.map(col => {
      const copyHeight = Math.max(unit, col.length * unit);
      const copies = Math.max(2, Math.ceil((containerHeight * 1.6) / copyHeight) + 1);
      return { copyHeight, copies };
    });
  })();

  $: baseVelocities = (() => {
    const dirSign = direction === 'up' ? 1 : -1;
    return columnItems.map((_, c) => {
      const altSign = c % 2 === 0 ? 1 : -1;
      return speed * columnFactor(c, variance) * dirSign * altSign;
    });
  })();

  // reset offsets/velocities whenever the column layout changes
  let lastMetaKey = '';
  $: {
    const key = columnMeta.map(m => `${m.copyHeight}:${m.copies}`).join('|') + `#${columnItems.length}`;
    if (key !== lastMetaKey) {
      lastMetaKey = key;
      offsets = columnMeta.map((meta, c) => meta.copyHeight * ((c * 0.37) % 1));
      velocities = columnItems.map(() => 0);
    }
  }

  function applyPlaneTransform(px: number, py: number) {
    if (!planeEl) return;
    planeEl.style.transform =
      `translate(-50%, -50%) scale(1.18) ` +
      `rotateX(${tilt + py}deg) rotateY(${turn + px}deg) rotateZ(${roll}deg) ` +
      `translateZ(${-depth}px)`;
  }

  function animate(ts: number) {
    if (lastTs === null) lastTs = ts;
    const dt = Math.min(0.05, Math.max(0, ts - lastTs) / 1000);
    lastTs = ts;

    const maxTilt = parallax * 8;
    const targetX = pointer.x * maxTilt;
    const targetY = -pointer.y * maxTilt;
    const damp = 1 - Math.exp(-dt / 0.12);
    pointerDamped.x += (targetX - pointerDamped.x) * damp;
    pointerDamped.y += (targetY - pointerDamped.y) * damp;
    applyPlaneTransform(pointerDamped.x, pointerDamped.y);

    if (!reduced) {
      for (let c = 0; c < trackEls.length; c++) {
        const meta = columnMeta[c];
        if (!meta) continue;
        const paused = wallHovered && pauseOnHover;
        const factor = paused || hoveredCol === c ? 0 : 1;
        const target = baseVelocities[c] * factor;

        const ease = 1 - Math.exp(-dt / (target === 0 ? 0.16 : 0.28));
        velocities[c] = (velocities[c] ?? 0) + (target - (velocities[c] ?? 0)) * ease;
        let next = (offsets[c] ?? 0) + velocities[c] * dt;
        next = ((next % meta.copyHeight) + meta.copyHeight) % meta.copyHeight;
        offsets[c] = next;

        const el = trackEls[c];
        if (el) el.style.transform = `translate3d(0, ${-next}px, 0)`;
      }
    } else {
      for (let c = 0; c < trackEls.length; c++) {
        const el = trackEls[c];
        const meta = columnMeta[c];
        if (el && meta) el.style.transform = `translate3d(0, ${-(offsets[c] ?? 0)}px, 0)`;
      }
    }

    rafId = requestAnimationFrame(animate);
  }

  function activate(id: string, index: number) {
    activeIdMutable = id;
    hoveredCol = index;
    activeId = id;
  }

  function release() {
    activeIdMutable = null;
    hoveredCol = -1;
    activeId = null;
  }

  function handlePointerMove(e: PointerEvent) {
    const rect = containerEl?.getBoundingClientRect();
    if (!rect) return;
    if (parallax > 0 && !reduced) {
      pointer = {
        x: (e.clientX - rect.left) / rect.width - 0.5,
        y: (e.clientY - rect.top) / rect.height - 0.5
      };
    }
    const hit = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
    const tile = hit?.closest?.('[data-tile-id]') as HTMLElement | null;
    if (!tile) return;
    const id = tile.dataset.tileId ?? null;
    if (id === activeIdMutable) return;
    activeIdMutable = id;
    hoveredCol = Number(tile.dataset.col);
    activeId = id;
  }

  function handlePointerEnterWall() {
    wallHovered = true;
  }

  function handlePointerLeaveWall() {
    wallHovered = false;
    pointer = { x: 0, y: 0 };
    release();
  }

  $: cssVarsStyle = [
    `--dw-tile-w: ${tileWidth}px`,
    `--dw-tile-h: ${tileHeight}px`,
    `--dw-gap: ${gap}px`,
    `--dw-radius: ${radius}px`,
    `--dw-lift: ${lift}px`,
    `--dw-dim: ${dim}`,
    `--dw-gray: ${grayscale ? 1 : 0}`,
    `--dw-overlay: ${overlayColor}`,
    `--dw-edge: ${Math.max(0, (1 - fade) * 100)}%`,
    `perspective: ${perspective}px`,
    `perspective-origin: 50% 50%`
  ].join('; ');

  onMount(() => {
    reduced = prefersReducedMotion();
    if (typeof window !== 'undefined' && window.matchMedia) {
      mq = window.matchMedia('(prefers-reduced-motion: reduce)');
      const onChange = (e: MediaQueryListEvent) => (reduced = e.matches);
      mq.addEventListener('change', onChange);
    }

    ro = new ResizeObserver(([entry]) => {
      containerHeight = entry.contentRect.height || 600;
    });
    ro.observe(containerEl);

    rafId = requestAnimationFrame(animate);
  });

  onDestroy(() => {
    if (rafId) cancelAnimationFrame(rafId);
    rafId = null;
    lastTs = null;
    ro?.disconnect();
  });
</script>

<div
  bind:this={containerEl}
  class={`drift-wall-root ${className}`.trim()}
  style={cssVarsStyle}
  on:pointermove={handlePointerMove}
  on:pointerenter={handlePointerEnterWall}
  on:pointerleave={handlePointerLeaveWall}
  role="group"
  aria-label="Drifting wall of tiles"
>
  <div bind:this={planeEl} class="drift-wall-plane">
    {#each columnItems as col, c (c)}
      <div class="drift-wall-col">
        <div class="drift-wall-track" bind:this={trackEls[c]}>
          {#each Array(columnMeta[c]?.copies ?? 2) as _, copyIndex (copyIndex)}
            {#each col as item, itemIndex (itemIndex)}
              {@const id = `${c}-${copyIndex}-${itemIndex}`}
              {@const isActive = activeId === id}
              {#if item.href}
                <a
                  href={item.href}
                  target="_blank"
                  rel="noreferrer noopener"
                  class="drift-wall-tile"
                  class:is-active={isActive}
                  data-tile-id={id}
                  data-col={c}
                  on:focus={() => { activate(id, c); }}
                  on:blur={release}
                >
                  <span class="drift-wall-inner">
                    <img
                      src={item.image}
                      alt={item.title ?? ''}
                      loading="lazy"
                      decoding="async"
                      draggable="false"
                      class="drift-wall-img"
                    />
                    <span class="drift-wall-overlay" aria-hidden="true"></span>
                  </span>
                </a>
              {:else}
                <div
                  tabindex="0"
                  role="button"
                  aria-label={item.title ?? 'tile'}
                  class="drift-wall-tile"
                  class:is-active={isActive}
                  data-tile-id={id}
                  data-col={c}
                  on:focus={() => { activate(id, c); }}
                  on:blur={release}
                >
                  <span class="drift-wall-inner">
                    <img
                      src={item.image}
                      alt={item.title ?? ''}
                      loading="lazy"
                      decoding="async"
                      draggable="false"
                      class="drift-wall-img"
                    />
                    <span class="drift-wall-overlay" aria-hidden="true"></span>
                  </span>
                </div>
              {/if}
            {/each}
          {/each}
        </div>
      </div>
    {/each}
  </div>
</div>

<style>
  .drift-wall-root {
    position: relative;
    display: block;
    height: 100%;
    min-height: 320px;
    width: 100%;
    overflow: hidden;
  }

  .drift-wall-plane {
    position: absolute;
    z-index: 1;
    left: 50%;
    top: 50%;
    display: flex;
    cursor: pointer;
    flex-direction: row;
    transform-style: preserve-3d;
    transform-origin: 50% 50%;
    will-change: transform;
  }

  .drift-wall-col {
    position: relative;
    width: calc(var(--dw-tile-w) + var(--dw-gap));
    transform-style: preserve-3d;
  }

  .drift-wall-track {
    display: flex;
    flex-direction: column;
    transform-style: preserve-3d;
    will-change: transform;
  }

  .drift-wall-tile {
    position: relative;
    display: block;
    flex: none;
    cursor: pointer;
    outline: none;
    width: 100%;
    height: calc(var(--dw-tile-h) + var(--dw-gap));
    transform-style: preserve-3d;
    text-decoration: none;
  }

  .drift-wall-inner {
    pointer-events: none;
    position: absolute;
    inset: calc(var(--dw-gap) / 2);
    display: block;
    overflow: hidden;
    background: #0b0b12;
    border-radius: var(--dw-radius);
    opacity: var(--dw-dim);
    transform: translateZ(0);
    transition:
      transform 420ms cubic-bezier(0.22, 1, 0.36, 1),
      opacity 420ms cubic-bezier(0.22, 1, 0.36, 1),
      box-shadow 420ms cubic-bezier(0.22, 1, 0.36, 1);
  }

  .drift-wall-tile.is-active .drift-wall-inner,
  .drift-wall-tile:focus-visible .drift-wall-inner {
    opacity: 1;
    transform: translateZ(var(--dw-lift));
    box-shadow: 0 24px 60px -18px rgba(0, 0, 0, 0.7);
  }
  .drift-wall-tile:focus-visible .drift-wall-inner {
    box-shadow:
      0 24px 60px -18px rgba(0, 0, 0, 0.7),
      0 0 0 2px rgba(255, 255, 255, 0.9);
  }

  .drift-wall-img {
    display: block;
    height: 100%;
    width: 100%;
    -webkit-user-select: none;
    user-select: none;
    object-fit: cover;
    filter: grayscale(var(--dw-gray)) saturate(0.92);
    transition: filter 420ms cubic-bezier(0.22, 1, 0.36, 1);
  }

  .drift-wall-tile.is-active .drift-wall-img,
  .drift-wall-tile:focus-visible .drift-wall-img {
    filter: grayscale(0) saturate(1.05);
  }

  .drift-wall-overlay {
    pointer-events: none;
    position: absolute;
    inset: 0;
    background: var(--dw-overlay);
    opacity: 0.42;
    transition: opacity 420ms cubic-bezier(0.22, 1, 0.36, 1);
  }

  .drift-wall-tile.is-active .drift-wall-overlay,
  .drift-wall-tile:focus-visible .drift-wall-overlay {
    opacity: 0;
  }
</style>
<script lang="ts">
  import { onMount, onDestroy } from 'svelte';

  export type DriftWallItem = {
    image: string;
    title?: string;
    href?: string;
  }

  // ----- props -----
  export let items: DriftWallItem[] = Array.from({ length: 15 }, (_, i) => {
    const ids = [1015, 1025, 1039, 1043, 1044, 1050, 1062, 1069, 1074, 1080, 1084, 106, 110, 133, 164];
    return {
      image: `https://picsum.photos/id/${ids[i % ids.length]}/600/400`,
      title: `Tile ${i + 1}`,
      href: undefined
    };
  });
  export let columns = 5;
  export let tileWidth = 200;
  export let tileHeight = 132;
  export let gap = 18;
  export let radius = 14;
  export let tilt = 16;
  export let turn = -14;
  export let roll = 0;
  export let perspective = 1200;
  export let depth = 120;
  export let speed = 42;
  export let direction: 'up' | 'down' = 'up';
  export let variance = 0.45;
  export let parallax = 0.6;
  export let pauseOnHover = false;
  export let lift = 64;
  export let fade = 0.6;
  export let dim = 0.55;
  export let grayscale = false;
  export let overlayColor = '#060010';
  export let className = '';

  // ----- helpers -----
  const prefersReducedMotion = (): boolean =>
    typeof window !== 'undefined' && !!window.matchMedia &&
    window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  const columnFactor = (index: number, varianceVal: number): number => {
    const pseudo = ((index * 0.618033988 + 0.35) % 1) * 2 - 1;
    return 1 + varianceVal * pseudo;
  };


  // ----- refs -----
  let containerEl: HTMLDivElement;
  let planeEl: HTMLDivElement;
  let trackEls: (HTMLDivElement | null)[] = [];
  let rafId: number | null = null;
  let ro: ResizeObserver | null = null;
  let mq: MediaQueryList | null = null;

  // ----- mutable animation state (mirrors React refs) -----
  let offsets: number[] = [];
  let velocities: number[] = [];
  let hoveredCol = -1;
  let wallHovered = false;
  let pointer = { x: 0, y: 0 };
  let pointerDamped = { x: 0, y: 0 };
  let lastTs: number | null = null;
  let activeIdMutable: string | null = null;

  let containerHeight = 600;
  let activeId: string | null = null;
  let reduced = false;

  $: columnItems = (() => {
    const cols: DriftWallItem[][] = Array.from({ length: columns }, () => []);
    items.forEach((item, i) => cols[i % columns].push(item));
    return cols.map(col => (col.length ? col : items.slice(0, 1)));
  })();

  $: columnMeta = (() => {
    const unit = tileHeight + gap;
    return columnItems.map(col => {
      const copyHeight = Math.max(unit, col.length * unit);
      const copies = Math.max(2, Math.ceil((containerHeight * 1.6) / copyHeight) + 1);
      return { copyHeight, copies };
    });
  })();

  $: baseVelocities = (() => {
    const dirSign = direction === 'up' ? 1 : -1;
    return columnItems.map((_, c) => {
      const altSign = c % 2 === 0 ? 1 : -1;
      return speed * columnFactor(c, variance) * dirSign * altSign;
    });
  })();

  // reset offsets/velocities whenever the column layout changes
  let lastMetaKey = '';
  $: {
    const key = columnMeta.map(m => `${m.copyHeight}:${m.copies}`).join('|') + `#${columnItems.length}`;
    if (key !== lastMetaKey) {
      lastMetaKey = key;
      offsets = columnMeta.map((meta, c) => meta.copyHeight * ((c * 0.37) % 1));
      velocities = columnItems.map(() => 0);
    }
  }

  function applyPlaneTransform(px: number, py: number) {
    if (!planeEl) return;
    planeEl.style.transform =
      `translate(-50%, -50%) scale(1.18) ` +
      `rotateX(${tilt + py}deg) rotateY(${turn + px}deg) rotateZ(${roll}deg) ` +
      `translateZ(${-depth}px)`;
  }

  function animate(ts: number) {
    if (lastTs === null) lastTs = ts;
    const dt = Math.min(0.05, Math.max(0, ts - lastTs) / 1000);
    lastTs = ts;

    const maxTilt = parallax * 8;
    const targetX = pointer.x * maxTilt;
    const targetY = -pointer.y * maxTilt;
    const damp = 1 - Math.exp(-dt / 0.12);
    pointerDamped.x += (targetX - pointerDamped.x) * damp;
    pointerDamped.y += (targetY - pointerDamped.y) * damp;
    applyPlaneTransform(pointerDamped.x, pointerDamped.y);

    if (!reduced) {
      for (let c = 0; c < trackEls.length; c++) {
        const meta = columnMeta[c];
        if (!meta) continue;
        const paused = wallHovered && pauseOnHover;
        const factor = paused || hoveredCol === c ? 0 : 1;
        const target = baseVelocities[c] * factor;

        const ease = 1 - Math.exp(-dt / (target === 0 ? 0.16 : 0.28));
        velocities[c] = (velocities[c] ?? 0) + (target - (velocities[c] ?? 0)) * ease;
        let next = (offsets[c] ?? 0) + velocities[c] * dt;
        next = ((next % meta.copyHeight) + meta.copyHeight) % meta.copyHeight;
        offsets[c] = next;

        const el = trackEls[c];
        if (el) el.style.transform = `translate3d(0, ${-next}px, 0)`;
      }
    } else {
      for (let c = 0; c < trackEls.length; c++) {
        const el = trackEls[c];
        const meta = columnMeta[c];
        if (el && meta) el.style.transform = `translate3d(0, ${-(offsets[c] ?? 0)}px, 0)`;
      }
    }

    rafId = requestAnimationFrame(animate);
  }

  function activate(id: string, index: number) {
    activeIdMutable = id;
    hoveredCol = index;
    activeId = id;
  }

  function release() {
    activeIdMutable = null;
    hoveredCol = -1;
    activeId = null;
  }

  function handlePointerMove(e: PointerEvent) {
    const rect = containerEl?.getBoundingClientRect();
    if (!rect) return;
    if (parallax > 0 && !reduced) {
      pointer = {
        x: (e.clientX - rect.left) / rect.width - 0.5,
        y: (e.clientY - rect.top) / rect.height - 0.5
      };
    }
    const hit = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
    const tile = hit?.closest?.('[data-tile-id]') as HTMLElement | null;
    if (!tile) return;
    const id = tile.dataset.tileId ?? null;
    if (id === activeIdMutable) return;
    activeIdMutable = id;
    hoveredCol = Number(tile.dataset.col);
    activeId = id;
  }

  function handlePointerEnterWall() {
    wallHovered = true;
  }

  function handlePointerLeaveWall() {
    wallHovered = false;
    pointer = { x: 0, y: 0 };
    release();
  }

  $: cssVarsStyle = [
    `--dw-tile-w: ${tileWidth}px`,
    `--dw-tile-h: ${tileHeight}px`,
    `--dw-gap: ${gap}px`,
    `--dw-radius: ${radius}px`,
    `--dw-lift: ${lift}px`,
    `--dw-dim: ${dim}`,
    `--dw-gray: ${grayscale ? 1 : 0}`,
    `--dw-overlay: ${overlayColor}`,
    `--dw-edge: ${Math.max(0, (1 - fade) * 100)}%`,
    `perspective: ${perspective}px`,
    `perspective-origin: 50% 50%`
  ].join('; ');

  onMount(() => {
    reduced = prefersReducedMotion();
    if (typeof window !== 'undefined' && window.matchMedia) {
      mq = window.matchMedia('(prefers-reduced-motion: reduce)');
      const onChange = (e: MediaQueryListEvent) => (reduced = e.matches);
      mq.addEventListener('change', onChange);
    }

    ro = new ResizeObserver(([entry]) => {
      containerHeight = entry.contentRect.height || 600;
    });
    ro.observe(containerEl);

    rafId = requestAnimationFrame(animate);
  });

  onDestroy(() => {
    if (rafId) cancelAnimationFrame(rafId);
    rafId = null;
    lastTs = null;
    ro?.disconnect();
  });
</script>

<div
  bind:this={containerEl}
  class={`drift-wall-root ${className}`.trim()}
  style={cssVarsStyle}
  on:pointermove={handlePointerMove}
  on:pointerenter={handlePointerEnterWall}
  on:pointerleave={handlePointerLeaveWall}
  role="group"
  aria-label="Drifting wall of tiles"
>
  <div bind:this={planeEl} class="drift-wall-plane">
    {#each columnItems as col, c (c)}
      <div class="drift-wall-col">
        <div class="drift-wall-track" bind:this={trackEls[c]}>
          {#each Array(columnMeta[c]?.copies ?? 2) as _, copyIndex (copyIndex)}
            {#each col as item, itemIndex (itemIndex)}
              {@const id = `${c}-${copyIndex}-${itemIndex}`}
              {@const isActive = activeId === id}
              {#if item.href}
                <a
                  href={item.href}
                  target="_blank"
                  rel="noreferrer noopener"
                  class="drift-wall-tile"
                  class:is-active={isActive}
                  data-tile-id={id}
                  data-col={c}
                  on:focus={() => { activate(id, c); }}
                  on:blur={release}
                >
                  <span class="drift-wall-inner">
                    <img
                      src={item.image}
                      alt={item.title ?? ''}
                      loading="lazy"
                      decoding="async"
                      draggable="false"
                      class="drift-wall-img"
                    />
                    <span class="drift-wall-overlay" aria-hidden="true"></span>
                  </span>
                </a>
              {:else}
                <div
                  tabindex="0"
                  role="button"
                  aria-label={item.title ?? 'tile'}
                  class="drift-wall-tile"
                  class:is-active={isActive}
                  data-tile-id={id}
                  data-col={c}
                  on:focus={() => { activate(id, c); }}
                  on:blur={release}
                >
                  <span class="drift-wall-inner">
                    <img
                      src={item.image}
                      alt={item.title ?? ''}
                      loading="lazy"
                      decoding="async"
                      draggable="false"
                      class="drift-wall-img"
                    />
                    <span class="drift-wall-overlay" aria-hidden="true"></span>
                  </span>
                </div>
              {/if}
            {/each}
          {/each}
        </div>
      </div>
    {/each}
  </div>
</div>

<style>
  .drift-wall-root {
    position: relative;
    display: block;
    height: 100%;
    min-height: 320px;
    width: 100%;
    overflow: hidden;
  }

  .drift-wall-plane {
    position: absolute;
    z-index: 1;
    left: 50%;
    top: 50%;
    display: flex;
    cursor: pointer;
    flex-direction: row;
    transform-style: preserve-3d;
    transform-origin: 50% 50%;
    will-change: transform;
  }

  .drift-wall-col {
    position: relative;
    width: calc(var(--dw-tile-w) + var(--dw-gap));
    transform-style: preserve-3d;
  }

  .drift-wall-track {
    display: flex;
    flex-direction: column;
    transform-style: preserve-3d;
    will-change: transform;
  }

  .drift-wall-tile {
    position: relative;
    display: block;
    flex: none;
    cursor: pointer;
    outline: none;
    width: 100%;
    height: calc(var(--dw-tile-h) + var(--dw-gap));
    transform-style: preserve-3d;
    text-decoration: none;
  }

  .drift-wall-inner {
    pointer-events: none;
    position: absolute;
    inset: calc(var(--dw-gap) / 2);
    display: block;
    overflow: hidden;
    background: #0b0b12;
    border-radius: var(--dw-radius);
    opacity: var(--dw-dim);
    transform: translateZ(0);
    transition:
      transform 420ms cubic-bezier(0.22, 1, 0.36, 1),
      opacity 420ms cubic-bezier(0.22, 1, 0.36, 1),
      box-shadow 420ms cubic-bezier(0.22, 1, 0.36, 1);
  }

  .drift-wall-tile.is-active .drift-wall-inner,
  .drift-wall-tile:focus-visible .drift-wall-inner {
    opacity: 1;
    transform: translateZ(var(--dw-lift));
    box-shadow: 0 24px 60px -18px rgba(0, 0, 0, 0.7);
  }
  .drift-wall-tile:focus-visible .drift-wall-inner {
    box-shadow:
      0 24px 60px -18px rgba(0, 0, 0, 0.7),
      0 0 0 2px rgba(255, 255, 255, 0.9);
  }

  .drift-wall-img {
    display: block;
    height: 100%;
    width: 100%;
    -webkit-user-select: none;
    user-select: none;
    object-fit: cover;
    filter: grayscale(var(--dw-gray)) saturate(0.92);
    transition: filter 420ms cubic-bezier(0.22, 1, 0.36, 1);
  }

  .drift-wall-tile.is-active .drift-wall-img,
  .drift-wall-tile:focus-visible .drift-wall-img {
    filter: grayscale(0) saturate(1.05);
  }

  .drift-wall-overlay {
    pointer-events: none;
    position: absolute;
    inset: 0;
    background: var(--dw-overlay);
    opacity: 0.42;
    transition: opacity 420ms cubic-bezier(0.22, 1, 0.36, 1);
  }

  .drift-wall-tile.is-active .drift-wall-overlay,
  .drift-wall-tile:focus-visible .drift-wall-overlay {
    opacity: 0;
  }
</style>

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

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

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

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

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

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

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

С ссылками

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

<div class="h-[500px] w-full">
	<DriftWall items={items} columns={4} />
</div>
<script>
	const items = [
		{ image: '/photo1.jpg', title: 'Документация', href: '/docs' },
		{ image: '/photo2.jpg', title: 'Статьи', href: '/article' },
		{ image: '/photo3.jpg', title: 'Компоненты', href: '/components' }
	];
</script>

<div class="h-[500px] w-full">
	<DriftWall items={items} columns={4} />
</div>

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

<DriftWall
	items={items}
	columns={6}
	tileWidth={180}
	tileHeight={120}
	gap={12}
	speed={60}
	direction="down"
	tilt={20}
	turn={-10}
	fade={0.5}
	dim={0.5}
	grayscale={false}
	pauseOnHover={true}
/>
<DriftWall
	items={items}
	columns={6}
	tileWidth={180}
	tileHeight={120}
	gap={12}
	speed={60}
	direction="down"
	tilt={20}
	turn={-10}
	fade={0.5}
	dim={0.5}
	grayscale={false}
	pauseOnHover={true}
/>

Пропсы

Пропс Тип По умолчанию Описание
items DriftWallItem[] [...] Массив плиток
columns number 5 Количество колонок
tileWidth number 200 Ширина плитки (px)
tileHeight number 132 Высота плитки (px)
gap number 18 Отступ между плитками (px)
radius number 14 Скругление углов (px)
tilt number 16 Наклон плоскости по X (град)
turn number -14 Поворот плоскости по Y (град)
roll number 0 Поворот плоскости по Z (град)
perspective number 1200 Перспектива 3D-сцены (px)
depth number 120 Смещение плоскости по Z (px)
speed number 42 Базовая скорость дрейфа (px/с)
direction 'up' 'down' 'up' Направление потока
variance number 0.45 Разброс скоростей колонок
parallax number 0.6 Сила наклона за указателем
pauseOnHover boolean false Пауза дрейфа при наведении
lift number 64 Приподъём активной плитки по Z (px)
fade number 0.6 Маска затухания краёв (0–1)
dim number 0.55 Затемнение неактивных плиток (0–1)
grayscale boolean false Обесцвечивание неактивных плиток
overlayColor string '#060010' Цвет слоя затемнения
className string '' Дополнительные классы

DriftWallItem

interface DriftWallItem {
	image: string;
	title?: string;
	href?: string;
}
interface DriftWallItem {
	image: string;
	title?: string;
	href?: string;
}

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

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

  • Дрейф колонок — бесконечная прокрутка с разной скоростью и направлением
  • Параллакс — плоскость наклоняется вслед за указателем мыши
  • Наведение — плитка приподнимается, подсвечивается и показывает оригинальный цвет
  • pauseOnHover — останавливает дрейф колонки под указателем
  • Клавиатура — плитки доступны для фокуса через Tab
  • Touch — работает на сенсорных устройствах

Доступность

  • Контейнер имеет роль group с aria-label
  • Плитки-ссылки используют семантический тег <a>
  • Плитки без ссылки получают role="button" и tabindex="0"
  • Поддержка prefers-reduced-motion — анимация дрейфа отключается
  • Изображения используют title как alt
  • Видимый focus-visible с подсветкой контура