Photo Fan
Веер снимков, как карты в руке: в покое стопка почти сложена, под курсором раскрывается и доворачивается за рукой. Вся геометрия — одна CSS-строка (номер карты × раскрытие + доворот) вокруг нижней рукояти; JS пишет на контейнер два числа по курсорному шаблону — lerp, сон в покое. На таче веер стоит полураскрытым.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| spreadDeg? | number | 14 | Размах веера в градусах на крайнюю карту. |
| followDeg? | number | 9 | Доворот всего веера за курсором, градусы. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/photo-fan.jsonЧто нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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>
);
}