From bb8da6dd18b50569f6c3132bf98489b81497763a Mon Sep 17 00:00:00 2001 From: Mikhail Lukin Date: Wed, 27 May 2026 14:16:12 +0700 Subject: [PATCH 1/2] feat(button): add motion --- .storybook/scope.ts | 32 + packages/button/package.json | 1 + .../src/components/base-button/Component.tsx | 19 +- packages/button/src/docs/description.mdx | 737 ++++++++++-------- packages/button/src/typings.ts | 7 + yarn.lock | 60 ++ 6 files changed, 525 insertions(+), 331 deletions(-) diff --git a/.storybook/scope.ts b/.storybook/scope.ts index d19bf702d9..14a98b6b71 100644 --- a/.storybook/scope.ts +++ b/.storybook/scope.ts @@ -2,6 +2,23 @@ import { ComponentType } from 'react'; import * as dateUtils from 'date-fns'; import * as knobs from '@storybook/addon-knobs'; import * as grid from './blocks/grid'; +import { + motion, + AnimatePresence, + LayoutGroup, + LazyMotion, + MotionConfig, + Reorder, + domMax, + m, + useAnimation, + useMotionValue, + useMotionValueEvent, + useSpring, + useTransform, + useReducedMotion, + useInView, +} from 'motion/react'; const coreComponentsContext = process.env.BUILD_STORYBOOK_FROM_DIST === 'true' @@ -41,4 +58,19 @@ export default { ...grid, ...dateUtils, ...knobs, + motion, + AnimatePresence, + LayoutGroup, + LazyMotion, + MotionConfig, + Reorder, + domMax, + m, + useAnimation, + useMotionValue, + useMotionValueEvent, + useSpring, + useTransform, + useReducedMotion, + useInView, }; diff --git a/packages/button/package.json b/packages/button/package.json index cce6e58f62..ae6115aa86 100644 --- a/packages/button/package.json +++ b/packages/button/package.json @@ -18,6 +18,7 @@ "@alfalab/core-components-spinner": "^6.0.4", "@alfalab/hooks": "^1.13.1", "classnames": "^2.5.1", + "motion": "^12.40.0", "react-merge-refs": "^1.1.0", "tslib": "^2.4.0" }, diff --git a/packages/button/src/components/base-button/Component.tsx b/packages/button/src/components/base-button/Component.tsx index 7aaffb248f..adce013fd6 100644 --- a/packages/button/src/components/base-button/Component.tsx +++ b/packages/button/src/components/base-button/Component.tsx @@ -4,11 +4,13 @@ import React, { type ButtonHTMLAttributes, forwardRef, useEffect, + useMemo, useRef, useState, } from 'react'; import mergeRefs from 'react-merge-refs'; import cn from 'classnames'; +import { motion } from 'motion/react'; import { getDataTestId } from '@alfalab/core-components-shared'; import { Spinner } from '@alfalab/core-components-spinner'; @@ -53,12 +55,19 @@ export const BaseButton = forwardRef< onClick, styles = {}, colorStylesMap = { default: {}, inverted: {} }, + motionProps, ...restProps }, ref, ) => { const buttonRef = useRef(null); + const MotionComponent = useMemo( + () => motion.create(Component as Parameters[0]), + [Component], + ); + const Tag = motionProps ? MotionComponent : Component; + const [focused] = useFocus(buttonRef, 'keyboard'); const [loaderTimePassed, setLoaderTimePassed] = useState(true); @@ -188,31 +197,33 @@ export const BaseButton = forwardRef< const hrefProps = { [typeof Component === 'string' ? 'href' : 'to']: href }; return ( - )} {...hrefProps} + {...(motionProps as object)} onClick={handleClick} disabled={disabled || showLoader} ref={mergeRefs([buttonRef, ref])} > {buttonChildren} - + ); } return ( - {buttonChildren} - + ); }, ); diff --git a/packages/button/src/docs/description.mdx b/packages/button/src/docs/description.mdx index bce8464879..e906fd7c3a 100644 --- a/packages/button/src/docs/description.mdx +++ b/packages/button/src/docs/description.mdx @@ -1,426 +1,509 @@ -## Виды кнопок +## Анимация (motion) -```jsx live mobileHeight={500} -render(() => { - const [disabled, setDisabled] = React.useState(false); +С помощью пропа `motionProps` можно передать любые анимационные свойства библиотеки [motion](https://motion.dev). +Кнопка автоматически переключается на `motion`-компонент при наличии `motionProps`. - return ( - <> - - - Accent - - - Primary - - - Secondary - - - Outlined - - - Transparent - - - Text - - +### 1. Scale при нажатии - +```jsx live + + Нажми меня + +``` - setDisabled((prevState) => !prevState)} - label='Недоступна' - /> - - ); -}); -//MOBILE -render(() => { - const [disabled, setDisabled] = React.useState(false); +### 2. Scale при наведении - return ( - <> - - Accent - - - - Primary - - - - Secondary - - - - Outlined - - - - Transparent - - - - Text - - - setDisabled((prevState) => !prevState)} - label='Недоступна' - /> - - ); -}); +```jsx live + + Наведи курсор + ``` -## Размеры +### 3. Bounce-эффект при нажатии -Кнопка доступна в размерах 72, 64, 56, 48, 40, 32. +```jsx live + + Bounce + +``` -```jsx live mobileHeight={460} -const BIG_SIZES = [72, 64, 56]; -const SMALL_SIZES = [48, 40, 32]; +### 4. Rotation при наведении -render( - <> - - {BIG_SIZES.map((size) => ( - - {`${size}px`} - - ))} - - +```jsx live + + Hover rotate + +``` + +### 5. Анимация появления при монтировании + +```jsx live +render(() => { + const [visible, setVisible] = React.useState(false); + + return ( - {SMALL_SIZES.map((size) => ( - - {`${size}px`} - - ))} + setVisible((v) => !v)}> + {visible ? 'Скрыть' : 'Показать'} + + + + {visible && ( + + Я появился + + )} + - , -); -//MOBILE -const SIZES = [72, 64, 56, 48, 40, 32]; - -render( - - {SIZES.map((size, idx) => ( - - - {`${size}px`} - - {SIZES.length - 1 !== idx && } - - ))} - , -); + ); +}); ``` -## Форма - -Для кнопки доступно два варианта скругления углов. +### 6. Shake-анимация при ошибке ```jsx live render(() => { - const [shape, setShape] = React.useState('rectangular'); + const controls = useAnimation(); + + const handleClick = () => { + controls.start({ + x: [0, -8, 8, -8, 8, -4, 4, 0], + transition: { duration: 0.45 }, + }); + }; return ( - <> - - - - - setShape(value)} - breakpoint={BREAKPOINT} - > - - - - + + Нажми — ошибка + ); }); ``` -## Ширина - -Кнопка адаптируется под длину контента. Для каждого вертикального размера кнопки задан минимальный горизонтальный размер. -С помощью свойства `block` можно заставить кнопку занимать всю ширину контейнера. -Через доступ по classname можно задать кнопке ширину в рх. +### 7. Pulse-анимация для привлечения внимания ```jsx live -
- - - - - - - - -
+ + Обрати внимание + ``` -## Анатомия - -С помощью слотов `leftAddons` и `rightAddons` можно кастомизировать кнопку. Например, добавить иконку. -Переданный контент будет отрисован слева или справа от текста кнопки. Если текста нет — будет отрисована квадратная кнопка. -В 56, 64 и 72 размерах доступна подпись под лейблом. +### 8. Fade + slide при появлении группы кнопок ```jsx live render(() => { - const [label, setLabel] = React.useState(true); - const [hint, setHint] = React.useState(); - const [leftAddons, setLeftAddons] = React.useState(false); - const [rightAddons, setRightAddons] = React.useState(false); + const [show, setShow] = React.useState(false); - const handleLabelChange = () => setLabel(!label); - const handleHintChange = () => setHint((p) => (p ? undefined : 'Hint')); - const handleLeftAddonsChange = () => setLeftAddons(!leftAddons); - const handleRightAddonsChange = () => setRightAddons(!rightAddons); + const items = ['Сохранить', 'Отменить', 'Удалить']; return (
- - - + setShow((v) => !v)}> + {show ? 'Скрыть' : 'Показать действия'} + - - - - - - + + + {show && ( + + {items.map((label, i) => ( + + {label} + + ))} + + )} +
); }); ``` -## Поведение лейбла - -С помощью свойства `textResizing` можно сжать или растянуть текстовый контент внутри кнопки. +### 9. Перетаскиваемая кнопка ```jsx live render(() => { - const [textResizing, setTextResizing] = React.useState('hug'); + const constraintsRef = React.useRef(null); return ( -
- - - - - setTextResizing(value)} - breakpoint={BREAKPOINT} +
+ - - - + Перетащи меня +
); }); ``` -## Перенос текста внутри кнопки - -С помощью свойства `nowrap` можно запретить перенос текста на новую строку. +### 10. Переход между состояниями через animate ```jsx live render(() => { - const [checked, setChecked] = React.useState(true); - - const handleChange = () => setChecked(!checked); + const [active, setActive] = React.useState(false); return ( - -
- - Пример длинного текста - -
- - Запретить перенос строки} - checked={checked} - onChange={handleChange} - /> - -
+ setActive((v) => !v)} + > + {active ? 'Активно' : 'Неактивно'} + ); }); -//MOBILE -render(() => { - const [checked, setChecked] = React.useState(true); +``` - const handleChange = () => setChecked(!checked); +### 11. MotionConfig — глобальная конфигурация анимаций + +`MotionConfig` применяет общий `transition` ко всем дочерним motion-компонентам. Переключи режим — и все кнопки разом изменят скорость анимации. + +```jsx live +render(() => { + const [slow, setSlow] = React.useState(false); return ( - -
- - Пример длинного текста - -
- - Запретить перенос строки} - checked={checked} - onChange={handleChange} - /> - -
+ + + {['accent', 'primary', 'secondary'].map((view) => ( + + {view} + + ))} + + + setSlow((v) => !v)} + label='Медленный режим (1.5s)' + /> + ); }); ``` -## Размытие фона +### 12. LayoutGroup — скоординированные layout-анимации -Для кнопок можно включить размытие фона, если она полупрозрачная, располагается поверх динамического контента или изображения. +`LayoutGroup` синхронизирует анимации макета между независимыми компонентами. Точка-индикатор плавно перемещается между кнопками благодаря общему `layoutId`. -```jsx live expanded +```jsx live render(() => { - const [checked, setChecked] = React.useState(true); - const handleChange = () => setChecked(!checked); - - const wrapper = { - position: 'relative', - borderRadius: '16px', - width: '330px', - height: '100px', - }; + const [selected, setSelected] = React.useState(0); + const items = ['Accent', 'Primary', 'Secondary']; - const image = { - width: '100%', - height: '100%', - borderRadius: 'inherit', - objectFit: 'cover', - }; + return ( + + + {items.map((label, i) => ( +
+ setSelected(i)} + > + {label} + + {i === selected && ( + + )} +
+ ))} +
+
+ ); +}); +``` - const wrapperButton = { - position: 'absolute', - display: 'flex', - inset: '0px', - padding: '20px', - justifyContent: 'space-between', - }; +### 13. LazyMotion — оптимизированная загрузка + +`LazyMotion` + `m` уменьшают bundle, загружая возможности анимаций по требованию. `m` — это облегчённый аналог `motion` для использования внутри `LazyMotion`. + +```jsx live +render(() => { + const [visible, setVisible] = React.useState(false); return ( - <> -
- -
- - -
-
- - - - - + Загружено лениво + + )} + + ); }); ``` -## Обработка событий +### 14. Reorder — перетаскивание для сортировки -С помощью свойства `loading` можно отобразить состояние загрузки. -Минимальное время отображения лоадера — 500мс, чтобы при быстрых ответах от сервера кнопка не «моргала». +`Reorder.Group` + `Reorder.Item` позволяют пользователю менять порядок элементов drag-and-drop. -```jsx live expanded +```jsx live render(() => { - const [loading, setLoading] = React.useState(false); - const [loadTimeout, setLoadTimeout] = React.useState('30'); - const timeoutId = React.useRef(); + const [items, setItems] = React.useState([ + { id: 'save', label: 'Сохранить', view: 'accent' }, + { id: 'cancel', label: 'Отменить', view: 'secondary' }, + { id: 'delete', label: 'Удалить', view: 'outlined' }, + ]); - const handleClick = () => { - setLoading(true); + return ( + + {items.map((item) => ( + + + {item.label} + + + ))} + + ); +}); +``` + +### 15. useMotionValue + useTransform — tilt при движении мышью - clearTimeout(timeoutId.current); +`useMotionValue` хранит значение вне React-рендера. `useTransform` пересчитывает одно значение в другое без перерисовки компонента. Здесь координаты курсора внутри кнопки трансформируются в `rotateX` / `rotateY`. - timeoutId.current = setTimeout(() => { - setLoading(false); - }, Number(loadTimeout)); +```jsx live +render(() => { + const ref = React.useRef(null); + const x = useMotionValue(0); + const y = useMotionValue(0); + const rotateX = useTransform(y, [-30, 30], [12, -12]); + const rotateY = useTransform(x, [-60, 60], [-12, 12]); + + const handleMouseMove = (e) => { + const rect = ref.current.getBoundingClientRect(); + x.set(e.clientX - rect.left - rect.width / 2); + y.set(e.clientY - rect.top - rect.height / 2); }; - const handleTimeoutChange = (_, { value }) => { - clearTimeout(timeoutId.current); - setLoading(false); - setLoadTimeout(value); + const handleMouseLeave = () => { + x.set(0); + y.set(0); }; return ( - <> - + Наведи и двигай курсор + +
+ ); +}); +``` + +### 16. useSpring — пружинное значение + +`useSpring` создаёт MotionValue, которое физически «догоняет» целевое значение. В отличие от `transition: spring`, оно работает непрерывно и не привязано к React-рендеру. Здесь счётчик плавно досчитывает до нового числа. + +```jsx live +render(() => { + const [target, setTarget] = React.useState(0); + const spring = useSpring(0, { stiffness: 80, damping: 18 }); + const display = useTransform(spring, (v) => Math.round(v)); + const [displayed, setDisplayed] = React.useState(0); - + useMotionValueEvent(display, 'change', setDisplayed); + + const randomize = () => { + const next = Math.floor(Math.random() * 10000); + setTarget(next); + spring.set(next); + }; - - - - - + return ( + + + Случайное число + + + {displayed} + + ); }); ``` -## Другие кнопки +--- + +## Motion+ — платные возможности + +Перечисленные компоненты доступны только в пакете `motion-plus` (единоразовая покупка, пожизненные обновления). +Они импортируются из `motion-plus/react` и **не входят** в свободно распространяемые `motion` / `framer-motion`. + +### AnimateNumber +Анимирует числовые значения при их изменении — для счётчиков, цен, таймеров. +Поддерживает `Intl.NumberFormat` (валюты, компактная нотация), кастомные transitions и отдельные CSS-классы для каждой части числа (префикс, целая часть, дробная часть, суффикс). + +### ScrambleText +Перемешивает символы текста перед тем, как показать финальное значение — эффект «матрицы». +Управляется пропом `active`, поддерживает `duration`, `delay` (в том числе `stagger()`), кастомный набор символов (`chars`). + +### Typewriter +Имитирует набор текста с человекоподобной скоростью и ритмом. +Умеет делать backspace до первого отличающегося символа при смене контента, поддерживает `speed`, `variance`, режимы удаления `"character" | "word" | "all"`, ARIA-атрибуты для доступности. + +### Ticker +Бесконечная прокрутка (marquee) по горизонтали или вертикали. +Принимает любые React-узлы через `items`, скорость задаётся через `velocity` (px/s, отрицательное значение — обратное направление), поддерживает `axis`, RTL и reduced-motion. + +### Carousel +Полнофункциональная карусель с инерцией, клавиатурной навигацией, snap-прокруткой и бесконечным циклом. +Хук `useCarousel` внутри дочерних компонентов даёт доступ к `nextPage()`, `prevPage()`, `gotoPage(n)`, `currentPage`, `totalPages`. Поддерживает `axis`, `gap`, `loop`, `itemSize: "fill"`, RTL, ARIA. + +--- + +## Почему motion — плохой выбор для компонентной библиотеки + +### ✅ Вес + +Несмотря на tree-shaking, `motion/react` тянет за собой весь animation runtime — даже если компонент использует только `whileTap`. Минимизировать это через `LazyMotion + m` можно, но это требует изменения архитектуры каждого компонента. + +### ✅ Слишком большая зависимость + +`motion` v12 — это фактически alias над `framer-motion`, который тянет цепочку: `framer-motion → motion-dom → motion-utils`. Сам `framer-motion` на диске занимает **5.6 MB**, `motion` — ещё 728 KB сверху. Это сторонняя зависимость от коммерческой компании **Framer Inc.**, история которой показывает смены API и ребрендинги (`popmotion → framer-motion → motion`). Любой breaking change в мажорной версии придётся синхронно обрабатывать во всех 131 пакете монорепозитория. + +### ✅ Сложность интеграции в сложные компоненты: хуже, чем кажется + +Кнопка — простейший случай: один DOM-элемент, нет оверлеев, нет порталов. Даже здесь интеграция потребовала изменений в типизации, `useMemo` для `motion.create`, замены `Component` на `Tag` в двух ветках рендера, и нового пропа в публичном API. + +Для реальных составных компонентов проблемы умножаются: + +- **`SidePanel` / `BottomSheet`**: анимация должна охватывать и оверлей, и само полотно, которые рендерятся в портале. `AnimatePresence` должен оборачивать оба, но портал разрывает дерево — координировать exit-анимации становится нетривиально. +- **`Select` / `Dropdown`**: список появляется в портале с абсолютным позиционированием. Layout-анимации (`LayoutGroup`) не работают через границы порталов. +- **`Collapse` / аккордеон**: анимация высоты через `animate: { height: 'auto' }` — известная больная точка framer-motion, требующая `layout`-анимаций или специальных обходных решений. +- **Все компоненты с `ref`**: `motion.create()` создаёт новый тип компонента. Если пропа `Component` меняется между рендерами, React **полностью размонтирует** поддерево — это потенциальный источник трудновоспроизводимых багов. + +### ➕ Проблемы с SSR + +Библиотека поддерживает серверный рендеринг через проп `client` у адаптивных компонентов. `framer-motion` имеет известные проблемы с SSR в контексте layout-анимаций и `AnimatePresence`: первый рендер на сервере не совпадает с клиентским, что вызывает hydration mismatch. Это требует дополнительной настройки (`LazyMotion` с `domMax`, явный `initial={false}`) в каждом компоненте. -Если нужна кнопка с одной иконкой, но без подложки, используйте [IconButton](/docs/iconbutton--docs). +### ➕ Конфликт с нативными CSS-анимациями -Если нужна кнопка с другим цветом фона, используйте [CustomButton](/docs/custombutton--docs). +В библиотеке уже есть CSS transitions (`:hover`, `:active`, `loading`-состояние). motion управляет стилями напрямую через `style`, перезаписывая инлайн-значения. При одновременном использовании CSS transitions и motion-анимаций порядок применения становится непредсказуемым — особенно в темизированных компонентах, где цвета задаются через CSS-переменные. -Если нужна кнопка с выпадающим списком, используйте [PickerButton](/docs/pickerbutton--docs). +--- diff --git a/packages/button/src/typings.ts b/packages/button/src/typings.ts index bc55f9567a..4fa2c517e9 100644 --- a/packages/button/src/typings.ts +++ b/packages/button/src/typings.ts @@ -4,6 +4,7 @@ import { type ElementType, type ReactNode, } from 'react'; +import { type MotionProps } from 'motion/react'; export type StyleColors = { default: { @@ -113,6 +114,12 @@ type ComponentProps = { * Дочерние элементы. */ children?: ReactNode; + + /** + * Пропсы для анимации через библиотеку motion (framer-motion). + * Позволяют добавить анимации при hover, tap, focus и другие. + */ + motionProps?: MotionProps; }; export type PrivateButtonProps = { diff --git a/yarn.lock b/yarn.lock index 8e3f075d14..8813fe974d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -246,6 +246,7 @@ __metadata: "@alfalab/core-components-spinner": "npm:^6.0.4" "@alfalab/hooks": "npm:^1.13.1" classnames: "npm:^2.5.1" + motion: "npm:^12.40.0" react-merge-refs: "npm:^1.1.0" tslib: "npm:^2.4.0" peerDependencies: @@ -19395,6 +19396,28 @@ __metadata: languageName: node linkType: hard +"framer-motion@npm:^12.40.0": + version: 12.40.0 + resolution: "framer-motion@npm:12.40.0" + dependencies: + motion-dom: "npm:^12.40.0" + motion-utils: "npm:^12.39.0" + tslib: "npm:^2.4.0" + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + checksum: 10c0/a1d26908d6661028fcdba0cf200fca18927a4d4eae0b1e64c37dfb7fdea9da66a8991abd0007079e98687060ba9c83db55620c238bc363106a24ff411d22f533 + languageName: node + linkType: hard + "fresh@npm:0.5.2": version: 0.5.2 resolution: "fresh@npm:0.5.2" @@ -25316,6 +25339,43 @@ __metadata: languageName: node linkType: hard +"motion-dom@npm:^12.40.0": + version: 12.40.0 + resolution: "motion-dom@npm:12.40.0" + dependencies: + motion-utils: "npm:^12.39.0" + checksum: 10c0/79da846a36fd5f6762a0fcfa6e0b7128e4d58f7c07d1467a9f789a9cd0b5adbef9bfbde75760901a13cf2bddd9b31e93e4348a714f570c45ca1e2bfabd22859e + languageName: node + linkType: hard + +"motion-utils@npm:^12.39.0": + version: 12.39.0 + resolution: "motion-utils@npm:12.39.0" + checksum: 10c0/6d7a2a2cc0797b72410a666a9cc1c201c8e39bf9669670464e433fe1e72af5f0217154c869867b34fbadf3664cf222c0d022bbc4eed7927f201ae971918e7440 + languageName: node + linkType: hard + +"motion@npm:^12.40.0": + version: 12.40.0 + resolution: "motion@npm:12.40.0" + dependencies: + framer-motion: "npm:^12.40.0" + tslib: "npm:^2.4.0" + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + checksum: 10c0/d0c118ed4829f2999c3ab7eb1ee916df70c65e95d262e951b69d3cea67a74e4a1d12e181badf4180e0d01c1ca8a9b109be4ea5456cad04bb58d4510f071af60f + languageName: node + linkType: hard + "mri@npm:^1.2.0": version: 1.2.0 resolution: "mri@npm:1.2.0" From e048b92167366ac85dcd3ccba5e8925b455f118c Mon Sep 17 00:00:00 2001 From: Mikhail Lukin Date: Wed, 27 May 2026 15:23:56 +0700 Subject: [PATCH 2/2] feat(button): add motion --- packages/button/src/typings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/button/src/typings.ts b/packages/button/src/typings.ts index 4fa2c517e9..27f6b44bc0 100644 --- a/packages/button/src/typings.ts +++ b/packages/button/src/typings.ts @@ -4,7 +4,7 @@ import { type ElementType, type ReactNode, } from 'react'; -import { type MotionProps } from 'motion/react'; +import { type MotionProps } from 'motion/react'; export type StyleColors = { default: {