diff --git a/docs/react-v9/contributing/rfcs/react-components/convergence/pointer-interactions.md b/docs/react-v9/contributing/rfcs/react-components/convergence/pointer-interactions.md
new file mode 100644
index 0000000000000..64c37e34b104f
--- /dev/null
+++ b/docs/react-v9/contributing/rfcs/react-components/convergence/pointer-interactions.md
@@ -0,0 +1,713 @@
+# RFC: Pointer Interactions
+
+---
+
+@anthropic-assistant
+
+## Contributors
+
+- _Add contributors here_
+
+## Stakeholders
+
+- Fluent UI React v9 consumers
+- Component authors
+- Accessibility team
+
+## Timeline
+
+- **Authored:** January 2026
+- **Feedback deadline:** _TBD_
+
+---
+
+## Summary
+
+This RFC proposes adding interaction hooks to `@fluentui/react-utilities` for handling pointer interactions in a modality-aware way. The primary goal is to solve the "sticky hover" problem on touch devices while providing a comprehensive, semantically correct interaction model across mouse, touch, pen, and keyboard inputs.
+
+The solution is inspired by and based on React Aria's `@react-aria/interactions` package, which has proven effective in production at scale.
+
+## Background
+
+### The Sticky Hover Problem
+
+On touch devices, tapping an element triggers the following browser event sequence:
+
+```
+touchstart → touchend → mouseover → mouseenter → mousemove → mousedown → mouseup → click
+```
+
+The emulated `mouseenter` event causes CSS `:hover` styles to activate and **persist** until the user taps elsewhere. This creates a poor user experience where buttons appear "stuck" in their hover state after being tapped.
+
+### Current State in Fluent UI v9
+
+Fluent UI v9 components use CSS `:hover` pseudo-selectors extensively. For example, `Button` has 80+ hover-related style rules in `useButtonStyles.styles.ts`:
+
+```ts
+// Current approach - problematic on touch devices
+':hover': {
+ backgroundColor: tokens.colorNeutralBackground1Hover,
+ borderColor: tokens.colorNeutralStroke1Hover,
+ color: tokens.colorNeutralForeground1Hover,
+},
+```
+
+### How React Aria Solves This
+
+React Aria's `@react-aria/interactions` package provides hooks that:
+
+1. **Separate hover from press semantically** - Hover is mouse/pen-specific; press is universal
+2. **Use `pointerType` to distinguish input modalities** - `'mouse' | 'pen' | 'touch' | 'keyboard' | 'virtual'`
+3. **Ignore emulated mouse events after touch** - Uses a global flag with 50ms timeout
+4. **Handle browser quirks** - iOS double pointerEnter, Safari scroll issues, etc.
+
+Key insight: **Touch devices don't have a hover concept.** Your finger is either touching or not - there's no "floating over" state. React Aria correctly models this by never firing hover events for touch interactions.
+
+### References
+
+- [React Aria: Building a Button Part 2](https://react-spectrum.adobe.com/blog/building-a-button-part-2.html) - Detailed explanation of the problem and solution
+- [React Aria useHover source](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/interactions/src/useHover.ts)
+- [React Aria usePress source](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/interactions/src/usePress.ts)
+
+## Problem Statement
+
+1. **Sticky hover on touch devices** - CSS `:hover` persists after tap, causing visual bugs
+2. **No semantic distinction between hover and press** - Components conflate preview (hover) with activation (press)
+3. **Inconsistent behavior across input types** - Mouse, touch, pen, and keyboard handled differently
+4. **No unified interaction modality tracking** - Hard to implement focus-visible correctly
+
+These issues affect all interactive components: Button, Link, Input, Checkbox, Radio, Switch, Tab, MenuItem, Slider, and more.
+
+## Detailed Design or Proposal
+
+### Location in react-utilities
+
+Add interaction hooks to `@fluentui/react-utilities` under the `hooks` directory:
+
+```
+@fluentui/react-utilities
+└── src/
+ └── hooks/
+ ├── index.ts
+ ├── useHover.ts # Mouse/pen hover only (excludes touch)
+ └── ... (other hooks)
+```
+
+**Note:** The hook is exported as `useHover_unstable` to indicate its experimental status.
+
+### Opt-in via FluentProvider
+
+The new behavior will be **opt-in** via a feature flag on `FluentProvider`:
+
+```tsx
+import { FluentProvider } from '@fluentui/react-components';
+
+function App() {
+ return (
+ // Opt-in to pointer-aware interactions
+
+
+
+ );
+}
+```
+
+When the flag is enabled:
+
+- Components use `useHover` and `usePress` hooks
+- Hover only fires for mouse/pen, never touch
+- Components render `data-hovered` and `data-pressed` attributes
+- Styles target data attributes instead of `:hover`
+
+When the flag is disabled (default):
+
+- Current CSS `:hover` behavior is preserved
+- No breaking changes for existing consumers
+
+### Hook APIs
+
+#### `useHover_unstable`
+
+```ts
+/**
+ * The type of pointer that triggered the hover event.
+ * Touch is intentionally excluded as hover is not a valid interaction for touch devices.
+ */
+export type PointerType = 'mouse' | 'pen';
+
+/**
+ * Event object passed to hover event handlers.
+ */
+export interface HoverEvent {
+ /** The type of hover event. */
+ type: 'hoverstart' | 'hoverend';
+ /** The pointer type that triggered the event. */
+ pointerType: PointerType;
+ /** The target element. */
+ target: HTMLElement;
+}
+
+/**
+ * Props for the useHover hook.
+ */
+export interface HoverProps {
+ /** Whether hover events should be disabled. */
+ isDisabled?: boolean;
+ /** Handler called when a hover interaction starts. */
+ onHoverStart?: (e: HoverEvent) => void;
+ /** Handler called when a hover interaction ends. */
+ onHoverEnd?: (e: HoverEvent) => void;
+ /** Handler called when the hover state changes. */
+ onHoverChange?: (isHovered: boolean) => void;
+}
+
+/**
+ * Result returned by the useHover hook.
+ */
+export interface HoverResult {
+ /** Props to spread on the target element. */
+ hoverProps: React.DOMAttributes;
+ /** Whether the element is currently hovered. */
+ isHovered: boolean;
+}
+
+function useHover_unstable(props: HoverProps): HoverResult;
+```
+
+#### `usePress` (Future)
+
+```ts
+interface PressProps {
+ /** Whether press events should be disabled. */
+ isDisabled?: boolean;
+ /** Handler called when press starts. */
+ onPressStart?: (e: PressEvent) => void;
+ /** Handler called when press ends. */
+ onPressEnd?: (e: PressEvent) => void;
+ /** Handler called on press up. */
+ onPressUp?: (e: PressEvent) => void;
+ /** Handler called when a press is released over the target. */
+ onPress?: (e: PressEvent) => void;
+ /** Handler called when press state changes. */
+ onPressChange?: (isPressed: boolean) => void;
+ /** Whether the target should not receive focus on press. */
+ preventFocusOnPress?: boolean;
+}
+
+interface PressResult {
+ /** Props to spread on the target element. */
+ pressProps: React.HTMLAttributes;
+ /** Whether the element is currently pressed. */
+ isPressed: boolean;
+}
+
+interface PressEvent {
+ /** The type of press event. */
+ type: 'pressstart' | 'pressend' | 'pressup' | 'press';
+ /** The pointer type that triggered the event. */
+ pointerType: 'mouse' | 'pen' | 'touch' | 'keyboard' | 'virtual';
+ /** The target element. */
+ target: HTMLElement;
+ /** Modifier keys. */
+ shiftKey: boolean;
+ ctrlKey: boolean;
+ metaKey: boolean;
+ altKey: boolean;
+}
+
+function usePress(props: PressProps): PressResult;
+```
+
+#### `useInteractionModality` (Future)
+
+```ts
+type Modality = 'keyboard' | 'pointer' | 'virtual';
+
+/** Returns the current interaction modality (keyboard, pointer, or virtual). */
+function useInteractionModality(): Modality;
+
+/** Returns whether focus should be visible (keyboard navigation). */
+function useFocusVisible(): boolean;
+
+/** Programmatically set the interaction modality. */
+function setInteractionModality(modality: Modality): void;
+```
+
+### Implementation: `useHover_unstable`
+
+The implementation is based on React Aria's approach with the following key features:
+
+1. **Global touch event tracking** - A `pointerup` listener on `document` sets a flag when touch interactions end
+2. **50ms ignore window** - After touch, emulated mouse events are ignored for 50ms (handles iOS quirks)
+3. **Element removal handling** - A `pointerover` listener detects when hovered elements are removed from DOM
+4. **SSR-safe** - Uses `canUseDOM()` to avoid running in server environments
+5. **Cleanup on unmount** - Properly removes event listeners
+
+```ts
+// packages/react-components/react-utilities/src/hooks/useHover.ts
+
+// Global state for ignoring emulated mouse events after touch.
+// iOS fires onPointerEnter twice: once with pointerType="touch" and again with pointerType="mouse".
+let globalIgnoreEmulatedMouseEvents = false;
+let hoverCount = 0;
+
+function setGlobalIgnoreEmulatedMouseEvents() {
+ globalIgnoreEmulatedMouseEvents = true;
+ setTimeout(() => {
+ globalIgnoreEmulatedMouseEvents = false;
+ }, 50);
+}
+
+function handleGlobalPointerEvent(e: PointerEvent) {
+ if (e.pointerType === 'touch') {
+ setGlobalIgnoreEmulatedMouseEvents();
+ }
+}
+
+function setupGlobalTouchEvents() {
+ if (!canUseDOM()) {
+ return;
+ }
+
+ if (hoverCount === 0) {
+ document.addEventListener('pointerup', handleGlobalPointerEvent);
+ }
+
+ hoverCount++;
+
+ return () => {
+ hoverCount--;
+ if (hoverCount === 0) {
+ document.removeEventListener('pointerup', handleGlobalPointerEvent);
+ }
+ };
+}
+
+export function useHover_unstable(props: HoverProps): HoverResult {
+ const { onHoverStart, onHoverChange, onHoverEnd, isDisabled } = props;
+
+ const [isHovered, setHovered] = React.useState(false);
+ const state = React.useRef({
+ isHovered: false,
+ pointerType: '' as string,
+ target: null as HTMLElement | null,
+ });
+
+ // Setup/teardown global touch event listener
+ React.useEffect(setupGlobalTouchEvents, []);
+
+ const hoverProps = React.useMemo>(
+ () => ({
+ onPointerEnter: (e: React.PointerEvent) => {
+ // Ignore emulated mouse events after touch
+ if (globalIgnoreEmulatedMouseEvents && e.pointerType === 'mouse') {
+ return;
+ }
+ // Never trigger hover for touch
+ if (e.pointerType === 'touch' || isDisabled || state.current.isHovered) {
+ return;
+ }
+ // ... trigger hover start
+ },
+ onPointerLeave: (e: React.PointerEvent) => {
+ if (!isDisabled && e.pointerType !== 'touch') {
+ // ... trigger hover end
+ }
+ },
+ }),
+ [isDisabled /* callbacks */],
+ );
+
+ return { hoverProps, isHovered };
+}
+```
+
+### Context Integration
+
+A new context was added to `@fluentui/react-shared-contexts`:
+
+```ts
+// packages/react-components/react-shared-contexts/library/src/PointerInteractionsContext/PointerInteractionsContext.ts
+
+/**
+ * Context value for pointer-aware interaction behavior.
+ * @internal
+ */
+export type PointerInteractionsContextValue = {
+ /**
+ * Whether pointer-aware hover behavior is enabled.
+ * When enabled, hover only fires for mouse/pen interactions, not touch.
+ * This fixes "sticky hover" on touch devices.
+ */
+ usePointerHover: boolean;
+};
+
+const defaultValue: PointerInteractionsContextValue = {
+ usePointerHover: false,
+};
+
+export const PointerInteractionsContext = React.createContext(defaultValue);
+export const PointerInteractionsProvider = PointerInteractionsContext.Provider;
+
+export function usePointerInteractions(): PointerInteractionsContextValue {
+ return React.useContext(PointerInteractionsContext);
+}
+```
+
+Exported from `@fluentui/react-shared-contexts` with `_unstable` suffix:
+
+- `PointerInteractionsProvider_unstable`
+- `usePointerInteractions_unstable`
+- `PointerInteractionsContextValue_unstable`
+
+Update `FluentProvider`:
+
+```ts
+// packages/react-components/react-provider/library/src/components/FluentProvider/FluentProvider.types.ts
+export type FluentProviderProps = {
+ // ... existing props
+
+ /**
+ * Enables pointer-aware hover behavior. When enabled, hover only fires for
+ * mouse/pen interactions, not touch. This fixes "sticky hover" on touch devices.
+ * @default false
+ */
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ UNSTABLE_usePointerHover?: boolean;
+};
+
+// The context is integrated in:
+// - useFluentProvider.ts: reads the prop and passes to state
+// - useFluentProviderContextValues.ts: creates memoized context value
+// - renderFluentProvider.tsx: wraps children with PointerInteractionsProvider
+```
+
+### Component Integration: Button
+
+The Button component was updated to integrate with the pointer-aware hover system:
+
+```tsx
+// packages/react-components/react-button/library/src/components/Button/useButtonBase.ts
+import { useHover_unstable } from '@fluentui/react-utilities';
+import { usePointerInteractions_unstable } from '@fluentui/react-shared-contexts';
+
+export const useButtonBase_unstable = (
+ props: ButtonBaseProps,
+ ref: React.Ref,
+): ButtonBaseState => {
+ const { disabled = false, disabledFocusable = false /* ... */ } = props;
+
+ // Check if pointer-aware hover behavior is enabled via FluentProvider
+ const { usePointerHover } = usePointerInteractions_unstable();
+
+ // Use pointer-aware hover hook - disabled when feature flag is off or button is disabled
+ const { hoverProps, isHovered } = useHover_unstable({
+ isDisabled: !usePointerHover || disabled || disabledFocusable,
+ });
+
+ // Get base ARIA button props
+ const ariaButtonProps = useARIAButtonProps(props.as, props);
+
+ // Merge hover props with ARIA button props when pointer hover is enabled
+ const mergedProps = usePointerHover
+ ? {
+ ...ariaButtonProps,
+ ...hoverProps,
+ // Merge event handlers - ensure both hover and aria handlers are called
+ onPointerEnter: e => {
+ hoverProps.onPointerEnter?.(e);
+ ariaButtonProps.onPointerEnter?.(e);
+ },
+ onPointerLeave: e => {
+ hoverProps.onPointerLeave?.(e);
+ ariaButtonProps.onPointerLeave?.(e);
+ },
+ // Add data-hovered attribute for CSS styling when hovered
+ 'data-hovered': isHovered ? '' : undefined,
+ }
+ : ariaButtonProps;
+
+ return {
+ disabled,
+ disabledFocusable,
+ isHovered,
+ // ...
+ root: slot.always(getIntrinsicElementProps(as, mergedProps), {
+ /* ... */
+ }),
+ };
+};
+```
+
+The `isHovered` state is added to `ButtonState` and `ButtonBaseState` types:
+
+```ts
+// packages/react-components/react-button/library/src/components/Button/Button.types.ts
+export type ButtonState = ComponentState &
+ Required> & {
+ iconOnly: boolean;
+ /**
+ * Whether the button is currently hovered (via pointer-aware hover detection).
+ * This is used when `UNSTABLE_usePointerHover` is enabled on FluentProvider.
+ * @default false
+ */
+ isHovered: boolean;
+ };
+```
+
+### Styling with Data Attributes
+
+The Button styles were updated to include `&[data-hovered]` selectors that mirror the existing `:hover` styles:
+
+```ts
+// packages/react-components/react-button/library/src/components/Button/useButtonStyles.styles.ts
+
+const useRootBaseClassName = makeResetStyles({
+ // ... base styles
+
+ ':hover': {
+ backgroundColor: tokens.colorNeutralBackground1Hover,
+ borderColor: tokens.colorNeutralStroke1Hover,
+ color: tokens.colorNeutralForeground1Hover,
+ cursor: 'pointer',
+ },
+
+ // Pointer-aware hover styles (used when UNSTABLE_usePointerHover is enabled)
+ '&[data-hovered]': {
+ backgroundColor: tokens.colorNeutralBackground1Hover,
+ borderColor: tokens.colorNeutralStroke1Hover,
+ color: tokens.colorNeutralForeground1Hover,
+ cursor: 'pointer',
+ },
+
+ // High contrast styles also include &[data-hovered]
+ '@media (forced-colors: active)': {
+ ':hover': {
+ backgroundColor: 'HighlightText',
+ borderColor: 'Highlight',
+ color: 'Highlight',
+ forcedColorAdjust: 'none',
+ },
+ '&[data-hovered]': {
+ backgroundColor: 'HighlightText',
+ borderColor: 'Highlight',
+ color: 'Highlight',
+ forcedColorAdjust: 'none',
+ },
+ },
+});
+
+// Appearance-specific styles (outline, primary, subtle, transparent)
+// also include &[data-hovered] selectors mirroring :hover
+const useRootStyles = makeStyles({
+ primary: {
+ ':hover': {
+ backgroundColor: tokens.colorBrandBackgroundHover,
+ // ...
+ },
+ '&[data-hovered]': {
+ backgroundColor: tokens.colorBrandBackgroundHover,
+ // ...
+ },
+ },
+ // ... other appearances
+});
+
+// Disabled styles override hover to prevent visual feedback
+const useRootDisabledStyles = makeStyles({
+ base: {
+ ':hover': {
+ backgroundColor: tokens.colorNeutralBackgroundDisabled,
+ cursor: 'not-allowed',
+ },
+ '&[data-hovered]': {
+ backgroundColor: tokens.colorNeutralBackgroundDisabled,
+ cursor: 'not-allowed',
+ },
+ },
+});
+```
+
+**Note:** The styles file does NOT conditionally apply styles based on the feature flag. Both `:hover` and `&[data-hovered]` styles are always present. This approach:
+
+- Avoids runtime style computation overhead
+- Allows smooth transition when flag is enabled/disabled
+- Ensures consistent bundle size regardless of feature flag
+
+### Phased Rollout
+
+#### Phase 1: Foundation (MVP) - COMPLETED
+
+- [x] Add `useHover_unstable` hook to `@fluentui/react-utilities`
+- [x] Add `UNSTABLE_usePointerHover` flag to FluentProvider
+- [x] Add `PointerInteractionsContext` to `@fluentui/react-shared-contexts`
+- [x] Integrate with `Button` component (including all variants via `useButtonBase_unstable`)
+- [x] Update Button styles with `&[data-hovered]` selectors
+
+#### Phase 2: Core Interactions (Future)
+
+- Implement `usePress` hook
+- Implement `useLongPress` hook
+- Integrate with all button variants (ToggleButton, CompoundButton, SplitButton, MenuButton)
+- Integrate with Link component
+
+#### Phase 3: Form Controls
+
+- Integrate with Input, Textarea, Select, Combobox, Dropdown
+- Integrate with Checkbox, Radio, Switch
+- Integrate with Slider, SpinButton
+
+#### Phase 4: Complex Components
+
+- Integrate with Tab, MenuItem, TreeItem
+- Integrate with Card (when interactive)
+- Implement `useMove` for drag interactions
+- Implement `useInteractionModality` for focus-visible coordination
+
+#### Phase 5: Stabilization
+
+- Remove `UNSTABLE_` prefix after sufficient testing
+- Make pointer-aware hover the default behavior
+- Deprecate CSS `:hover` approach in documentation
+
+### Affected Components
+
+All interactive components will need updates:
+
+| Component | Priority | Notes |
+| --------------- | -------- | ------------------------ |
+| Button | High | Primary proof of concept |
+| ToggleButton | High | Same as Button |
+| CompoundButton | High | Same as Button |
+| SplitButton | High | Same as Button |
+| MenuButton | High | Same as Button |
+| Link | High | Similar to Button |
+| Input | Medium | Focus + hover states |
+| Textarea | Medium | Same as Input |
+| Select | Medium | Dropdown hover |
+| Combobox | Medium | Input + dropdown |
+| Dropdown | Medium | Similar to Select |
+| Checkbox | Medium | Toggle interaction |
+| Radio | Medium | Same as Checkbox |
+| Switch | Medium | Same as Checkbox |
+| Slider | Medium | Thumb hover + drag |
+| SpinButton | Medium | Buttons + input |
+| Tab | Medium | Tab hover |
+| MenuItem | Medium | Menu item hover |
+| TreeItem | Low | Tree node hover |
+| Card | Low | When interactive |
+| Tooltip trigger | Low | Hover-triggered |
+
+### Pros and Cons
+
+#### Pros
+
+- **Fixes sticky hover** - The primary goal is achieved
+- **Semantically correct** - Hover and press have distinct meanings
+- **Proven approach** - Based on React Aria's battle-tested implementation
+- **Opt-in** - No breaking changes for existing consumers
+- **Comprehensive** - Provides foundation for future interaction improvements
+- **Modality-aware** - Can correctly handle mouse, touch, pen, and keyboard
+- **Accessibility-ready** - Enables better focus-visible implementation
+
+#### Cons
+
+- **Implementation effort** - Requires updating all interactive components
+- **Style duplication** - Need both CSS `:hover` and `[data-hovered]` styles during transition
+- **Testing complexity** - Touch interactions are harder to test
+- **Learning curve** - Contributors need to understand new patterns
+- **Coordination with Tabster** - Need to ensure compatibility with existing focus management
+
+## Discarded Solutions
+
+### CSS-only: `@media (hover: hover)`
+
+```css
+@media (hover: hover) {
+ .button:hover {
+ background-color: var(--hover-color);
+ }
+}
+```
+
+**Why discarded:**
+
+- Doesn't work on hybrid devices (laptops with touchscreens)
+- The media query reflects device capability, not current interaction modality
+- A touch-capable laptop would still show hover on touch
+
+### CSS-only: `:hover:not(:active)`
+
+```css
+.button:hover:not(:active) {
+ background-color: var(--hover-color);
+}
+```
+
+**Why discarded:**
+
+- Only partially works - hover still persists after touch ends
+- Doesn't distinguish between mouse hover and touch-emulated hover
+
+### Always use JS hover (breaking change)
+
+**Why discarded:**
+
+- Would be a breaking change for all consumers
+- Some consumers may have CSS customizations that depend on `:hover`
+- Risk of regression in existing applications
+
+## Open Issues
+
+### 1. Styling approach
+
+Should hover state be indicated via:
+
+- **Data attribute:** `data-hovered` (recommended)
+- **CSS class:** `.fui-hovered`
+- **CSS variable:** `--fui-hovered: 1`
+
+**Recommendation:** Data attribute for semantic clarity and CSS specificity.
+
+### 2. Coordination with Tabster
+
+Fluent UI already has `useFocusVisible` and `useFocusWithin` in `@fluentui/react-tabster`. How should the interaction hooks in `@fluentui/react-utilities` coordinate with these?
+
+Options:
+
+- Keep focus hooks in react-tabster, interaction hooks in react-utilities
+- Move all interaction-related hooks to react-utilities
+- Create shared modality tracking used by both
+
+### 3. Testing strategy
+
+How to test touch interactions in unit tests?
+
+- JSDOM doesn't support PointerEvents well
+- May need custom test utilities
+- React Aria uses a test-specific fallback branch
+
+### 4. Migration path
+
+When the feature is stabilized:
+
+- Should pointer-aware hover become the default?
+- How long to support the CSS `:hover` fallback?
+- How to communicate the change to consumers?
+
+### 5. Server-side rendering
+
+The global event listeners for touch detection need SSR-safe initialization. Need to ensure:
+
+- No errors during SSR
+- Proper hydration
+- Event listeners attached only on client
+
+---
+
+## References
+
+- [React Aria Interactions](https://react-spectrum.adobe.com/react-aria/interactions.html)
+- [Building a Button Part 2 - React Spectrum Blog](https://react-spectrum.adobe.com/blog/building-a-button-part-2.html)
+- [Fluent UI Button styles](https://github.com/microsoft/fluentui/blob/master/packages/react-components/react-button/library/src/components/Button/useButtonStyles.styles.ts)
+- [CSS :hover media query issues](https://css-tricks.com/touch-devices-not-quite-hover/)
diff --git a/packages/react-components/react-button/library/etc/react-button.api.md b/packages/react-components/react-button/library/etc/react-button.api.md
index 9148c6c57585e..7012aa25b31cd 100644
--- a/packages/react-components/react-button/library/etc/react-button.api.md
+++ b/packages/react-components/react-button/library/etc/react-button.api.md
@@ -47,6 +47,7 @@ export type ButtonSlots = {
// @public (undocumented)
export type ButtonState = ComponentState & Required> & {
iconOnly: boolean;
+ isHovered: boolean;
};
// @public
diff --git a/packages/react-components/react-button/library/src/components/Button/Button.types.ts b/packages/react-components/react-button/library/src/components/Button/Button.types.ts
index 54ff9e596bd81..e854da90ca066 100644
--- a/packages/react-components/react-button/library/src/components/Button/Button.types.ts
+++ b/packages/react-components/react-button/library/src/components/Button/Button.types.ts
@@ -86,6 +86,24 @@ export type ButtonState = ComponentState &
* @default false
*/
iconOnly: boolean;
+
+ /**
+ * Whether the button is currently hovered (via pointer-aware hover detection).
+ * This is used when `UNSTABLE_usePointerHover` is enabled on FluentProvider to provide
+ * touch-safe hover behavior that doesn't trigger on touch interactions.
+ *
+ * @default false
+ */
+ isHovered: boolean;
};
-export type ButtonBaseState = DistributiveOmit;
+export type ButtonBaseState = DistributiveOmit & {
+ /**
+ * Whether the button is currently hovered (via pointer-aware hover detection).
+ * This is used when `UNSTABLE_usePointerHover` is enabled on FluentProvider to provide
+ * touch-safe hover behavior that doesn't trigger on touch interactions.
+ *
+ * @default false
+ */
+ isHovered: boolean;
+};
diff --git a/packages/react-components/react-button/library/src/components/Button/useButtonBase.ts b/packages/react-components/react-button/library/src/components/Button/useButtonBase.ts
index 1492edf41d3da..8aa4802dd68b8 100644
--- a/packages/react-components/react-button/library/src/components/Button/useButtonBase.ts
+++ b/packages/react-components/react-button/library/src/components/Button/useButtonBase.ts
@@ -2,7 +2,8 @@
import * as React from 'react';
import { ARIAButtonSlotProps, useARIAButtonProps } from '@fluentui/react-aria';
-import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities';
+import { getIntrinsicElementProps, slot, useHover_unstable } from '@fluentui/react-utilities';
+import { useOverrides_unstable as useOverrides } from '@fluentui/react-shared-contexts';
import type { ButtonBaseProps, ButtonBaseState } from './Button.types';
/**
@@ -16,15 +17,53 @@ export const useButtonBase_unstable = (
): ButtonBaseState => {
const { as = 'button', disabled = false, disabledFocusable = false, icon, iconPosition = 'before' } = props;
const iconShorthand = slot.optional(icon, { elementType: 'span' });
+
+ // Check if smart hover behavior is enabled via FluentProvider
+ const overrides = useOverrides();
+ const smartHover = overrides.smartHover ?? false;
+ console.log('Smart Hover Enabled:', smartHover);
+
+ // Use smart hover hook - disabled when feature flag is off or button is disabled
+ const { hoverProps, isHovered } = useHover_unstable({
+ isDisabled: !smartHover,
+ });
+
+ console.log('hovered', isHovered);
+
+ console.log(hoverProps);
+
+ // Get base ARIA button props
+ const ariaButtonProps = useARIAButtonProps(props.as, props);
+
+ // Merge hover props with ARIA button props when smart hover is enabled
+ const mergedProps = smartHover
+ ? {
+ ...ariaButtonProps,
+ ...hoverProps,
+ // Merge event handlers - ensure both hover and aria handlers are called
+ onPointerEnter: (e: React.PointerEvent) => {
+ hoverProps.onPointerEnter?.(e as React.PointerEvent);
+ ariaButtonProps.onPointerEnter?.(e);
+ },
+ onPointerLeave: (e: React.PointerEvent) => {
+ hoverProps.onPointerLeave?.(e as React.PointerEvent);
+ ariaButtonProps.onPointerLeave?.(e);
+ },
+ // Add data-hovered attribute for CSS styling when hovered
+ 'data-hovered': isHovered ? '' : undefined,
+ }
+ : ariaButtonProps;
+
return {
// Props passed at the top-level
disabled,
disabledFocusable,
iconPosition,
iconOnly: Boolean(iconShorthand?.children && !props.children),
+ isHovered,
// Slots definition
components: { root: 'button', icon: 'span' },
- root: slot.always>(getIntrinsicElementProps(as, useARIAButtonProps(props.as, props)), {
+ root: slot.always>(getIntrinsicElementProps(as, mergedProps), {
elementType: 'button',
defaultProps: {
ref: ref as React.Ref,
diff --git a/packages/react-components/react-button/library/src/components/Button/useButtonStyles.styles.ts b/packages/react-components/react-button/library/src/components/Button/useButtonStyles.styles.ts
index cb99c3e70d97b..946165e166aa2 100644
--- a/packages/react-components/react-button/library/src/components/Button/useButtonStyles.styles.ts
+++ b/packages/react-components/react-button/library/src/components/Button/useButtonStyles.styles.ts
@@ -52,6 +52,15 @@ const useRootBaseClassName = makeResetStyles({
cursor: 'pointer',
},
+ // Pointer-aware hover styles (used when UNSTABLE_usePointerHover is enabled)
+ '&[data-hovered]': {
+ backgroundColor: tokens.colorNeutralBackground1Hover,
+ borderColor: tokens.colorNeutralStroke1Hover,
+ color: tokens.colorNeutralForeground1Hover,
+
+ cursor: 'pointer',
+ },
+
':hover:active,:active:focus-visible': {
backgroundColor: tokens.colorNeutralBackground1Pressed,
borderColor: tokens.colorNeutralStroke1Pressed,
@@ -92,6 +101,13 @@ const useRootBaseClassName = makeResetStyles({
forcedColorAdjust: 'none',
},
+ '&[data-hovered]': {
+ backgroundColor: 'HighlightText',
+ borderColor: 'Highlight',
+ color: 'Highlight',
+ forcedColorAdjust: 'none',
+ },
+
':hover:active,:active:focus-visible': {
backgroundColor: 'HighlightText',
borderColor: 'Highlight',
@@ -144,6 +160,10 @@ const useRootStyles = makeStyles({
backgroundColor: tokens.colorTransparentBackgroundHover,
},
+ '&[data-hovered]': {
+ backgroundColor: tokens.colorTransparentBackgroundHover,
+ },
+
':hover:active,:active:focus-visible': {
backgroundColor: tokens.colorTransparentBackgroundPressed,
},
@@ -159,6 +179,12 @@ const useRootStyles = makeStyles({
color: tokens.colorNeutralForegroundOnBrand,
},
+ '&[data-hovered]': {
+ backgroundColor: tokens.colorBrandBackgroundHover,
+ ...shorthands.borderColor('transparent'),
+ color: tokens.colorNeutralForegroundOnBrand,
+ },
+
':hover:active,:active:focus-visible': {
backgroundColor: tokens.colorBrandBackgroundPressed,
...shorthands.borderColor('transparent'),
@@ -177,6 +203,12 @@ const useRootStyles = makeStyles({
color: 'Highlight',
},
+ '&[data-hovered]': {
+ backgroundColor: 'HighlightText',
+ ...shorthands.borderColor('Highlight'),
+ color: 'Highlight',
+ },
+
':hover:active,:active:focus-visible': {
backgroundColor: 'HighlightText',
...shorthands.borderColor('Highlight'),
@@ -207,6 +239,21 @@ const useRootStyles = makeStyles({
},
},
+ '&[data-hovered]': {
+ backgroundColor: tokens.colorSubtleBackgroundHover,
+ ...shorthands.borderColor('transparent'),
+ color: tokens.colorNeutralForeground2Hover,
+ [`& .${iconFilledClassName}`]: {
+ display: 'inline',
+ },
+ [`& .${iconRegularClassName}`]: {
+ display: 'none',
+ },
+ [`& .${buttonClassNames.icon}`]: {
+ color: tokens.colorNeutralForeground2BrandHover,
+ },
+ },
+
':hover:active,:active:focus-visible': {
backgroundColor: tokens.colorSubtleBackgroundPressed,
...shorthands.borderColor('transparent'),
@@ -230,6 +277,13 @@ const useRootStyles = makeStyles({
color: 'Highlight',
},
},
+ '&[data-hovered]': {
+ color: 'Highlight',
+
+ [`& .${buttonClassNames.icon}`]: {
+ color: 'Highlight',
+ },
+ },
':hover:active,:active:focus-visible': {
color: 'Highlight',
@@ -256,6 +310,18 @@ const useRootStyles = makeStyles({
},
},
+ '&[data-hovered]': {
+ backgroundColor: tokens.colorTransparentBackgroundHover,
+ ...shorthands.borderColor('transparent'),
+ color: tokens.colorNeutralForeground2BrandHover,
+ [`& .${iconFilledClassName}`]: {
+ display: 'inline',
+ },
+ [`& .${iconRegularClassName}`]: {
+ display: 'none',
+ },
+ },
+
':hover:active,:active:focus-visible': {
backgroundColor: tokens.colorTransparentBackgroundPressed,
...shorthands.borderColor('transparent'),
@@ -273,6 +339,10 @@ const useRootStyles = makeStyles({
backgroundColor: tokens.colorTransparentBackground,
color: 'Highlight',
},
+ '&[data-hovered]': {
+ backgroundColor: tokens.colorTransparentBackground,
+ color: 'Highlight',
+ },
':hover:active,:active:focus-visible': {
backgroundColor: tokens.colorTransparentBackground,
color: 'Highlight',
@@ -349,6 +419,25 @@ const useRootDisabledStyles = makeStyles({
},
},
+ // Disabled buttons should not show hover styles even when data-hovered is present
+ '&[data-hovered]': {
+ backgroundColor: tokens.colorNeutralBackgroundDisabled,
+ ...shorthands.borderColor(tokens.colorNeutralStrokeDisabled),
+ color: tokens.colorNeutralForegroundDisabled,
+
+ cursor: 'not-allowed',
+
+ [`& .${iconFilledClassName}`]: {
+ display: 'none',
+ },
+ [`& .${iconRegularClassName}`]: {
+ display: 'inline',
+ },
+ [`& .${buttonClassNames.icon}`]: {
+ color: tokens.colorNeutralForegroundDisabled,
+ },
+ },
+
':hover:active,:active:focus-visible': {
backgroundColor: tokens.colorNeutralBackgroundDisabled,
...shorthands.borderColor(tokens.colorNeutralStrokeDisabled),
@@ -393,6 +482,16 @@ const useRootDisabledStyles = makeStyles({
},
},
+ '&[data-hovered]': {
+ backgroundColor: 'ButtonFace',
+ ...shorthands.borderColor('GrayText'),
+ color: 'GrayText',
+
+ [`& .${buttonClassNames.icon}`]: {
+ color: 'GrayText',
+ },
+ },
+
':hover:active,:active:focus-visible': {
backgroundColor: 'ButtonFace',
...shorthands.borderColor('GrayText'),
@@ -413,6 +512,10 @@ const useRootDisabledStyles = makeStyles({
backgroundColor: tokens.colorTransparentBackground,
},
+ '&[data-hovered]': {
+ backgroundColor: tokens.colorTransparentBackground,
+ },
+
':hover:active,:active:focus-visible': {
backgroundColor: tokens.colorTransparentBackground,
},
@@ -424,6 +527,10 @@ const useRootDisabledStyles = makeStyles({
...shorthands.borderColor('transparent'),
},
+ '&[data-hovered]': {
+ ...shorthands.borderColor('transparent'),
+ },
+
':hover:active,:active:focus-visible': {
...shorthands.borderColor('transparent'),
},
@@ -440,6 +547,11 @@ const useRootDisabledStyles = makeStyles({
...shorthands.borderColor('transparent'),
},
+ '&[data-hovered]': {
+ backgroundColor: tokens.colorTransparentBackground,
+ ...shorthands.borderColor('transparent'),
+ },
+
':hover:active,:active:focus-visible': {
backgroundColor: tokens.colorTransparentBackground,
...shorthands.borderColor('transparent'),
@@ -454,6 +566,11 @@ const useRootDisabledStyles = makeStyles({
...shorthands.borderColor('transparent'),
},
+ '&[data-hovered]': {
+ backgroundColor: tokens.colorTransparentBackground,
+ ...shorthands.borderColor('transparent'),
+ },
+
':hover:active,:active:focus-visible': {
backgroundColor: tokens.colorTransparentBackground,
...shorthands.borderColor('transparent'),
diff --git a/packages/react-components/react-button/library/src/components/SplitButton/useSplitButtonBase.ts b/packages/react-components/react-button/library/src/components/SplitButton/useSplitButtonBase.ts
index e2be9ccae3737..356aa5655229f 100644
--- a/packages/react-components/react-button/library/src/components/SplitButton/useSplitButtonBase.ts
+++ b/packages/react-components/react-button/library/src/components/SplitButton/useSplitButtonBase.ts
@@ -63,6 +63,8 @@ export const useSplitButtonBase_unstable = (
disabled,
disabledFocusable,
iconPosition,
+ // SplitButton container doesn't track hover state - child buttons handle their own hover
+ isHovered: false,
components: { root: 'div', menuButton: MenuButton, primaryActionButton: Button },
root: slot.always(getIntrinsicElementProps('div', { ref, ...props }), { elementType: 'div' }),
menuButton: menuButtonShorthand,
diff --git a/packages/react-components/react-button/stories/src/Button/ButtonDefault.stories.tsx b/packages/react-components/react-button/stories/src/Button/ButtonDefault.stories.tsx
index cda4373a9d055..58d5c80c63130 100644
--- a/packages/react-components/react-button/stories/src/Button/ButtonDefault.stories.tsx
+++ b/packages/react-components/react-button/stories/src/Button/ButtonDefault.stories.tsx
@@ -1,6 +1,14 @@
import * as React from 'react';
import type { JSXElement } from '@fluentui/react-components';
-import { Button } from '@fluentui/react-components';
+import { Button, FluentProvider } from '@fluentui/react-components';
import type { ButtonProps } from '@fluentui/react-components';
-export const Default = (props: ButtonProps): JSXElement => ;
+export const Default = (props: ButtonProps): JSXElement => (
+
+
+
+);
diff --git a/packages/react-components/react-shared-contexts/library/src/OverridesContext/OverridesContext.ts b/packages/react-components/react-shared-contexts/library/src/OverridesContext/OverridesContext.ts
index 618b8b694be96..21d8e67c4ba12 100644
--- a/packages/react-components/react-shared-contexts/library/src/OverridesContext/OverridesContext.ts
+++ b/packages/react-components/react-shared-contexts/library/src/OverridesContext/OverridesContext.ts
@@ -8,6 +8,7 @@ import * as React from 'react';
export type OverridesContextValue = {
// No 'underline' as it is not supported by TextArea
inputDefaultAppearance?: 'outline' | 'filled-darker' | 'filled-lighter';
+ smartHover?: boolean;
};
/**
diff --git a/packages/react-components/react-utilities/src/hooks/index.ts b/packages/react-components/react-utilities/src/hooks/index.ts
index 2985ecf995dbe..88049d3a9a6e4 100644
--- a/packages/react-components/react-utilities/src/hooks/index.ts
+++ b/packages/react-components/react-utilities/src/hooks/index.ts
@@ -5,6 +5,8 @@ export { useControllableState } from './useControllableState';
export { useEventCallback } from './useEventCallback';
export { useFirstMount } from './useFirstMount';
export { useForceUpdate } from './useForceUpdate';
+export type { HoverEvent, HoverProps, HoverResult, PointerType } from './useHover';
+export { useHover_unstable } from './useHover';
export { IdPrefixProvider, resetIdsForTests, useId } from './useId';
export { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect';
export type { RefObjectFunction } from './useMergedRefs';
diff --git a/packages/react-components/react-utilities/src/hooks/useHover.ts b/packages/react-components/react-utilities/src/hooks/useHover.ts
new file mode 100644
index 0000000000000..bd602d45647d8
--- /dev/null
+++ b/packages/react-components/react-utilities/src/hooks/useHover.ts
@@ -0,0 +1,229 @@
+'use client';
+
+import * as React from 'react';
+import { useEventCallback } from './useEventCallback';
+import { canUseDOM } from '../ssr/canUseDOM';
+import { elementContains } from '../virtualParent/elementContains';
+
+/**
+ * The type of pointer that triggered the hover event.
+ * Touch is intentionally excluded as hover is not a valid interaction for touch devices.
+ */
+export type PointerType = 'mouse' | 'pen';
+
+/**
+ * Event object passed to hover event handlers.
+ */
+export interface HoverEvent {
+ /** The type of hover event. */
+ type: 'hoverstart' | 'hoverend';
+ /** The pointer type that triggered the event. */
+ pointerType: PointerType;
+ /** The target element. */
+ target: HTMLElement;
+}
+
+/**
+ * Props for the useHover hook.
+ */
+export interface HoverProps {
+ /** Whether hover events should be disabled. */
+ isDisabled?: boolean;
+ /** Handler called when a hover interaction starts. */
+ onHoverStart?: (e: HoverEvent) => void;
+ /** Handler called when a hover interaction ends. */
+ onHoverEnd?: (e: HoverEvent) => void;
+ /** Handler called when the hover state changes. */
+ onHoverChange?: (isHovered: boolean) => void;
+}
+
+/**
+ * Result returned by the useHover hook.
+ */
+export interface HoverResult {
+ /** Props to spread on the target element. */
+ hoverProps: React.DOMAttributes;
+ /** Whether the element is currently hovered. */
+ isHovered: boolean;
+}
+
+// Global state for ignoring emulated mouse events after touch.
+// iOS fires onPointerEnter twice: once with pointerType="touch" and again with pointerType="mouse".
+// We want to ignore these emulated events so they do not trigger hover behavior.
+// See https://bugs.webkit.org/show_bug.cgi?id=214609
+let globalIgnoreEmulatedMouseEvents = false;
+let hoverCount = 0;
+
+function setGlobalIgnoreEmulatedMouseEvents() {
+ globalIgnoreEmulatedMouseEvents = true;
+
+ // Clear globalIgnoreEmulatedMouseEvents after a short timeout. iOS fires onPointerEnter
+ // with pointerType="mouse" immediately after onPointerUp and before onFocus. On other
+ // devices that don't have this quirk, we don't want to ignore a mouse hover sometime in
+ // the distant future because a user previously touched the element.
+ setTimeout(() => {
+ globalIgnoreEmulatedMouseEvents = false;
+ }, 50);
+}
+
+function handleGlobalPointerEvent(e: PointerEvent) {
+ if (e.pointerType === 'touch') {
+ setGlobalIgnoreEmulatedMouseEvents();
+ }
+}
+
+function setupGlobalTouchEvents() {
+ if (!canUseDOM()) {
+ return;
+ }
+
+ if (hoverCount === 0) {
+ document.addEventListener('pointerup', handleGlobalPointerEvent);
+ }
+
+ hoverCount++;
+
+ return () => {
+ hoverCount--;
+ if (hoverCount === 0) {
+ document.removeEventListener('pointerup', handleGlobalPointerEvent);
+ }
+ };
+}
+
+/**
+ * Handles pointer hover interactions for an element. Normalizes behavior
+ * across browsers and platforms, and ignores emulated mouse events on touch devices.
+ *
+ * This hook only triggers hover for mouse and pen pointer types, never for touch.
+ * This fixes the "sticky hover" problem on touch devices where CSS :hover persists
+ * after tapping an element.
+ *
+ * @param props - Props for the hover interaction
+ * @returns Hover state and props to spread on the target element
+ */
+export function useHover_unstable(props: HoverProps): HoverResult {
+ const { onHoverStart, onHoverChange, onHoverEnd, isDisabled } = props;
+ const [isHovered, setHovered] = React.useState(false);
+
+ const state = React.useRef({
+ isHovered: false,
+ pointerType: '',
+ target: null as HTMLElement | null,
+ }).current;
+
+ // Setup/teardown global touch event listener
+ React.useEffect(setupGlobalTouchEvents, []);
+
+ // Ref to store cleanup function for global pointerover listener
+ const removePointerOverListener = React.useRef<(() => void) | null>(null);
+
+ const triggerHoverEnd = useEventCallback((pointerType: string) => {
+ console.log('triggerHoverEnd', { pointerType, isDisabled, currentHovered: state.isHovered });
+ const target = state.target;
+ state.pointerType = '';
+ state.target = null;
+
+ if (pointerType === 'touch' || !state.isHovered || !target) {
+ return;
+ }
+
+ state.isHovered = false;
+
+ // Cleanup global listener
+ removePointerOverListener.current?.();
+ removePointerOverListener.current = null;
+
+ onHoverEnd?.({
+ type: 'hoverend',
+ target,
+ pointerType: pointerType as PointerType,
+ });
+
+ onHoverChange?.(false);
+ setHovered(false);
+ });
+
+ const triggerHoverStart = useEventCallback((event: React.PointerEvent, pointerType: string) => {
+ console.log('triggerHoverStart', { pointerType, isDisabled, currentHovered: state.isHovered });
+ state.pointerType = pointerType;
+
+ if (
+ isDisabled ||
+ pointerType === 'touch' ||
+ state.isHovered ||
+ !elementContains(event.currentTarget, event.target as HTMLElement)
+ ) {
+ return;
+ }
+
+ state.isHovered = true;
+ state.target = event.currentTarget;
+
+ // When an element that is hovered over is removed, no pointerleave event is fired by the browser,
+ // even though the originally hovered target may have shrunk in size so it is no longer hovered.
+ // However, a pointerover event will be fired on the new target the mouse is over.
+ // We use this to detect when hover should end due to element removal.
+ const doc = event.currentTarget.ownerDocument;
+ const onPointerOver = (e: PointerEvent) => {
+ if (state.isHovered && state.target && !elementContains(state.target, e.target as HTMLElement)) {
+ triggerHoverEnd(e.pointerType);
+ }
+ };
+
+ doc.addEventListener('pointerover', onPointerOver, true);
+ removePointerOverListener.current = () => {
+ doc.removeEventListener('pointerover', onPointerOver, true);
+ };
+
+ onHoverStart?.({
+ type: 'hoverstart',
+ target: event.currentTarget,
+ pointerType: pointerType as PointerType,
+ });
+
+ onHoverChange?.(true);
+ setHovered(true);
+ });
+
+ // Cleanup on unmount
+ React.useEffect(() => {
+ return () => {
+ removePointerOverListener.current?.();
+ };
+ }, []);
+
+ // End hover when disabled changes to true
+ React.useEffect(() => {
+ if (isDisabled && state.isHovered) {
+ triggerHoverEnd(state.pointerType);
+ }
+ }, [isDisabled, triggerHoverEnd]);
+
+ const hoverProps = React.useMemo>(
+ () => ({
+ onPointerEnter: (e: React.PointerEvent) => {
+ // Ignore emulated mouse events after touch
+ console.log('entered');
+ if (globalIgnoreEmulatedMouseEvents && e.pointerType === 'mouse') {
+ return;
+ }
+ triggerHoverStart(e, e.pointerType as PointerType);
+ },
+ onPointerLeave: (e: React.PointerEvent) => {
+ console.log('leave');
+ if (!isDisabled && elementContains(e.currentTarget, e.target as HTMLElement)) {
+ triggerHoverEnd(e.pointerType);
+ }
+ },
+ }),
+ [isDisabled, triggerHoverStart, triggerHoverEnd],
+ );
+
+ console.log('count', hoverCount);
+
+ return {
+ hoverProps,
+ isHovered,
+ };
+}
diff --git a/packages/react-components/react-utilities/src/index.ts b/packages/react-components/react-utilities/src/index.ts
index 68d394bf67003..f50a26e0af6b0 100644
--- a/packages/react-components/react-utilities/src/index.ts
+++ b/packages/react-components/react-utilities/src/index.ts
@@ -49,6 +49,7 @@ export {
useEventCallback,
useFirstMount,
useForceUpdate,
+ useHover_unstable,
useId,
useIsomorphicLayoutEffect,
useMergedRefs,
@@ -58,7 +59,15 @@ export {
useScrollbarWidth,
useTimeout,
} from './hooks/index';
-export type { RefObjectFunction, UseControllableStateOptions, UseOnClickOrScrollOutsideOptions } from './hooks/index';
+export type {
+ HoverEvent,
+ HoverProps,
+ HoverResult,
+ PointerType,
+ RefObjectFunction,
+ UseControllableStateOptions,
+ UseOnClickOrScrollOutsideOptions,
+} from './hooks/index';
export { canUseDOM, useIsSSR, SSRProvider } from './ssr/index';