diff --git a/apps/common-app/src/new_api/components/touchable/index.tsx b/apps/common-app/src/new_api/components/touchable/index.tsx
index 2f5b9f52d1..4141b9625c 100644
--- a/apps/common-app/src/new_api/components/touchable/index.tsx
+++ b/apps/common-app/src/new_api/components/touchable/index.tsx
@@ -91,6 +91,60 @@ export default function TouchableExample() {
+
+ Hover states
+ Hover-only feedback (web).
+
+
+
+
+
+
+
+
+
+ Hover + press combined.
+
+
+
+
+
+
+
+
+
+
Android ripple
Configurable ripple effect on Touchable component.
diff --git a/packages/docs-gesture-handler/docs/components/touchable.mdx b/packages/docs-gesture-handler/docs/components/touchable.mdx
index 16b53853a1..a5f179ec19 100644
--- a/packages/docs-gesture-handler/docs/components/touchable.mdx
+++ b/packages/docs-gesture-handler/docs/components/touchable.mdx
@@ -172,6 +172,22 @@ defaultOpacity?: number;
Defines the opacity of the whole component when the button is active. By default set to `1`.
+### activeScale
+
+```ts
+activeScale?: number;
+```
+
+Defines the scale of the whole component when the button is active.
+
+### defaultScale
+
+```ts
+defaultScale?: number;
+```
+
+Defines the scale of the whole component when the button is inactive. By default set to `1`.
+
### activeUnderlayOpacity
```ts
@@ -188,6 +204,46 @@ defaultUnderlayOpacity?: number;
Defines the initial opacity of underlay when the button is inactive. By default set to `0`.
+
+### hoverOpacity
+
+
+```ts
+hoverOpacity?: number;
+```
+
+Defines the opacity of the whole component when the button is hovered. By default falls back to [`defaultOpacity`](#defaultopacity).
+
+
+### hoverScale
+
+
+```ts
+hoverScale?: number;
+```
+
+Defines the scale of the whole component when the button is hovered. By default falls back to [`defaultScale`](#defaultscale).
+
+
+### hoverUnderlayOpacity
+
+
+```ts
+hoverUnderlayOpacity?: number;
+```
+
+Defines the opacity of the underlay when the button is hovered. By default falls back to [`defaultUnderlayOpacity`](#defaultunderlayopacity).
+
+
+### hoverAnimationDuration
+
+
+```ts
+hoverAnimationDuration?: number;
+```
+
+Duration of the hover animation, in milliseconds. By default falls back to [`tapAnimationDuration`](#tapanimationduration).
+
### underlayColor
```ts
diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx
index d2fa0ea62b..4fdb6ab6c1 100644
--- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx
+++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx
@@ -88,6 +88,38 @@ export interface ButtonProps extends ViewProps, AccessibilityProps {
*/
activeUnderlayOpacity?: number | undefined;
+ /**
+ * Web only.
+ *
+ * Opacity applied to the button when it is hovered. Defaults to
+ * `defaultOpacity` when not set.
+ */
+ hoverOpacity?: number | undefined;
+
+ /**
+ * Web only.
+ *
+ * Scale applied to the button when it is hovered. Defaults to
+ * `defaultScale` when not set.
+ */
+ hoverScale?: number | undefined;
+
+ /**
+ * Web only.
+ *
+ * Opacity applied to the underlay when the button is hovered. Defaults
+ * to `defaultUnderlayOpacity` when not set.
+ */
+ hoverUnderlayOpacity?: number | undefined;
+
+ /**
+ * Web only.
+ *
+ * Duration of the hover animation, in milliseconds. Defaults to
+ * `tapAnimationDuration` when not set (or set to any negative value).
+ */
+ hoverAnimationDuration?: number | undefined;
+
/**
* Opacity applied to the button when it is not pressed.
*/
diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx
index 973b999879..68044c1cb8 100644
--- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx
+++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx
@@ -9,9 +9,13 @@ type ButtonProps = ViewProps & {
enabled?: boolean;
pressAndHoldAnimationDuration?: number;
tapAnimationDuration?: number;
+ hoverAnimationDuration?: number;
activeOpacity?: number;
activeScale?: number;
activeUnderlayOpacity?: number;
+ hoverOpacity?: number;
+ hoverScale?: number;
+ hoverUnderlayOpacity?: number;
defaultOpacity?: number;
defaultScale?: number;
defaultUnderlayOpacity?: number;
@@ -23,9 +27,13 @@ export const ButtonComponent = ({
enabled = true,
pressAndHoldAnimationDuration: pressAndHoldAnimationDurationProp = -1,
tapAnimationDuration: tapAnimationDurationProp = 100,
+ hoverAnimationDuration: hoverAnimationDurationProp = -1,
activeOpacity = 1,
activeScale = 1,
activeUnderlayOpacity = 0,
+ hoverOpacity: hoverOpacityProp,
+ hoverScale: hoverScaleProp,
+ hoverUnderlayOpacity: hoverUnderlayOpacityProp,
defaultOpacity = 1,
defaultScale = 1,
defaultUnderlayOpacity = 0,
@@ -40,8 +48,18 @@ export const ButtonComponent = ({
pressAndHoldAnimationDurationProp < 0
? tapAnimationDuration
: pressAndHoldAnimationDurationProp;
+ const hoverAnimationDuration =
+ hoverAnimationDurationProp < 0
+ ? tapAnimationDuration
+ : hoverAnimationDurationProp;
+
+ const hoverOpacity = hoverOpacityProp ?? defaultOpacity;
+ const hoverScale = hoverScaleProp ?? defaultScale;
+ const hoverUnderlayOpacity =
+ hoverUnderlayOpacityProp ?? defaultUnderlayOpacity;
const [pressed, setPressed] = React.useState(false);
+ const [hovered, setHovered] = React.useState(false);
const [currentDuration, setCurrentDuration] = React.useState(
pressAndHoldAnimationDuration
);
@@ -156,14 +174,58 @@ export const ButtonComponent = ({
[pressAndHoldAnimationDuration, tapAnimationDuration]
);
+ const handlePointerEnter = React.useCallback(
+ (event: NativeSyntheticEvent<{ pointerType?: string }>) => {
+ if (!enabled || event.nativeEvent.pointerType === 'touch') {
+ return;
+ }
+ // Skip duration update while pressed so the press transition owns it.
+ if (!pressed) {
+ setCurrentDuration(hoverAnimationDuration);
+ }
+ setHovered(true);
+ },
+ [enabled, pressed, hoverAnimationDuration]
+ );
+
+ const handlePointerLeave = React.useCallback(
+ (event: NativeSyntheticEvent<{ pointerType?: string }>) => {
+ pressOut(event);
+ if (event.nativeEvent.pointerType === 'touch') {
+ return;
+ }
+ if (!pressed) {
+ setCurrentDuration(hoverAnimationDuration);
+ }
+ setHovered(false);
+ },
+ [pressOut, pressed, hoverAnimationDuration]
+ );
+
+ // Mask hover at render rather than clearing the state. Avoids a state
+ // write inside an effect, and lets hover resume naturally when `enabled`
+ // flips back to true while the pointer is still inside.
+ const effectiveHovered = hovered && enabled;
+
const currentUnderlayOpacity = pressed
? activeUnderlayOpacity
- : defaultUnderlayOpacity;
+ : effectiveHovered
+ ? hoverUnderlayOpacity
+ : defaultUnderlayOpacity;
const hasUnderlay = underlayColor != null;
- const hasOpacity = activeOpacity !== 1 || defaultOpacity !== 1;
- const currentOpacity = pressed ? activeOpacity : defaultOpacity;
- const hasScale = activeScale !== 1 || defaultScale !== 1;
- const currentScale = pressed ? activeScale : defaultScale;
+ const hasOpacity =
+ activeOpacity !== 1 || hoverOpacity !== 1 || defaultOpacity !== 1;
+ const currentOpacity = pressed
+ ? activeOpacity
+ : effectiveHovered
+ ? hoverOpacity
+ : defaultOpacity;
+ const hasScale = activeScale !== 1 || hoverScale !== 1 || defaultScale !== 1;
+ const currentScale = pressed
+ ? activeScale
+ : effectiveHovered
+ ? hoverScale
+ : defaultScale;
const easing = 'cubic-bezier(0.5, 1, 0.89, 1)';
const transitionProps: string[] = [];
@@ -177,6 +239,7 @@ export const ButtonComponent = ({
return (
+ onPointerLeave={handlePointerLeave}>
{hasUnderlay && (