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

Photo Fan

Веер снимков, как карты в руке: в покое стопка почти сложена, под курсором раскрывается и доворачивается за рукой. Вся геометрия — одна CSS-строка (номер карты × раскрытие + доворот) вокруг нижней рукояти; JS пишет на контейнер два числа по курсорному шаблону — lerp, сон в покое. На таче веер стоит полураскрытым.

csshovermouse-moveintermediatereduced-motion ✓
↗
🎛 Настройки — крути и копируй

Пропсы

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

ПропТипПо умолчаниюЧто это
spreadDeg?number14Размах веера в градусах на крайнюю карту.
followDeg?number9Доворот всего веера за курсором, градусы.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/photo-fan.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 { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
const css = `/* база (и тач-экран, где JS не заводится вовсе): веер полураскрыт — --pf-open 0.55 */
.pfn {
  --pf-open: 0.55;
  --pf-turn: 0;
  position: relative;
  width: min(420px, 88cqw);
  aspect-ratio: 5 / 4;
  /* рукоять веера — нижний центр: там сходятся все карты */
}

.pfn-card {
  position: absolute;
  left: 50%;
  bottom: 0;
  width: 44%;
  aspect-ratio: 3 / 4;
  margin-left: -22%;
  border-radius: 12px;
  object-fit: cover;
  user-select: none;
  border: 3px solid var(--ds-surface, oklch(0.95 0.01 265));
  box-shadow: 0 10px 26px oklch(0 0 0 / 0.3);
  /* вся геометрия веера — одна строка: номер карты × раскрытие + доворот за курсором.
     origin в нижнем центре ОБЩЕЙ рукояти, чуть ниже кромки — карты расходятся веером,
     а не крутятся на месте */
  transform-origin: 50% 130%;
  rotate: calc(
    var(--pf-i) * var(--pf-spread) * var(--pf-open) +
    var(--pf-turn) * var(--pf-follow)
  );
}
`;

export interface PhotoFanProps {
  /** Снимки, 3–7; средний — лицо веера. */
  items?: string[];
  /** Размах веера в градусах на крайнюю карту. */
  spreadDeg?: number;
  /** Доворот всего веера за курсором, градусы. */
  followDeg?: number;
}

const ITEMS = [
  "https://motiva.pages.dev/img/1015-600x750.jpg",
  "https://motiva.pages.dev/img/1016-600x750.jpg",
  "https://motiva.pages.dev/img/1019-600x750.jpg",
  "https://motiva.pages.dev/img/1021-600x750.jpg",
  "https://motiva.pages.dev/img/1024-600x750.jpg",
];

/**
 * Веер снимков, как карты в руке: в покое стопка почти сложена, под курсором
 * раскрывается, и весь веер доворачивается за рукой.
 *
 * Вся геометрия — в CSS: карта `i` повёрнута на
 * `(i × spread + сдвиг) × размах` вокруг НИЖНЕГО центра общей рукояти, JS пишет
 * на контейнер ровно два числа (`--pf-open`, `--pf-turn`) по курсорному шаблону:
 * обработчик запоминает координату и будит цикл, значения догоняют lerp'ом,
 * цикл засыпает, когда догнал и когда курсор ушёл.
 *
 * На тач-экране «наведения» нет — веер стоит полураскрытым (это и база без JS);
 * `prefers-reduced-motion` и `data-shot` раскрытие не анимируют.
 */
export function PhotoFan({ items = ITEMS, spreadDeg = 14, followDeg = 9 }: PhotoFanProps = {}) {
  const host = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = host.current;
    if (!el) return;
    if (!window.matchMedia("(hover: hover) and (pointer: fine)").matches) return;
    const frozen =
      document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let raf = 0;
    let open = 0.55;
    let turn = 0;
    let tOpen = 0.55;
    let tTurn = 0;

    const apply = () => {
      el.style.setProperty("--pf-open", open.toFixed(3));
      el.style.setProperty("--pf-turn", turn.toFixed(3));
    };
    const loop = () => {
      open += (tOpen - open) * 0.12;
      turn += (tTurn - turn) * 0.12;
      apply();
      if (Math.abs(tOpen - open) > 0.002 || Math.abs(tTurn - turn) > 0.002) {
        raf = requestAnimationFrame(loop);
      } else {
        raf = 0;
      }
    };
    const wake = () => {
      if (raf) return;
      if (frozen) {
        open = tOpen;
        turn = tTurn;
        apply();
        return;
      }
      raf = requestAnimationFrame(loop);
    };

    const onMove = (e: PointerEvent) => {
      const r = el.getBoundingClientRect();
      tTurn = ((e.clientX - r.left) / r.width) * 2 - 1;
      tOpen = 1;
      wake();
    };
    const onLeave = () => {
      tOpen = 0.55;
      tTurn = 0;
      wake();
    };
    el.addEventListener("pointermove", onMove);
    el.addEventListener("pointerleave", onLeave);
    return () => {
      cancelAnimationFrame(raf);
      el.removeEventListener("pointermove", onMove);
      el.removeEventListener("pointerleave", onLeave);
    };
  }, []);

  const mid = (items.length - 1) / 2;
  return (
    <div
      ref={host}
      className="pfn"
      style={
        {
          "--pf-spread": `${spreadDeg}deg`,
          "--pf-follow": `${followDeg}deg`,
        } as CSSProperties
      }
    >
      <style href="motiva-photo-fan" precedence="medium">{css}</style>
      {items.map((src, i) => (
        <img
          key={src}
          className="pfn-card"
          src={src}
          alt=""
          loading="lazy"
          draggable={false}
          style={{ "--pf-i": i - mid, zIndex: i } as CSSProperties}
        />
      ))}
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — madewithgsap.com/effects (позиция 114, превью сетки; платная платформа, исходники не открывались)v1.0.0