From 274e31c52f659f10d8ceca475734913cf520435e Mon Sep 17 00:00:00 2001 From: Mikhail Lukin Date: Wed, 27 May 2026 16:54:28 +0700 Subject: [PATCH 01/22] feat(button): add motion --- packages/button/package.json | 1 + yarn.lock | 60 ++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) 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/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 052fb6e8a218248041209b4a6c5d4cb706c78562 Mon Sep 17 00:00:00 2001 From: Mikhail Lukin Date: Wed, 27 May 2026 16:54:59 +0700 Subject: [PATCH 02/22] feat(button): add motion --- packages/button/src/docs/description.mdx | 451 ++--------------------- 1 file changed, 39 insertions(+), 412 deletions(-) diff --git a/packages/button/src/docs/description.mdx b/packages/button/src/docs/description.mdx index bce8464879..099bebfdb6 100644 --- a/packages/button/src/docs/description.mdx +++ b/packages/button/src/docs/description.mdx @@ -1,426 +1,53 @@ -## Виды кнопок +## Spring animation ```jsx live mobileHeight={500} render(() => { - const [disabled, setDisabled] = React.useState(false); + const [state, setState] = React.useState({ stiffness: 100, damping: 10, mass: 1 }); return ( <> - - +
+ Accent - - Primary - - - Secondary - - - Outlined - - - Transparent - - - Text - - - - - - setDisabled((prevState) => !prevState)} - label='Недоступна' - /> - - ); -}); -//MOBILE -render(() => { - const [disabled, setDisabled] = React.useState(false); - - return ( - <> - - Accent - - - - Primary - - - - Secondary - - - - Outlined - - - - Transparent - - - - Text - - - setDisabled((prevState) => !prevState)} - label='Недоступна' - /> - - ); -}); -``` - -## Размеры - -Кнопка доступна в размерах 72, 64, 56, 48, 40, 32. - -```jsx live mobileHeight={460} -const BIG_SIZES = [72, 64, 56]; -const SMALL_SIZES = [48, 40, 32]; - -render( - <> - - {BIG_SIZES.map((size) => ( - - {`${size}px`} - - ))} - - - - {SMALL_SIZES.map((size) => ( - - {`${size}px`} - - ))} - - , -); -//MOBILE -const SIZES = [72, 64, 56, 48, 40, 32]; - -render( - - {SIZES.map((size, idx) => ( - - - {`${size}px`} - - {SIZES.length - 1 !== idx && } - - ))} - , -); -``` - -## Форма - -Для кнопки доступно два варианта скругления углов. - -```jsx live -render(() => { - const [shape, setShape] = React.useState('rectangular'); - - return ( - <> - - - - - setShape(value)} - breakpoint={BREAKPOINT} - > - - - - - ); -}); -``` - -## Ширина - -Кнопка адаптируется под длину контента. Для каждого вертикального размера кнопки задан минимальный горизонтальный размер. -С помощью свойства `block` можно заставить кнопку занимать всю ширину контейнера. -Через доступ по classname можно задать кнопке ширину в рх. - -```jsx live -
- - - - - - - - -
-``` - -## Анатомия - -С помощью слотов `leftAddons` и `rightAddons` можно кастомизировать кнопку. Например, добавить иконку. -Переданный контент будет отрисован слева или справа от текста кнопки. Если текста нет — будет отрисована квадратная кнопка. -В 56, 64 и 72 размерах доступна подпись под лейблом. - -```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 handleLabelChange = () => setLabel(!label); - const handleHintChange = () => setHint((p) => (p ? undefined : 'Hint')); - const handleLeftAddonsChange = () => setLeftAddons(!leftAddons); - const handleRightAddonsChange = () => setRightAddons(!rightAddons); - - return ( -
- - - - - - - - - - - -
- ); -}); -``` - -## Поведение лейбла - -С помощью свойства `textResizing` можно сжать или растянуть текстовый контент внутри кнопки. - -```jsx live -render(() => { - const [textResizing, setTextResizing] = React.useState('hug'); - - return ( -
- - - - - setTextResizing(value)} - breakpoint={BREAKPOINT} - > - - - -
- ); -}); -``` - -## Перенос текста внутри кнопки - -С помощью свойства `nowrap` можно запретить перенос текста на новую строку. - -```jsx live -render(() => { - const [checked, setChecked] = React.useState(true); - - const handleChange = () => setChecked(!checked); - - return ( - -
- - Пример длинного текста - -
- - Запретить перенос строки} - checked={checked} - onChange={handleChange} - /> - -
- ); -}); -//MOBILE -render(() => { - const [checked, setChecked] = React.useState(true); - - const handleChange = () => setChecked(!checked); - - return ( - -
- - Пример длинного текста - -
- - Запретить перенос строки} - checked={checked} - onChange={handleChange} - /> - -
- ); -}); -``` - -## Размытие фона - -Для кнопок можно включить размытие фона, если она полупрозрачная, располагается поверх динамического контента или изображения. - -```jsx live expanded -render(() => { - const [checked, setChecked] = React.useState(true); - const handleChange = () => setChecked(!checked); - - const wrapper = { - position: 'relative', - borderRadius: '16px', - width: '330px', - height: '100px', - }; - - const image = { - width: '100%', - height: '100%', - borderRadius: 'inherit', - objectFit: 'cover', - }; - - const wrapperButton = { - position: 'absolute', - display: 'flex', - inset: '0px', - padding: '20px', - justifyContent: 'space-between', - }; - - return ( - <> -
- -
- - + +
+
+
Stiffness: {state.stiffness}
+ + setState(s => ({ ...s, stiffness: value }))} + /> +
+
+
Damping: {state.damping}
+ + setState(s => ({ ...s, damping: value }))} + /> +
+
+
Mass: {state.mass}
+ + setState(s => ({ ...s, mass: value }))} + /> +
- - - - ); }); ``` - -## Обработка событий - -С помощью свойства `loading` можно отобразить состояние загрузки. -Минимальное время отображения лоадера — 500мс, чтобы при быстрых ответах от сервера кнопка не «моргала». - -```jsx live expanded -render(() => { - const [loading, setLoading] = React.useState(false); - const [loadTimeout, setLoadTimeout] = React.useState('30'); - const timeoutId = React.useRef(); - - const handleClick = () => { - setLoading(true); - - clearTimeout(timeoutId.current); - - timeoutId.current = setTimeout(() => { - setLoading(false); - }, Number(loadTimeout)); - }; - - const handleTimeoutChange = (_, { value }) => { - clearTimeout(timeoutId.current); - setLoading(false); - setLoadTimeout(value); - }; - - return ( - <> - - - - - - - - - - ); -}); -``` - -## Другие кнопки - -Если нужна кнопка с одной иконкой, но без подложки, используйте [IconButton](/docs/iconbutton--docs). - -Если нужна кнопка с другим цветом фона, используйте [CustomButton](/docs/custombutton--docs). - -Если нужна кнопка с выпадающим списком, используйте [PickerButton](/docs/pickerbutton--docs). From 7b09d5d264244963885a5de5c218db1c157e6eb4 Mon Sep 17 00:00:00 2001 From: Mikhail Lukin Date: Wed, 27 May 2026 17:50:48 +0700 Subject: [PATCH 03/22] feat(button): add motion --- .../src/components/base-button/Component.tsx | 31 ++++++++++++++++ packages/button/src/docs/description.mdx | 36 ++++++++++++------- packages/button/src/typings.ts | 15 ++++++++ 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/packages/button/src/components/base-button/Component.tsx b/packages/button/src/components/base-button/Component.tsx index 7aaffb248f..77aa79cbcc 100644 --- a/packages/button/src/components/base-button/Component.tsx +++ b/packages/button/src/components/base-button/Component.tsx @@ -3,12 +3,14 @@ import React, { type AnchorHTMLAttributes, type ButtonHTMLAttributes, forwardRef, + useCallback, useEffect, useRef, useState, } from 'react'; import mergeRefs from 'react-merge-refs'; import cn from 'classnames'; +import { animate } from 'motion'; import { getDataTestId } from '@alfalab/core-components-shared'; import { Spinner } from '@alfalab/core-components-spinner'; @@ -53,6 +55,8 @@ export const BaseButton = forwardRef< onClick, styles = {}, colorStylesMap = { default: {}, inverted: {} }, + shake = false, + shakeSpring = {}, ...restProps }, ref, @@ -61,6 +65,8 @@ export const BaseButton = forwardRef< const [focused] = useFocus(buttonRef, 'keyboard'); + const isAnimating = useRef(false); + const [loaderTimePassed, setLoaderTimePassed] = useState(true); const timerId = useRef(0); @@ -168,6 +174,30 @@ export const BaseButton = forwardRef< [], ); + const triggerShake = useCallback(() => { + if (!shake || !buttonRef.current || isAnimating.current) return; + + isAnimating.current = true; + + const springOptions = { + type: 'spring' as const, + stiffness: shakeSpring.stiffness ?? 100, + damping: shakeSpring.damping ?? 10, + mass: shakeSpring.mass ?? 1, + }; + + animate(buttonRef.current, { x: [0, 10] }, springOptions) + .finished.then(() => + animate(buttonRef.current, { x: [10, -10] }, springOptions).finished, + ) + .then(() => + animate(buttonRef.current, { x: [-10, 0] }, springOptions).finished, + ) + .then(() => { + isAnimating.current = false; + }); + }, [shake, shakeSpring]); + const handleClick = ( e: React.MouseEvent & React.MouseEvent, @@ -179,6 +209,7 @@ export const BaseButton = forwardRef< return; } onClick?.(e); + triggerShake(); }; if (href) { diff --git a/packages/button/src/docs/description.mdx b/packages/button/src/docs/description.mdx index 099bebfdb6..d64dec060b 100644 --- a/packages/button/src/docs/description.mdx +++ b/packages/button/src/docs/description.mdx @@ -1,4 +1,6 @@ -## Spring animation +## Shake + +Shake-анимация с эффектом «выброса» — кнопка смещается с начальной скоростью, а затем плавно возвращается обратно благодаря spring-физике. ```jsx live mobileHeight={500} render(() => { @@ -7,42 +9,50 @@ render(() => { return ( <>
- - Accent + + Click me! -
-
-
Stiffness: {state.stiffness}
+
+
+
+ Stiffness: {state.stiffness} +
setState(s => ({ ...s, stiffness: value }))} + onChange={({ value }) => setState((s) => ({ ...s, stiffness: value }))} />
-
-
Damping: {state.damping}
+
+
+ Damping: {state.damping} +
setState(s => ({ ...s, damping: value }))} + onChange={({ value }) => setState((s) => ({ ...s, damping: value }))} />
-
-
Mass: {state.mass}
+
+
+ Mass: {state.mass.toFixed(1)} +
setState(s => ({ ...s, mass: value }))} + onChange={({ value }) => setState((s) => ({ ...s, mass: value }))} />
diff --git a/packages/button/src/typings.ts b/packages/button/src/typings.ts index bc55f9567a..c1b0aa867e 100644 --- a/packages/button/src/typings.ts +++ b/packages/button/src/typings.ts @@ -113,6 +113,21 @@ type ComponentProps = { * Дочерние элементы. */ children?: ReactNode; + + /** + * Включить shake-анимацию по клику + * @default false + */ + shake?: boolean; + + /** + * Spring-параметры для shake-анимации + */ + shakeSpring?: { + stiffness?: number; + damping?: number; + mass?: number; + }; }; export type PrivateButtonProps = { From 0dcdd505579dc5681533cd559c4f451cce427635 Mon Sep 17 00:00:00 2001 From: Mikhail Lukin Date: Thu, 28 May 2026 13:13:01 +0700 Subject: [PATCH 04/22] feat(button): add motion --- .../src/components/base-button/Component.tsx | 90 +- packages/button/src/docs/description.mdx | 1143 ++++++++++++++++- packages/button/src/hooks/index.ts | 2 + .../button/src/hooks/useSpringAnimation.ts | 156 +++ packages/button/src/typings.ts | 110 +- 5 files changed, 1456 insertions(+), 45 deletions(-) create mode 100644 packages/button/src/hooks/index.ts create mode 100644 packages/button/src/hooks/useSpringAnimation.ts diff --git a/packages/button/src/components/base-button/Component.tsx b/packages/button/src/components/base-button/Component.tsx index 77aa79cbcc..930ca88935 100644 --- a/packages/button/src/components/base-button/Component.tsx +++ b/packages/button/src/components/base-button/Component.tsx @@ -3,20 +3,19 @@ import React, { type AnchorHTMLAttributes, type ButtonHTMLAttributes, forwardRef, - useCallback, useEffect, useRef, useState, } from 'react'; import mergeRefs from 'react-merge-refs'; import cn from 'classnames'; -import { animate } from 'motion'; import { getDataTestId } from '@alfalab/core-components-shared'; import { Spinner } from '@alfalab/core-components-spinner'; import { useFocus } from '@alfalab/hooks'; import { LOADER_MIN_DISPLAY_INTERVAL } from '../../constants/loader-min-display-interval'; +import { useSpringAnimation } from '../../hooks'; import { type CommonButtonProps, type PrivateButtonProps } from '../../typings'; import defaultColors from './default.module.css'; @@ -56,7 +55,25 @@ export const BaseButton = forwardRef< styles = {}, colorStylesMap = { default: {}, inverted: {} }, shake = false, - shakeSpring = {}, + shakeSpring, + pulse = false, + pulseSpring, + bounce = false, + bounceSpring, + wobble = false, + wobbleSpring, + jelly = false, + jellySpring, + swing = false, + swingSpring, + pop = false, + popSpring, + nod = false, + nodSpring, + rubber = false, + rubberSpring, + onSpringAnimationStart, + onSpringAnimationEnd, ...restProps }, ref, @@ -65,18 +82,39 @@ export const BaseButton = forwardRef< const [focused] = useFocus(buttonRef, 'keyboard'); - const isAnimating = useRef(false); - const [loaderTimePassed, setLoaderTimePassed] = useState(true); - const timerId = useRef(0); - const showLoader = loading || !loaderTimePassed; - const showHint = hint && [56, 64, 72].includes(size); + const animationCallbacks = { onStart: onSpringAnimationStart, onEnd: onSpringAnimationEnd }; - const iconOnly = !children; + const shakeAnim = useSpringAnimation(buttonRef, 'shake', shakeSpring, animationCallbacks); + const pulseAnim = useSpringAnimation(buttonRef, 'pulse', pulseSpring, animationCallbacks); + const bounceAnim = useSpringAnimation( + buttonRef, + 'bounce', + bounceSpring, + animationCallbacks, + ); + const wobbleAnim = useSpringAnimation( + buttonRef, + 'wobble', + wobbleSpring, + animationCallbacks, + ); + const jellyAnim = useSpringAnimation(buttonRef, 'jelly', jellySpring, animationCallbacks); + const swingAnim = useSpringAnimation(buttonRef, 'swing', swingSpring, animationCallbacks); + const popAnim = useSpringAnimation(buttonRef, 'pop', popSpring, animationCallbacks); + const nodAnim = useSpringAnimation(buttonRef, 'nod', nodSpring, animationCallbacks); + const rubberAnim = useSpringAnimation( + buttonRef, + 'rubber', + rubberSpring, + animationCallbacks, + ); + const showHint = hint && [56, 64, 72].includes(size); + const iconOnly = !children; const sizeStyle = `size-${size}`; const componentProps = { @@ -174,30 +212,6 @@ export const BaseButton = forwardRef< [], ); - const triggerShake = useCallback(() => { - if (!shake || !buttonRef.current || isAnimating.current) return; - - isAnimating.current = true; - - const springOptions = { - type: 'spring' as const, - stiffness: shakeSpring.stiffness ?? 100, - damping: shakeSpring.damping ?? 10, - mass: shakeSpring.mass ?? 1, - }; - - animate(buttonRef.current, { x: [0, 10] }, springOptions) - .finished.then(() => - animate(buttonRef.current, { x: [10, -10] }, springOptions).finished, - ) - .then(() => - animate(buttonRef.current, { x: [-10, 0] }, springOptions).finished, - ) - .then(() => { - isAnimating.current = false; - }); - }, [shake, shakeSpring]); - const handleClick = ( e: React.MouseEvent & React.MouseEvent, @@ -209,7 +223,15 @@ export const BaseButton = forwardRef< return; } onClick?.(e); - triggerShake(); + if (shake) shakeAnim.trigger(); + else if (pulse) pulseAnim.trigger(); + else if (bounce) bounceAnim.trigger(); + else if (wobble) wobbleAnim.trigger(); + else if (jelly) jellyAnim.trigger(); + else if (swing) swingAnim.trigger(); + else if (pop) popAnim.trigger(); + else if (nod) nodAnim.trigger(); + else if (rubber) rubberAnim.trigger(); }; if (href) { diff --git a/packages/button/src/docs/description.mdx b/packages/button/src/docs/description.mdx index d64dec060b..cb9bedbb53 100644 --- a/packages/button/src/docs/description.mdx +++ b/packages/button/src/docs/description.mdx @@ -1,21 +1,535 @@ +# Приведённый код и live-демо имеют демонстрационный характер и не являются финальной реализацией. + ## Shake -Shake-анимация с эффектом «выброса» — кнопка смещается с начальной скоростью, а затем плавно возвращается обратно благодаря spring-физике. +Shake-анимация с эффектом «выброса» — кнопка смещается по оси X и плавно возвращается благодаря spring-физике. ```jsx live mobileHeight={500} render(() => { const [state, setState] = React.useState({ stiffness: 100, damping: 10, mass: 1 }); + const [playing, setPlaying] = React.useState(false); + const cancelRef = React.useRef(null); + + return ( + <> +
+
+ { + setPlaying(true); + cancelRef.current = cancel; + }} + onSpringAnimationEnd={() => setPlaying(false)} + > + Click me! + + {playing && ( + <> + + Анимация воспроизводится... + + cancelRef.current && cancelRef.current()} + > + Отменить + + + )} +
+
+
+
+ Stiffness: {state.stiffness} +
+ + setState((s) => ({ ...s, stiffness: value }))} + /> +
+
+
+ Damping: {state.damping} +
+ + setState((s) => ({ ...s, damping: value }))} + /> +
+
+
+ Mass: {state.mass.toFixed(1)} +
+ + setState((s) => ({ ...s, mass: value }))} + /> +
+
+
+ + ); +}); +``` + +## Pulse + +Pulse-анимация — кнопка кратковременно увеличивается в масштабе и возвращается обратно. + +```jsx live mobileHeight={500} +render(() => { + const [state, setState] = React.useState({ stiffness: 300, damping: 15, mass: 1 }); + const [playing, setPlaying] = React.useState(false); + const cancelRef = React.useRef(null); + + return ( + <> +
+
+ { + setPlaying(true); + cancelRef.current = cancel; + }} + onSpringAnimationEnd={() => setPlaying(false)} + > + Click me! + + {playing && ( + <> + + Анимация воспроизводится... + + cancelRef.current && cancelRef.current()} + > + Отменить + + + )} +
+
+
+
+ Stiffness: {state.stiffness} +
+ + setState((s) => ({ ...s, stiffness: value }))} + /> +
+
+
+ Damping: {state.damping} +
+ + setState((s) => ({ ...s, damping: value }))} + /> +
+
+
+ Mass: {state.mass.toFixed(1)} +
+ + setState((s) => ({ ...s, mass: value }))} + /> +
+
+
+ + ); +}); +``` + +## Bounce + +Bounce-анимация — кнопка подпрыгивает вверх и возвращается обратно. + +```jsx live mobileHeight={500} +render(() => { + const [state, setState] = React.useState({ stiffness: 200, damping: 12, mass: 1 }); + const [playing, setPlaying] = React.useState(false); + const cancelRef = React.useRef(null); + + return ( + <> +
+
+ { + setPlaying(true); + cancelRef.current = cancel; + }} + onSpringAnimationEnd={() => setPlaying(false)} + > + Click me! + + {playing && ( + <> + + Анимация воспроизводится... + + cancelRef.current && cancelRef.current()} + > + Отменить + + + )} +
+
+
+
+ Stiffness: {state.stiffness} +
+ + setState((s) => ({ ...s, stiffness: value }))} + /> +
+
+
+ Damping: {state.damping} +
+ + setState((s) => ({ ...s, damping: value }))} + /> +
+
+
+ Mass: {state.mass.toFixed(1)} +
+ + setState((s) => ({ ...s, mass: value }))} + /> +
+
+
+ + ); +}); +``` + +## Wobble + +Wobble-анимация — кнопка покачивается вокруг своей оси. Создаёт более мягкий эффект привлечения внимания по сравнению со shake. + +```jsx live mobileHeight={500} +render(() => { + const [state, setState] = React.useState({ stiffness: 150, damping: 8, mass: 1 }); + const [playing, setPlaying] = React.useState(false); + const cancelRef = React.useRef(null); + + return ( + <> +
+
+ { + setPlaying(true); + cancelRef.current = cancel; + }} + onSpringAnimationEnd={() => setPlaying(false)} + > + Click me! + + {playing && ( + <> + + Анимация воспроизводится... + + cancelRef.current && cancelRef.current()} + > + Отменить + + + )} +
+
+
+
+ Stiffness: {state.stiffness} +
+ + setState((s) => ({ ...s, stiffness: value }))} + /> +
+
+
+ Damping: {state.damping} +
+ + setState((s) => ({ ...s, damping: value }))} + /> +
+
+
+ Mass: {state.mass.toFixed(1)} +
+ + setState((s) => ({ ...s, mass: value }))} + /> +
+
+
+ + ); +}); +``` + +## Jelly + +Jelly-анимация — кнопка деформируется: растягивается по X и сжимается по Y, затем возвращается. Эффект желе благодаря одновременной анимации `scaleX` и `scaleY`. + +```jsx live mobileHeight={500} +render(() => { + const [state, setState] = React.useState({ stiffness: 300, damping: 10, mass: 1 }); + const [playing, setPlaying] = React.useState(false); + const cancelRef = React.useRef(null); + + return ( + <> +
+
+ { + setPlaying(true); + cancelRef.current = cancel; + }} + onSpringAnimationEnd={() => setPlaying(false)} + > + Click me! + + {playing && ( + <> + + Анимация воспроизводится... + + cancelRef.current && cancelRef.current()} + > + Отменить + + + )} +
+
+
+
+ Stiffness: {state.stiffness} +
+ + setState((s) => ({ ...s, stiffness: value }))} + /> +
+
+
+ Damping: {state.damping} +
+ + setState((s) => ({ ...s, damping: value }))} + /> +
+
+
+ Mass: {state.mass.toFixed(1)} +
+ + setState((s) => ({ ...s, mass: value }))} + /> +
+
+
+ + ); +}); +``` + +## Swing + +Swing-анимация — маятниковое качание с затуханием. Отличается от wobble тем, что амплитуда уменьшается с каждым качком. + +```jsx live mobileHeight={500} +render(() => { + const [state, setState] = React.useState({ stiffness: 100, damping: 8, mass: 1 }); + const [playing, setPlaying] = React.useState(false); + const cancelRef = React.useRef(null); return ( <>
- - Click me! - -
+ { + setPlaying(true); + cancelRef.current = cancel; + }} + onSpringAnimationEnd={() => setPlaying(false)} + > + Click me! + + {playing && ( + <> + + Анимация воспроизводится... + + cancelRef.current && cancelRef.current()} + > + Отменить + + + )} +
+
Stiffness: {state.stiffness} @@ -61,3 +575,620 @@ render(() => { ); }); ``` + +## Pop + +Pop-анимация — быстрый масштаб вверх, чуть ниже единицы, обратно. + +```jsx live mobileHeight={500} +render(() => { + const [state, setState] = React.useState({ stiffness: 400, damping: 20, mass: 1 }); + const [playing, setPlaying] = React.useState(false); + const cancelRef = React.useRef(null); + + return ( + <> +
+
+ { + setPlaying(true); + cancelRef.current = cancel; + }} + onSpringAnimationEnd={() => setPlaying(false)} + > + Click me! + + {playing && ( + <> + + Анимация воспроизводится... + + cancelRef.current && cancelRef.current()} + > + Отменить + + + )} +
+
+
+
+ Stiffness: {state.stiffness} +
+ + setState((s) => ({ ...s, stiffness: value }))} + /> +
+
+
+ Damping: {state.damping} +
+ + setState((s) => ({ ...s, damping: value }))} + /> +
+
+
+ Mass: {state.mass.toFixed(1)} +
+ + setState((s) => ({ ...s, mass: value }))} + /> +
+
+
+ + ); +}); +``` + +## Nod + +Nod-анимация — кивок вниз и обратно. Противоположность bounce по направлению. + +```jsx live mobileHeight={500} +render(() => { + const [state, setState] = React.useState({ stiffness: 200, damping: 12, mass: 1 }); + const [playing, setPlaying] = React.useState(false); + const cancelRef = React.useRef(null); + + return ( + <> +
+
+ { + setPlaying(true); + cancelRef.current = cancel; + }} + onSpringAnimationEnd={() => setPlaying(false)} + > + Click me! + + {playing && ( + <> + + Анимация воспроизводится... + + cancelRef.current && cancelRef.current()} + > + Отменить + + + )} +
+
+
+
+ Stiffness: {state.stiffness} +
+ + setState((s) => ({ ...s, stiffness: value }))} + /> +
+
+
+ Damping: {state.damping} +
+ + setState((s) => ({ ...s, damping: value }))} + /> +
+
+
+ Mass: {state.mass.toFixed(1)} +
+ + setState((s) => ({ ...s, mass: value }))} + /> +
+
+
+ + ); +}); +``` + +## Rubber + +Rubber-анимация — кнопка сужается по X как резинка, затем растягивается и возвращается. Акцент на горизонтальной эластичности. + +```jsx live mobileHeight={500} +render(() => { + const [state, setState] = React.useState({ stiffness: 250, damping: 12, mass: 1 }); + const [playing, setPlaying] = React.useState(false); + const cancelRef = React.useRef(null); + + return ( + <> +
+
+ { + setPlaying(true); + cancelRef.current = cancel; + }} + onSpringAnimationEnd={() => setPlaying(false)} + > + Click me! + + {playing && ( + <> + + Анимация воспроизводится... + + cancelRef.current && cancelRef.current()} + > + Отменить + + + )} +
+
+
+
+ Stiffness: {state.stiffness} +
+ + setState((s) => ({ ...s, stiffness: value }))} + /> +
+
+
+ Damping: {state.damping} +
+ + setState((s) => ({ ...s, damping: value }))} + /> +
+
+
+ Mass: {state.mass.toFixed(1)} +
+ + setState((s) => ({ ...s, mass: value }))} + /> +
+
+
+ + ); +}); +``` + +--- + +## Модуль анимаций: `useSpringAnimation` + +Хук реализован в `packages/button/src/hooks/useSpringAnimation.ts` и инкапсулирует всю логику spring-анимаций на основе библиотеки [motion](https://motion.dev/docs/animate). + +### Зачем хук, а не инлайн-код + +Каждая анимация — это цепочка вызовов `animate()` (motion не поддерживает больше двух keyframes в spring-режиме). Хук берёт на себя: + +- построение цепочки шагов из пресета +- блокировку повторного запуска пока анимация идёт +- отмену с мгновенным сбросом трансформов +- cleanup при размонтировании компонента + +### API хука + +```tsx +const { trigger, cancel, isPlaying } = useSpringAnimation( + ref, // RefObject — элемент для анимации + type, // 'shake' | 'pulse' | 'bounce' | 'wobble' + springOptions, // { stiffness?, damping?, mass? } — переопределяют дефолты пресета + callbacks, // { onStart?(cancel): void, onEnd?(): void } +); +``` + + + + + + + + + + + + + + + + + + + + + + + + + + +
ВозвращаемоеТипОписание
+ trigger + + () => void + Запускает анимацию. Игнорируется, если уже играет.
+ cancel + + () => void + Останавливает анимацию и мгновенно возвращает элемент в исходное положение.
+ isPlaying + + boolean + + React-состояние: true пока анимация воспроизводится. +
+ +### Пресеты анимаций + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ТипЭффектСвойствоДефолтный spring
+ shake + Горизонтальный сдвиг влево-вправо + x + stiffness 100, damping 10
+ pulse + Масштабирование вверх и обратно + scale + stiffness 300, damping 15
+ bounce + Прыжок вверх и обратно + y + stiffness 200, damping 12
+ wobble + Вращение влево-вправо + rotate + stiffness 150, damping 8
+ jelly + Деформация scaleX/scaleY в противофазе + scaleX + scaleY + stiffness 300, damping 10
+ swing + Маятниковое вращение с затуханием + rotate + stiffness 100, damping 8
+ pop + Резкий масштаб вверх и обратно + scale + stiffness 400, damping 20
+ nod + Кивок вниз и обратно + y + stiffness 200, damping 12
+ rubber + Сужение и растяжение по X + scaleX + stiffness 250, damping 12
+ +### Использование в компоненте + +```tsx +import { useRef } from 'react'; +import { useSpringAnimation } from './hooks'; + +function MyComponent() { + const ref = useRef(null); + + const { trigger, cancel, isPlaying } = useSpringAnimation( + ref, + 'shake', + { stiffness: 150, damping: 12 }, + { + onStart: (cancelFn) => console.log('started', cancelFn), + onEnd: () => console.log('finished'), + }, + ); + + return ( +
+ + {isPlaying && } +
+ ); +} +``` + +### Пропсы Button для анимаций + +Button оборачивает хук и предоставляет декларативный API через пропсы. Одновременно активна только одна анимация — они взаимоисключающие. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ПропТипОписание
+ shake + + boolean + Включить shake-анимацию по клику
+ shakeSpring + + SpringOptions + Переопределить spring-параметры shake
+ pulse + + boolean + Включить pulse-анимацию по клику
+ pulseSpring + + SpringOptions + Переопределить spring-параметры pulse
+ bounce + + boolean + Включить bounce-анимацию по клику
+ bounceSpring + + SpringOptions + Переопределить spring-параметры bounce
+ wobble + + boolean + Включить wobble-анимацию по клику
+ wobbleSpring + + SpringOptions + Переопределить spring-параметры wobble
+ onSpringAnimationStart + + (cancel: () => void) => void + Вызывается при старте; получает функцию отмены
+ onSpringAnimationEnd + + () => void + Вызывается при завершении или отмене
+ +### Добавление нового пресета + +Чтобы добавить новый тип анимации, достаточно расширить `PRESETS` в файле хука и добавить тип в `AnimationType`: + +```ts +// packages/button/src/hooks/useSpringAnimation.ts + +export type AnimationType = 'shake' | 'pulse' | 'bounce' | 'wobble' | 'flip'; + +const PRESETS: Record = { + // ...существующие пресеты... + flip: { + defaultSpring: { stiffness: 200, damping: 20, mass: 1 }, + steps: [ + { property: 'rotateY', from: 0, to: 90 }, + { property: 'rotateY', from: -90, to: 0 }, + ], + }, +}; +``` + +Новый тип сразу доступен в хуке и может использоваться в любом компоненте. diff --git a/packages/button/src/hooks/index.ts b/packages/button/src/hooks/index.ts new file mode 100644 index 0000000000..3e56e7cd6b --- /dev/null +++ b/packages/button/src/hooks/index.ts @@ -0,0 +1,2 @@ +export { useSpringAnimation } from './useSpringAnimation'; +export type { AnimationType } from './useSpringAnimation'; diff --git a/packages/button/src/hooks/useSpringAnimation.ts b/packages/button/src/hooks/useSpringAnimation.ts new file mode 100644 index 0000000000..76fd7ad5e0 --- /dev/null +++ b/packages/button/src/hooks/useSpringAnimation.ts @@ -0,0 +1,156 @@ +import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'; +import { animate } from 'motion'; + +import { type SpringOptions } from '../typings'; + +export type AnimationType = + | 'shake' + | 'pulse' + | 'bounce' + | 'wobble' + | 'jelly' + | 'swing' + | 'pop' + | 'nod' + | 'rubber'; + +type AnimationValues = Record; + +type AnimationPreset = { + defaultSpring: Required; + steps: AnimationValues[]; +}; + +const PRESETS: Record = { + shake: { + defaultSpring: { stiffness: 100, damping: 10, mass: 1 }, + steps: [{ x: [0, 10] }, { x: [10, -10] }, { x: [-10, 0] }], + }, + pulse: { + defaultSpring: { stiffness: 300, damping: 15, mass: 1 }, + steps: [{ scale: [1, 1.08] }, { scale: [1.08, 1] }], + }, + bounce: { + defaultSpring: { stiffness: 200, damping: 12, mass: 1 }, + steps: [{ y: [0, -14] }, { y: [-14, 0] }], + }, + wobble: { + defaultSpring: { stiffness: 150, damping: 8, mass: 1 }, + steps: [{ rotate: [0, -6] }, { rotate: [-6, 6] }, { rotate: [6, -3] }, { rotate: [-3, 0] }], + }, + jelly: { + defaultSpring: { stiffness: 300, damping: 10, mass: 1 }, + steps: [ + { scaleX: [1, 1.25], scaleY: [1, 0.75] }, + { scaleX: [1.25, 0.85], scaleY: [0.75, 1.15] }, + { scaleX: [0.85, 1], scaleY: [1.15, 1] }, + ], + }, + swing: { + defaultSpring: { stiffness: 100, damping: 8, mass: 1 }, + steps: [ + { rotate: [0, -12] }, + { rotate: [-12, 8] }, + { rotate: [8, -4] }, + { rotate: [-4, 0] }, + ], + }, + pop: { + defaultSpring: { stiffness: 400, damping: 20, mass: 1 }, + steps: [{ scale: [1, 1.15] }, { scale: [1.15, 0.95] }, { scale: [0.95, 1] }], + }, + nod: { + defaultSpring: { stiffness: 200, damping: 12, mass: 1 }, + steps: [{ y: [0, 8] }, { y: [8, 0] }], + }, + rubber: { + defaultSpring: { stiffness: 250, damping: 12, mass: 1 }, + steps: [{ scaleX: [1, 0.85] }, { scaleX: [0.85, 1.1] }, { scaleX: [1.1, 1] }], + }, +}; + +type UseSpringAnimationCallbacks = { + onStart?: (cancel: () => void) => void; + onEnd?: () => void; +}; + +export function useSpringAnimation( + ref: RefObject, + type: AnimationType, + springOptions?: SpringOptions, + callbacks?: UseSpringAnimationCallbacks, +): { + trigger: () => void; + cancel: () => void; + isPlaying: boolean; +} { + const isPlayingRef = useRef(false); + const [isPlaying, setIsPlaying] = useState(false); + const animationRef = useRef | null>(null); + + const callbacksRef = useRef(callbacks); + callbacksRef.current = callbacks; + + const springOptionsRef = useRef(springOptions); + springOptionsRef.current = springOptions; + + const cancel = useCallback(() => { + animationRef.current?.cancel(); + animationRef.current = null; + if (ref.current) { + animate( + ref.current, + { x: 0, y: 0, scale: 1, scaleX: 1, scaleY: 1, rotate: 0 }, + { duration: 0 }, + ); + } + if (isPlayingRef.current) { + isPlayingRef.current = false; + setIsPlaying(false); + callbacksRef.current?.onEnd?.(); + } + }, [ref]); + + const trigger = useCallback(() => { + if (!ref.current || isPlayingRef.current) return; + + const el = ref.current; + const preset = PRESETS[type]; + const merged = { ...preset.defaultSpring, ...springOptionsRef.current }; + const springOpts = { type: 'spring' as const, ...merged }; + + isPlayingRef.current = true; + setIsPlaying(true); + callbacksRef.current?.onStart?.(cancel); + + const { steps } = preset; + + const runStep = (index: number): void => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + animationRef.current = animate(el, steps[index] as any, springOpts); + + if (index < steps.length - 1) { + animationRef.current.then(() => runStep(index + 1)); + } else { + animationRef.current.then(() => { + if (isPlayingRef.current) { + isPlayingRef.current = false; + setIsPlaying(false); + callbacksRef.current?.onEnd?.(); + } + }); + } + }; + + runStep(0); + }, [ref, type, cancel]); + + useEffect( + () => () => { + animationRef.current?.cancel(); + }, + [], + ); + + return { trigger, cancel, isPlaying }; +} diff --git a/packages/button/src/typings.ts b/packages/button/src/typings.ts index c1b0aa867e..4d6faf4a32 100644 --- a/packages/button/src/typings.ts +++ b/packages/button/src/typings.ts @@ -5,6 +5,12 @@ import { type ReactNode, } from 'react'; +export type SpringOptions = { + stiffness?: number; + damping?: number; + mass?: number; +}; + export type StyleColors = { default: { [key: string]: string; @@ -123,11 +129,105 @@ type ComponentProps = { /** * Spring-параметры для shake-анимации */ - shakeSpring?: { - stiffness?: number; - damping?: number; - mass?: number; - }; + shakeSpring?: SpringOptions; + + /** + * Включить pulse-анимацию по клику (масштабирование) + * @default false + */ + pulse?: boolean; + + /** + * Spring-параметры для pulse-анимации + */ + pulseSpring?: SpringOptions; + + /** + * Включить bounce-анимацию по клику (прыжок по Y) + * @default false + */ + bounce?: boolean; + + /** + * Spring-параметры для bounce-анимации + */ + bounceSpring?: SpringOptions; + + /** + * Включить wobble-анимацию по клику (вращение) + * @default false + */ + wobble?: boolean; + + /** + * Spring-параметры для wobble-анимации + */ + wobbleSpring?: SpringOptions; + + /** + * Включить jelly-анимацию по клику (деформация scaleX/scaleY) + * @default false + */ + jelly?: boolean; + + /** + * Spring-параметры для jelly-анимации + */ + jellySpring?: SpringOptions; + + /** + * Включить swing-анимацию по клику (маятниковое вращение) + * @default false + */ + swing?: boolean; + + /** + * Spring-параметры для swing-анимации + */ + swingSpring?: SpringOptions; + + /** + * Включить pop-анимацию по клику (резкий отклик масштабом) + * @default false + */ + pop?: boolean; + + /** + * Spring-параметры для pop-анимации + */ + popSpring?: SpringOptions; + + /** + * Включить nod-анимацию по клику (кивок вниз) + * @default false + */ + nod?: boolean; + + /** + * Spring-параметры для nod-анимации + */ + nodSpring?: SpringOptions; + + /** + * Включить rubber-анимацию по клику (растяжение по X) + * @default false + */ + rubber?: boolean; + + /** + * Spring-параметры для rubber-анимации + */ + rubberSpring?: SpringOptions; + + /** + * Вызывается при старте spring-анимации. Получает функцию отмены текущей анимации. + */ + onSpringAnimationStart?: (cancel: () => void) => void; + + /** + * Вызывается при завершении или отмене spring-анимации. + */ + onSpringAnimationEnd?: () => void; }; export type PrivateButtonProps = { From db6ca1528ea9ddd91185bdaa24aa60b85640896a Mon Sep 17 00:00:00 2001 From: Mikhail Lukin Date: Thu, 28 May 2026 17:07:14 +0700 Subject: [PATCH 05/22] feat(button): add motion --- .../button/src/hooks/useSpringAnimation.ts | 46 ++++++++++++------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/packages/button/src/hooks/useSpringAnimation.ts b/packages/button/src/hooks/useSpringAnimation.ts index 76fd7ad5e0..0e0dd21550 100644 --- a/packages/button/src/hooks/useSpringAnimation.ts +++ b/packages/button/src/hooks/useSpringAnimation.ts @@ -1,5 +1,6 @@ import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'; -import { animate } from 'motion'; +import { spring } from 'motion'; +import { animate } from 'motion/mini'; import { type SpringOptions } from '../typings'; @@ -14,7 +15,7 @@ export type AnimationType = | 'nod' | 'rubber'; -type AnimationValues = Record; +type AnimationValues = Record; type AnimationPreset = { defaultSpring: Required; @@ -24,7 +25,11 @@ type AnimationPreset = { const PRESETS: Record = { shake: { defaultSpring: { stiffness: 100, damping: 10, mass: 1 }, - steps: [{ x: [0, 10] }, { x: [10, -10] }, { x: [-10, 0] }], + steps: [ + { translate: ['0px', '10px'] }, + { translate: ['10px', '-10px'] }, + { translate: ['-10px', '0px'] }, + ], }, pulse: { defaultSpring: { stiffness: 300, damping: 15, mass: 1 }, @@ -32,27 +37,32 @@ const PRESETS: Record = { }, bounce: { defaultSpring: { stiffness: 200, damping: 12, mass: 1 }, - steps: [{ y: [0, -14] }, { y: [-14, 0] }], + steps: [{ translate: ['0px 0px', '0px -14px'] }, { translate: ['0px -14px', '0px 0px'] }], }, wobble: { defaultSpring: { stiffness: 150, damping: 8, mass: 1 }, - steps: [{ rotate: [0, -6] }, { rotate: [-6, 6] }, { rotate: [6, -3] }, { rotate: [-3, 0] }], + steps: [ + { rotate: ['0deg', '-6deg'] }, + { rotate: ['-6deg', '6deg'] }, + { rotate: ['6deg', '-3deg'] }, + { rotate: ['-3deg', '0deg'] }, + ], }, jelly: { defaultSpring: { stiffness: 300, damping: 10, mass: 1 }, steps: [ - { scaleX: [1, 1.25], scaleY: [1, 0.75] }, - { scaleX: [1.25, 0.85], scaleY: [0.75, 1.15] }, - { scaleX: [0.85, 1], scaleY: [1.15, 1] }, + { scale: ['1 1', '1.25 0.75'] }, + { scale: ['1.25 0.75', '0.85 1.15'] }, + { scale: ['0.85 1.15', '1 1'] }, ], }, swing: { defaultSpring: { stiffness: 100, damping: 8, mass: 1 }, steps: [ - { rotate: [0, -12] }, - { rotate: [-12, 8] }, - { rotate: [8, -4] }, - { rotate: [-4, 0] }, + { rotate: ['0deg', '-12deg'] }, + { rotate: ['-12deg', '8deg'] }, + { rotate: ['8deg', '-4deg'] }, + { rotate: ['-4deg', '0deg'] }, ], }, pop: { @@ -61,11 +71,15 @@ const PRESETS: Record = { }, nod: { defaultSpring: { stiffness: 200, damping: 12, mass: 1 }, - steps: [{ y: [0, 8] }, { y: [8, 0] }], + steps: [{ translate: ['0px 0px', '0px 8px'] }, { translate: ['0px 8px', '0px 0px'] }], }, rubber: { defaultSpring: { stiffness: 250, damping: 12, mass: 1 }, - steps: [{ scaleX: [1, 0.85] }, { scaleX: [0.85, 1.1] }, { scaleX: [1.1, 1] }], + steps: [ + { scale: ['1 1', '0.85 1'] }, + { scale: ['0.85 1', '1.1 1'] }, + { scale: ['1.1 1', '1 1'] }, + ], }, }; @@ -100,7 +114,7 @@ export function useSpringAnimation( if (ref.current) { animate( ref.current, - { x: 0, y: 0, scale: 1, scaleX: 1, scaleY: 1, rotate: 0 }, + { translate: '0px 0px', scale: '1 1', rotate: '0deg' }, { duration: 0 }, ); } @@ -117,7 +131,7 @@ export function useSpringAnimation( const el = ref.current; const preset = PRESETS[type]; const merged = { ...preset.defaultSpring, ...springOptionsRef.current }; - const springOpts = { type: 'spring' as const, ...merged }; + const springOpts = { type: spring, ...merged }; isPlayingRef.current = true; setIsPlaying(true); From 9cd51d4bfbdd624dba4b564aff0c58ed631c9766 Mon Sep 17 00:00:00 2001 From: Mikhail Lukin Date: Thu, 28 May 2026 17:13:01 +0700 Subject: [PATCH 06/22] feat(button): add motion --- packages/button/src/docs/description.mdx | 31 +++++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/button/src/docs/description.mdx b/packages/button/src/docs/description.mdx index cb9bedbb53..e991721724 100644 --- a/packages/button/src/docs/description.mdx +++ b/packages/button/src/docs/description.mdx @@ -870,6 +870,25 @@ render(() => { Хук реализован в `packages/button/src/hooks/useSpringAnimation.ts` и инкапсулирует всю логику spring-анимаций на основе библиотеки [motion](https://motion.dev/docs/animate). +### Зависимости и размер бандла + +Для уменьшения размера бандла используется `motion/mini` вместо полного `motion`: + +```ts +import { animate } from 'motion/mini'; // WAAPI-обёртка без встроенного spring +import { spring } from 'motion'; // точечный импорт spring-генератора +``` + +`motion/mini` использует браузерный Web Animations API (WAAPI) и весит значительно меньше полного пакета. Однако WAAPI не поддерживает spring-физику нативно, поэтому `spring` импортируется точечно из основного пакета. + +**Ограничение WAAPI:** принимает только валидные CSS-свойства. Motion-шорткаты `x`, `y`, `scaleX`, `scaleY` не работают — вместо них используются CSS Individual Transform Properties: + +- смещение по X → CSS `translate: '10px'` +- смещение по Y → CSS `translate: '0px -14px'` +- независимый scaleX/scaleY → CSS `scale: '1.25 0.75'` + +Требует браузеры с поддержкой CSS Transforms Level 2 (Chrome 104+, Firefox 72+, Safari 14.1+). + ### Зачем хук, а не инлайн-код Каждая анимация — это цепочка вызовов `animate()` (motion не поддерживает больше двух keyframes в spring-режиме). Хук берёт на себя: @@ -949,7 +968,7 @@ const { trigger, cancel, isPlaying } = useSpringAnimation( Горизонтальный сдвиг влево-вправо - x + translate (X) stiffness 100, damping 10 @@ -969,7 +988,7 @@ const { trigger, cancel, isPlaying } = useSpringAnimation( Прыжок вверх и обратно - y + translate (Y) stiffness 200, damping 12 @@ -987,9 +1006,9 @@ const { trigger, cancel, isPlaying } = useSpringAnimation( jelly - Деформация scaleX/scaleY в противофазе + Деформация по X и Y в противофазе - scaleX + scaleY + scale (X Y) stiffness 300, damping 10 @@ -1019,7 +1038,7 @@ const { trigger, cancel, isPlaying } = useSpringAnimation( Кивок вниз и обратно - y + translate (Y) stiffness 200, damping 12 @@ -1029,7 +1048,7 @@ const { trigger, cancel, isPlaying } = useSpringAnimation( Сужение и растяжение по X - scaleX + scale (X 1) stiffness 250, damping 12 From 95712e67e45939e34a33117ad2a37cccf01ebe41 Mon Sep 17 00:00:00 2001 From: Mikhail Lukin Date: Thu, 28 May 2026 17:31:16 +0700 Subject: [PATCH 07/22] feat(button): add motion --- packages/button/src/hooks/useSpringAnimation.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/button/src/hooks/useSpringAnimation.ts b/packages/button/src/hooks/useSpringAnimation.ts index 0e0dd21550..564c89b7e2 100644 --- a/packages/button/src/hooks/useSpringAnimation.ts +++ b/packages/button/src/hooks/useSpringAnimation.ts @@ -103,9 +103,11 @@ export function useSpringAnimation( const animationRef = useRef | null>(null); const callbacksRef = useRef(callbacks); + callbacksRef.current = callbacks; const springOptionsRef = useRef(springOptions); + springOptionsRef.current = springOptions; const cancel = useCallback(() => { From 766fd2de9caf39e4ccc94f5b9ef8b945d94028d5 Mon Sep 17 00:00:00 2001 From: Mikhail Lukin Date: Tue, 23 Jun 2026 14:24:23 +0700 Subject: [PATCH 08/22] feat: concept feat: concept feat: concept feat: concept --- packages/backdrop/src/index.module.css | 13 ++- packages/base-modal/src/Component.tsx | 106 +++++++++++------- .../src/components/base-button/Component.tsx | 3 +- packages/button/src/hooks/index.ts | 2 - packages/button/src/typings.ts | 6 +- packages/shared/src/hooks/index.ts | 2 + .../src/hooks/useSpringAnimation.ts | 86 +++++++++++++- 7 files changed, 163 insertions(+), 55 deletions(-) delete mode 100644 packages/button/src/hooks/index.ts rename packages/{button => shared}/src/hooks/useSpringAnimation.ts (66%) diff --git a/packages/backdrop/src/index.module.css b/packages/backdrop/src/index.module.css index 23c6f7ed92..711d7bebf5 100644 --- a/packages/backdrop/src/index.module.css +++ b/packages/backdrop/src/index.module.css @@ -8,30 +8,31 @@ bottom: var(--gap-0); top: var(--gap-0); left: var(--gap-0); + background-color: var(--backdrop-visible-background); -webkit-tap-highlight-color: transparent; /* убирает хайлайт */ } .appear, .enter { - background-color: var(--backdrop-hidden-background); + opacity: 0; } .appearActive, .enterActive, .appearDone, .enterDone { - background-color: var(--backdrop-visible-background); - transition: background-color 200ms ease-in; + opacity: 1; + transition: opacity 200ms ease-in; } .exit { - background-color: var(--backdrop-visible-background); + opacity: 1; } .exitActive, .exitDone { - background-color: var(--backdrop-hidden-background); - transition: background-color 200ms ease-out; + opacity: 0; + transition: opacity 200ms ease-out; } .transparent.transparent { diff --git a/packages/base-modal/src/Component.tsx b/packages/base-modal/src/Component.tsx index 5c780346a3..e1f7876c65 100644 --- a/packages/base-modal/src/Component.tsx +++ b/packages/base-modal/src/Component.tsx @@ -9,6 +9,7 @@ import React, { type Ref, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -17,14 +18,14 @@ import React, { import FocusLock from 'react-focus-lock'; import mergeRefs from 'react-merge-refs'; import { RemoveScroll } from 'react-remove-scroll'; -import { CSSTransition } from 'react-transition-group'; +// import { CSSTransition } from 'react-transition-group'; import { type CSSTransitionProps } from 'react-transition-group/CSSTransition'; import { ResizeObserver as ResizeObserverPolyfill } from '@juggle/resize-observer'; import cn from 'classnames'; import { Backdrop as DefaultBackdrop, type BackdropProps } from '@alfalab/core-components-backdrop'; import { Portal, type PortalProps } from '@alfalab/core-components-portal'; -import { getScrollbarSize, isIOS } from '@alfalab/core-components-shared'; +import { getScrollbarSize, isIOS, useSpringTransition } from '@alfalab/core-components-shared'; import { Stack } from '@alfalab/core-components-stack'; import { stackingOrder } from '@alfalab/core-components-stack-context'; @@ -300,6 +301,10 @@ export const BaseModal = forwardRef( usePortal = true, iOSLock = false, onWheel, + // @ts-ignore + onSpringStart, + // @ts-ignore + onSpringEnd, }, ref, ) => { @@ -499,6 +504,33 @@ export const BaseModal = forwardRef( [handleScroll, onUnmount, removeResizeHandle, transitionProps], ); + const { playEnter, playExit } = useSpringTransition( + componentNodeRef, + 'slideFromRight', + undefined, + { + onEntered: () => handleEntered(componentNodeRef.current!, false), + onExited: () => handleExited(componentNodeRef.current!), + }, + ); + + useLayoutEffect(() => { + if (open && isExited) { + setExited(false); + } + }, [open, isExited]); + + useLayoutEffect(() => { + if (exited !== false) return; + if (open) { + playEnter(); + onSpringStart(); + } else { + playExit(); + onSpringEnd(); + } + }, [open, exited, playEnter, playExit, onSpringStart, onSpringEnd]); + useEffect(() => { if (open && isExited) { /* @@ -524,8 +556,6 @@ export const BaseModal = forwardRef( restoreContainerStyles(el); }; } - - setExited(false); } if (!open) { @@ -641,46 +671,46 @@ export const BaseModal = forwardRef( zIndex: computedZIndex, }} > - */} +
-
- {children} -
+ {children}
- +
+ {/*
*/}
diff --git a/packages/button/src/components/base-button/Component.tsx b/packages/button/src/components/base-button/Component.tsx index 930ca88935..42feda77b3 100644 --- a/packages/button/src/components/base-button/Component.tsx +++ b/packages/button/src/components/base-button/Component.tsx @@ -10,12 +10,11 @@ import React, { import mergeRefs from 'react-merge-refs'; import cn from 'classnames'; -import { getDataTestId } from '@alfalab/core-components-shared'; +import { getDataTestId, useSpringAnimation } from '@alfalab/core-components-shared'; import { Spinner } from '@alfalab/core-components-spinner'; import { useFocus } from '@alfalab/hooks'; import { LOADER_MIN_DISPLAY_INTERVAL } from '../../constants/loader-min-display-interval'; -import { useSpringAnimation } from '../../hooks'; import { type CommonButtonProps, type PrivateButtonProps } from '../../typings'; import defaultColors from './default.module.css'; diff --git a/packages/button/src/hooks/index.ts b/packages/button/src/hooks/index.ts deleted file mode 100644 index 3e56e7cd6b..0000000000 --- a/packages/button/src/hooks/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { useSpringAnimation } from './useSpringAnimation'; -export type { AnimationType } from './useSpringAnimation'; diff --git a/packages/button/src/typings.ts b/packages/button/src/typings.ts index 4d6faf4a32..8c82994447 100644 --- a/packages/button/src/typings.ts +++ b/packages/button/src/typings.ts @@ -5,11 +5,7 @@ import { type ReactNode, } from 'react'; -export type SpringOptions = { - stiffness?: number; - damping?: number; - mass?: number; -}; +import { type SpringOptions } from '@alfalab/core-components-shared'; export type StyleColors = { default: { diff --git a/packages/shared/src/hooks/index.ts b/packages/shared/src/hooks/index.ts index 533a4a3e98..352b18ac97 100644 --- a/packages/shared/src/hooks/index.ts +++ b/packages/shared/src/hooks/index.ts @@ -8,3 +8,5 @@ export const hooks = { export * from './use-force-update'; export * from './use-ref-as-state'; + +export { useSpringAnimation, useSpringTransition, type SpringOptions } from './useSpringAnimation'; diff --git a/packages/button/src/hooks/useSpringAnimation.ts b/packages/shared/src/hooks/useSpringAnimation.ts similarity index 66% rename from packages/button/src/hooks/useSpringAnimation.ts rename to packages/shared/src/hooks/useSpringAnimation.ts index 564c89b7e2..3eb2c48ff5 100644 --- a/packages/button/src/hooks/useSpringAnimation.ts +++ b/packages/shared/src/hooks/useSpringAnimation.ts @@ -2,9 +2,13 @@ import { type RefObject, useCallback, useEffect, useRef, useState } from 'react' import { spring } from 'motion'; import { animate } from 'motion/mini'; -import { type SpringOptions } from '../typings'; +export type SpringOptions = { + stiffness?: number; + damping?: number; + mass?: number; +}; -export type AnimationType = +type AnimationType = | 'shake' | 'pulse' | 'bounce' @@ -88,6 +92,84 @@ type UseSpringAnimationCallbacks = { onEnd?: () => void; }; +type TransitionAnimationType = 'slideFromRight'; + +type TransitionPreset = { + defaultSpring: Required; + enter: AnimationValues; + exit: AnimationValues; +}; + +const TRANSITION_PRESETS: Record = { + slideFromRight: { + defaultSpring: { stiffness: 320, damping: 28, mass: 1.5 }, + enter: { translate: ['100% 0px', '0px 0px'] }, + exit: { translate: ['0px 0px', '100% 0px'] }, + }, +}; + +type UseSpringTransitionCallbacks = { + onEntered?: () => void; + onExited?: () => void; +}; + +export function useSpringTransition( + ref: RefObject, + type: TransitionAnimationType, + springOptions?: SpringOptions, + callbacks?: UseSpringTransitionCallbacks, +): { + playEnter: () => void; + playExit: () => void; +} { + const animationRef = useRef | null>(null); + const callbacksRef = useRef(callbacks); + callbacksRef.current = callbacks; + const springOptionsRef = useRef(springOptions); + springOptionsRef.current = springOptions; + + const playEnter = useCallback(() => { + if (!ref.current) return; + const preset = TRANSITION_PRESETS[type]; + const merged = { ...preset.defaultSpring, ...springOptionsRef.current }; + + animationRef.current?.cancel(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + animationRef.current = animate(ref.current, preset.enter as any, { + type: spring, + ...merged, + }); + animationRef.current.then(() => { + callbacksRef.current?.onEntered?.(); + }); + }, [ref, type]); + + const playExit = useCallback(() => { + if (!ref.current) return; + const preset = TRANSITION_PRESETS[type]; + const merged = { ...preset.defaultSpring, ...springOptionsRef.current }; + + animationRef.current?.cancel(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + animationRef.current = animate(ref.current, preset.exit as any, { + type: spring, + ...merged, + }); + animationRef.current.then(() => { + callbacksRef.current?.onExited?.(); + }); + }, [ref, type]); + + useEffect( + () => () => { + animationRef.current?.cancel(); + }, + [], + ); + + return { playEnter, playExit }; +} + export function useSpringAnimation( ref: RefObject, type: AnimationType, From 7a3145848793172f584ff524185ff344bdde4b18 Mon Sep 17 00:00:00 2001 From: Mikhail Lukin Date: Tue, 23 Jun 2026 17:57:36 +0700 Subject: [PATCH 09/22] feat: test --- packages/base-modal/src/Component.tsx | 9 ++-- .../src/__snapshots__/component.test.tsx.snap | 4 +- packages/bottom-sheet/src/component.test.tsx | 12 +++--- .../src/__snapshots__/Component.test.tsx.snap | 2 +- .../input-autocomplete/src/Component.test.tsx | 4 +- .../src/__snapshots__/Component.test.tsx.snap | 2 +- .../src/__snapshots__/Component.test.tsx.snap | 4 +- .../src/__snapshots__/Component.test.tsx.snap | 8 ++-- .../src/__snapshots__/Component.test.tsx.snap | 3 +- .../src/__snapshots__/Component.test.tsx.snap | 4 +- tools/jest/setupTests.ts | 41 +++++++++++++++++++ 11 files changed, 69 insertions(+), 24 deletions(-) diff --git a/packages/base-modal/src/Component.tsx b/packages/base-modal/src/Component.tsx index e1f7876c65..d050836af4 100644 --- a/packages/base-modal/src/Component.tsx +++ b/packages/base-modal/src/Component.tsx @@ -226,6 +226,9 @@ export type BaseModalProps = { * Хэндлер события прокрутки колесиком */ onWheel?: (e: WheelEvent) => void; + + onSpringStart?: () => void; + onSpringEnd?: () => void; }; export type BaseModalContext = { @@ -301,9 +304,7 @@ export const BaseModal = forwardRef( usePortal = true, iOSLock = false, onWheel, - // @ts-ignore onSpringStart, - // @ts-ignore onSpringEnd, }, ref, @@ -524,10 +525,10 @@ export const BaseModal = forwardRef( if (exited !== false) return; if (open) { playEnter(); - onSpringStart(); + onSpringStart?.(); } else { playExit(); - onSpringEnd(); + onSpringEnd?.(); } }, [open, exited, playEnter, playExit, onSpringStart, onSpringEnd]); diff --git a/packages/bottom-sheet/src/__snapshots__/component.test.tsx.snap b/packages/bottom-sheet/src/__snapshots__/component.test.tsx.snap index 0a3b828a0a..0f4821abc8 100644 --- a/packages/bottom-sheet/src/__snapshots__/component.test.tsx.snap +++ b/packages/bottom-sheet/src/__snapshots__/component.test.tsx.snap @@ -30,7 +30,7 @@ exports[`Bottom sheet Snapshots tests should match snapshot 1`] = ` tabindex="-1" >