diff --git a/src/components/ThreeDotsMenu/index.tsx b/src/components/ThreeDotsMenu/index.tsx
index abf10edd2052..5245e35be926 100644
--- a/src/components/ThreeDotsMenu/index.tsx
+++ b/src/components/ThreeDotsMenu/index.tsx
@@ -34,6 +34,11 @@ function ThreeDotsMenu({
     icon,
     iconFill,
     iconStyles,
+    iconHoverStyle,
+    iconWidth,
+    iconHeight,
+    shouldChangeFillOnOpen = true,
+    testID,
     onIconPress = () => {},
     menuItems,
     anchorPosition,
@@ -140,6 +145,13 @@ function ThreeDotsMenu({
         });
     }, [windowWidth, windowHeight, shouldSelfPosition, getMenuPosition, isPopupMenuVisible]);
 
+    const getIconFill = () => {
+        if (!shouldChangeFillOnOpen) {
+            return iconFill ?? theme.icon;
+        }
+        return (iconFill ?? isPopupMenuVisible) ? theme.success : theme.icon;
+    };
+
     const TooltipToRender = shouldShowProductTrainingTooltip ? EducationalTooltip : Tooltip;
     const tooltipProps = shouldShowProductTrainingTooltip
         ? {
@@ -172,14 +184,18 @@ function ThreeDotsMenu({
                         }}
                         ref={buttonRef}
                         style={[styles.touchableButtonImage, styles.threeDotsMenuIconWidth, iconStyles]}
+                        hoverStyle={iconHoverStyle}
                         role={getButtonRole(isNested)}
                         isNested={isNested}
                         accessibilityLabel={translate(iconTooltip)}
                         sentryLabel={sentryLabel}
+                        testID={testID}
                     >
                         <Icon
                             src={icon ?? expensifyIcons.ThreeDots}
-                            fill={(iconFill ?? isPopupMenuVisible) ? theme.success : theme.icon}
+                            fill={getIconFill()}
+                            width={iconWidth}
+                            height={iconHeight}
                         />
                     </PressableWithoutFeedback>
                 </TooltipToRender>
diff --git a/src/components/ThreeDotsMenu/types.ts b/src/components/ThreeDotsMenu/types.ts
index b9df040f0b2c..92d8ce9e357e 100644
--- a/src/components/ThreeDotsMenu/types.ts
+++ b/src/components/ThreeDotsMenu/types.ts
@@ -18,9 +18,24 @@ type ThreeDotsMenuProps = WithSentryLabel & {
     /** Any additional styles to pass to the icon container. */
     iconStyles?: StyleProp<ViewStyle>;
 
+    /** Hover style applied to the trigger (e.g. a ghost-button background). */
+    iconHoverStyle?: StyleProp<ViewStyle>;
+
     /** The fill color to pass into the icon. */
     iconFill?: string;
 
+    /** The width of the trigger icon. Defaults to the standard icon size. */
+    iconWidth?: number;
+
+    /** The height of the trigger icon. Defaults to the standard icon size. */
+    iconHeight?: number;
+
+    /** Whether the trigger icon turns green while the menu is open. Defaults to true. */
+    shouldChangeFillOnOpen?: boolean;
+
+    /** Test ID for the trigger */
+    testID?: string;
+
     /** Function to call on icon press */
     onIconPress?: (() => void) | ((e?: GestureResponderEvent | KeyboardEvent | undefined) => void);
 
diff --git a/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx b/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx
index ec1f95fe1bca..ce2d030a2146 100644
--- a/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx
+++ b/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx
@@ -1,5 +1,6 @@
 import ReceiptImage from '@components/ReceiptImage';
 import ReceiptPreview from '@components/TransactionItemRow/ReceiptPreview';
+import type {AnchorPosition} from '@components/TransactionItemRow/types';
 
 import useHover from '@hooks/useHover';
 import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
@@ -18,7 +19,7 @@ import type {Transaction} from '@src/types/onyx';
 import type {ViewStyle} from 'react-native';
 
 import {Str} from 'expensify-common';
-import React, {useState} from 'react';
+import React, {useRef, useState} from 'react';
 import {View} from 'react-native';
 
 function ReceiptCell({
@@ -46,11 +47,16 @@ function ReceiptCell({
     // ReceiptPreview handles its own visibility via debounced state, so keeping it
     // mounted avoids re-creating the portal and reloading images on subsequent hovers.
     const [shouldMountPreview, setShouldMountPreview] = useState(false);
+    const cellRef = useRef<View>(null);
+    // The preview is a document.body portal, so it needs the hovered cell's window position to
+    // anchor itself beside the row instead of sitting fixed in the upper-left corner.
+    const [previewAnchor, setPreviewAnchor] = useState<AnchorPosition>();
 
     const handleMouseEnter = () => {
         if (!shouldMountPreview) {
             setShouldMountPreview(true);
         }
+        cellRef.current?.measureInWindow((left, top, width, height) => setPreviewAnchor({left, top, width, height}));
         bind.onMouseEnter();
     };
 
@@ -69,6 +75,7 @@ function ReceiptCell({
 
     return (
         <View
+            ref={cellRef}
             style={[
                 StyleUtils.getWidthAndHeightStyle(isLargeScreenWidth ? variables.w28 : variables.h36, isLargeScreenWidth ? variables.h32 : variables.w40),
                 StyleUtils.getBorderRadiusStyle(variables.componentBorderRadiusSmall),
@@ -103,6 +110,7 @@ function ReceiptCell({
                     hovered={hovered && shouldShowPreview}
                     isEReceipt={!!isEReceipt}
                     transactionItem={transactionItem}
+                    anchorPosition={previewAnchor}
                 />
             )}
         </View>
diff --git a/src/components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition.ts b/src/components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition.ts
new file mode 100644
index 000000000000..9eee1e74de7c
--- /dev/null
+++ b/src/components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition.ts
@@ -0,0 +1,51 @@
+import type {AnchorPosition} from '@components/TransactionItemRow/types';
+
+import variables from '@styles/variables';
+
+/** Width of the preview, shared with `styles.receiptPreview.width` via `variables.receiptPreviewWidth`. */
+const RECEIPT_PREVIEW_WIDTH = variables.receiptPreviewWidth;
+
+/** Horizontal gap between the hovered thumbnail and the preview. */
+const RECEIPT_PREVIEW_GAP = 16;
+
+/** Gap kept between the preview and the viewport edges. */
+const RECEIPT_PREVIEW_EDGE_MARGIN = 24;
+
+/** Minimum slice of the preview kept on-screen so a row near the bottom doesn't push it fully out of view. */
+const RECEIPT_PREVIEW_MIN_VISIBLE_HEIGHT = 160;
+
+/**
+ * Anchors the preview's bottom-left corner to the right of the hovered thumbnail, so the preview grows upward
+ * from the row. If there isn't room on the right, it flips to the left of the thumbnail. The top is clamped to a
+ * viewport margin so a tall receipt near the top of the screen never runs off the top edge. Returns undefined
+ * when there is no anchor, so the caller keeps the static style.
+ *
+ * @param previewHeight Measured height of the preview. 0 means "not measured yet" — we can't bottom-align without
+ * it, so we fall back to aligning the top with the row (keeping a minimum slice on-screen) for the first frame.
+ */
+function getAnchoredPreviewPosition(anchorPosition: AnchorPosition | undefined, windowWidth: number, windowHeight: number, previewHeight = 0) {
+    if (!anchorPosition) {
+        return undefined;
+    }
+
+    const rightOfThumbnail = anchorPosition.left + anchorPosition.width + RECEIPT_PREVIEW_GAP;
+    const overflowsRight = windowWidth > 0 && rightOfThumbnail + RECEIPT_PREVIEW_WIDTH + RECEIPT_PREVIEW_EDGE_MARGIN > windowWidth;
+    const left = overflowsRight ? Math.max(RECEIPT_PREVIEW_EDGE_MARGIN, anchorPosition.left - RECEIPT_PREVIEW_WIDTH - RECEIPT_PREVIEW_GAP) : rightOfThumbnail;
+
+    // Before it's measured we can't bottom-align, so keep a minimum slice on-screen relative to the row top.
+    if (previewHeight <= 0) {
+        const lowestTop = windowHeight - RECEIPT_PREVIEW_MIN_VISIBLE_HEIGHT;
+        return {left, top: Math.min(Math.max(anchorPosition.top, RECEIPT_PREVIEW_EDGE_MARGIN), Math.max(RECEIPT_PREVIEW_EDGE_MARGIN, lowestTop))};
+    }
+
+    // Align the preview's bottom edge with the thumbnail's bottom (bottom-left corner sits at the thumbnail's
+    // right side). Cap the thumbnail bottom to the viewport so a row scrolled partially off the bottom doesn't
+    // push the preview below the screen, then clamp to the top margin so a tall preview stays on-screen.
+    const thumbnailBottom = Math.min(anchorPosition.top + anchorPosition.height, windowHeight - RECEIPT_PREVIEW_EDGE_MARGIN);
+    const top = Math.max(RECEIPT_PREVIEW_EDGE_MARGIN, thumbnailBottom - previewHeight);
+
+    return {left, top};
+}
+
+export default getAnchoredPreviewPosition;
+export {RECEIPT_PREVIEW_WIDTH, RECEIPT_PREVIEW_GAP, RECEIPT_PREVIEW_EDGE_MARGIN, RECEIPT_PREVIEW_MIN_VISIBLE_HEIGHT};
diff --git a/src/components/TransactionItemRow/ReceiptPreview/index.tsx b/src/components/TransactionItemRow/ReceiptPreview/index.tsx
index cd92ce192175..bf061fa44121 100644
--- a/src/components/TransactionItemRow/ReceiptPreview/index.tsx
+++ b/src/components/TransactionItemRow/ReceiptPreview/index.tsx
@@ -2,6 +2,7 @@ import ActivityIndicator from '@components/ActivityIndicator';
 import DistanceEReceipt from '@components/DistanceEReceipt';
 import EReceiptWithSizeCalculation from '@components/EReceiptWithSizeCalculation';
 import type {ImageOnLoadEvent} from '@components/Image/types';
+import type {AnchorPosition} from '@components/TransactionItemRow/types';
 
 import useDebouncedState from '@hooks/useDebouncedState';
 import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP';
@@ -25,6 +26,8 @@ import ReactDOM from 'react-dom';
 import {StyleSheet, View} from 'react-native';
 import Animated, {FadeIn, FadeOut} from 'react-native-reanimated';
 
+import getAnchoredPreviewPosition from './getAnchoredPreviewPosition';
+
 type ReceiptPreviewProps = {
     /** Path to the image to be opened in the preview */
     source: ReceiptSource;
@@ -37,9 +40,12 @@ type ReceiptPreviewProps = {
 
     /** Transaction object related to the preview */
     transactionItem: Transaction;
+
+    /** Window position of the hovered cell. When set, the preview is anchored to the right of the row instead of the fixed upper-left corner. */
+    anchorPosition?: AnchorPosition;
 };
 
-function ReceiptPreview({source, hovered, isEReceipt = false, transactionItem}: ReceiptPreviewProps) {
+function ReceiptPreview({source, hovered, isEReceipt = false, transactionItem, anchorPosition}: ReceiptPreviewProps) {
     const isDistanceEReceipt = isDistanceRequest(transactionItem) && !isManualDistanceRequest(transactionItem);
     const isPerDiemEReceipt = isPerDiemRequest(transactionItem) && !hasReceiptSource(transactionItem) && !!transactionItem.transactionID;
     const styles = useThemeStyles();
@@ -49,8 +55,10 @@ function ReceiptPreview({source, hovered, isEReceipt = false, transactionItem}:
     const [shouldShow, debounceShouldShow, setShouldShow] = useDebouncedState(false, CONST.TIMING.SHOW_HOVER_PREVIEW_DELAY);
     const {shouldUseNarrowLayout} = useResponsiveLayoutOnWideRHP();
     const hasMeasured = useRef(false);
-    const {windowHeight} = useWindowDimensions();
+    const {windowWidth, windowHeight} = useWindowDimensions();
     const [isLoading, setIsLoading] = useState(true);
+    // Measured preview height, used to clamp the vertical position so a tall receipt never runs off the bottom edge.
+    const [previewHeight, setPreviewHeight] = useState(0);
 
     const handleDistanceEReceiptLayout = (e: LayoutChangeEvent) => {
         if (hasMeasured.current) {
@@ -116,11 +124,14 @@ function ReceiptPreview({source, hovered, isEReceipt = false, transactionItem}:
     const shouldShowDistanceEReceipt = isDistanceEReceipt && !isEReceipt && !isPerDiemEReceipt;
     const sourceObject = typeof source === 'string' ? {uri: source} : source;
 
+    const anchoredPositionStyle = getAnchoredPreviewPosition(anchorPosition, windowWidth, windowHeight, previewHeight);
+
     return ReactDOM.createPortal(
         <Animated.View
             entering={FadeIn.duration(CONST.TIMING.SHOW_HOVER_PREVIEW_ANIMATION_DURATION)}
             exiting={FadeOut.duration(CONST.TIMING.SHOW_HOVER_PREVIEW_ANIMATION_DURATION)}
-            style={[styles.receiptPreview, styles.flexColumn, styles.alignItemsCenter, styles.justifyContentStart]}
+            onLayout={(e) => setPreviewHeight(e.nativeEvent.layout.height)}
+            style={[styles.receiptPreview, styles.flexColumn, styles.alignItemsCenter, styles.justifyContentStart, anchoredPositionStyle]}
         >
             {shouldShowImage ? (
                 <View style={[styles.w100]}>
diff --git a/src/components/TransactionItemRow/types.ts b/src/components/TransactionItemRow/types.ts
index 7b0b96d5c4a4..80f9a8757161 100644
--- a/src/components/TransactionItemRow/types.ts
+++ b/src/components/TransactionItemRow/types.ts
@@ -120,6 +120,9 @@ type TransactionItemRowProps = {
     canEditTag?: boolean;
 };
 
+/** Window position of the hovered cell used to anchor the receipt preview beside the row. */
+type AnchorPosition = {top: number; left: number; width: number; height: number};
+
 /**
  * Data computed by the dispatcher and consumed by the Narrow variant.
  */
@@ -148,4 +151,4 @@ type TransactionItemRowWideComputedData = Omit<TransactionItemRowNarrowComputedD
     isMarkAsDone: boolean;
 };
 
-export type {TransactionWithOptionalSearchFields, TransactionItemRowProps, TransactionItemRowNarrowComputedData, TransactionItemRowWideComputedData};
+export type {AnchorPosition, TransactionWithOptionalSearchFields, TransactionItemRowProps, TransactionItemRowNarrowComputedData, TransactionItemRowWideComputedData};
diff --git a/src/pages/home/RecentlyAddedSection/index.tsx b/src/pages/home/RecentlyAddedSection/index.tsx
index 1966de8f2454..cf392bc04c0f 100644
--- a/src/pages/home/RecentlyAddedSection/index.tsx
+++ b/src/pages/home/RecentlyAddedSection/index.tsx
@@ -1,6 +1,4 @@
 import Icon from '@components/Icon';
-import PopoverMenu from '@components/PopoverMenu';
-import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
 import Text from '@components/Text';
 import {useWideRHPActions} from '@components/WideRHPContextProvider';
 import WidgetContainer from '@components/WidgetContainer';
@@ -10,7 +8,6 @@ import useIsAnonymousUser from '@hooks/useIsAnonymousUser';
 import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
 import useLocalize from '@hooks/useLocalize';
 import useOnyx from '@hooks/useOnyx';
-import usePopoverPosition from '@hooks/usePopoverPosition';
 import useResponsiveLayout from '@hooks/useResponsiveLayout';
 import useStyleUtils from '@hooks/useStyleUtils';
 import useTheme from '@hooks/useTheme';
@@ -23,7 +20,8 @@ import {buildQueryStringFromFilterFormValues} from '@libs/SearchQueryUtils';
 import type {TransactionThreadNavigationDescriptor} from '@libs/TransactionThreadNavigationUtils';
 import {getReportIDToOpenForExpense} from '@libs/TransactionThreadNavigationUtils';
 
-import type {AnchorPosition} from '@styles/index';
+import WidgetHeaderMenu from '@pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu';
+
 import variables from '@styles/variables';
 
 import CONST from '@src/CONST';
@@ -31,7 +29,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
 import ROUTES from '@src/ROUTES';
 
 import {useIsFocused} from '@react-navigation/native';
-import React, {useRef, useState} from 'react';
+import React from 'react';
 import {View} from 'react-native';
 
 import type {RecentlyAddedExpense} from './useRecentlyAddedData';
@@ -42,15 +40,6 @@ import {useRecentlyAddedData} from './useRecentlyAddedData';
 
 const HEADER_RECEIPT_ICON_SIZE = 16;
 
-// The overflow button is sized to its icon so it doesn't inflate the centered widget header row;
-// hitSlop keeps a comfortable tap target without adding visual height.
-const OVERFLOW_MENU_HIT_SLOP = {top: 10, bottom: 10, left: 10, right: 10};
-
-const OVERFLOW_MENU_ANCHOR_ALIGNMENT = {
-    horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
-    vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
-} as const;
-
 function RecentlyAddedSection() {
     const {transactions} = useRecentlyAddedData();
     const {translate} = useLocalize();
@@ -61,16 +50,12 @@ function RecentlyAddedSection() {
     // The hovered receipt preview is a portal on document.body, so it isn't dismissed by navigation alone.
     // Once the screen blurs (e.g. after opening an expense), we hide the preview instead of leaving it floating over the RHP.
     const isFocused = useIsFocused();
-    const icons = useMemoizedLazyExpensifyIcons(['ThreeDots', 'Receipt']);
-    const {calculatePopoverPosition} = usePopoverPosition();
+    const icons = useMemoizedLazyExpensifyIcons(['Receipt']);
     const {markReportIDAsExpense} = useWideRHPActions();
     const {email: currentUserEmail, accountID: currentUserAccountID} = useCurrentUserPersonalDetails();
     const isAnonymousUser = useIsAnonymousUser();
     const [betas] = useOnyx(ONYXKEYS.BETAS);
     const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
-    const [isOverflowMenuVisible, setIsOverflowMenuVisible] = useState(false);
-    const [overflowMenuPosition, setOverflowMenuPosition] = useState<AnchorPosition>();
-    const overflowMenuButtonRef = useRef<View>(null);
 
     const hasExpenses = transactions.length > 0;
 
@@ -108,13 +93,6 @@ function RecentlyAddedSection() {
         });
     };
 
-    const openOverflowMenu = () => {
-        calculatePopoverPosition(overflowMenuButtonRef, OVERFLOW_MENU_ANCHOR_ALIGNMENT).then((position) => {
-            setOverflowMenuPosition(position);
-            setIsOverflowMenuVisible(true);
-        });
-    };
-
     const navigateToExpensesPage = () => {
         Navigation.navigate(
             ROUTES.SEARCH_ROOT.getRoute({
@@ -130,38 +108,18 @@ function RecentlyAddedSection() {
     }
 
     const overflowMenu = hasExpenses ? (
-        <>
-            <PressableWithoutFeedback
-                ref={overflowMenuButtonRef}
-                testID="recentlyAddedOverflowMenu"
-                accessibilityLabel={translate('common.more')}
-                sentryLabel="RecentlyAddedOverflowMenu"
-                onPress={openOverflowMenu}
-                hitSlop={OVERFLOW_MENU_HIT_SLOP}
-                style={[styles.alignItemsCenter, styles.justifyContentCenter, styles.threeDotsMenuIconWidth]}
-            >
-                <Icon
-                    src={icons.ThreeDots}
-                    fill={theme.icon}
-                />
-            </PressableWithoutFeedback>
-            <PopoverMenu
-                isVisible={isOverflowMenuVisible}
-                anchorRef={overflowMenuButtonRef}
-                anchorPosition={overflowMenuPosition ?? {horizontal: 0, vertical: 0}}
-                anchorAlignment={OVERFLOW_MENU_ANCHOR_ALIGNMENT}
-                onClose={() => setIsOverflowMenuVisible(false)}
-                onItemSelected={() => setIsOverflowMenuVisible(false)}
-                menuItems={[
-                    {
-                        text: translate('homePage.recentlyAddedSection.viewAll'),
-                        icon: icons.Receipt,
-                        onSelected: navigateToExpensesPage,
-                        shouldCallAfterModalHide: true,
-                    },
-                ]}
-            />
-        </>
+        <WidgetHeaderMenu
+            testID="recentlyAddedOverflowMenu"
+            sentryLabel="RecentlyAddedOverflowMenu"
+            menuItems={[
+                {
+                    text: translate('homePage.recentlyAddedSection.viewAll'),
+                    icon: icons.Receipt,
+                    onSelected: navigateToExpensesPage,
+                    shouldCallAfterModalHide: true,
+                },
+            ]}
+        />
     ) : undefined;
 
     return (
diff --git a/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx b/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx
index 81580fcd41e4..66bc4b37af6b 100644
--- a/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx
+++ b/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx
@@ -1,5 +1,4 @@
 import BlockingView from '@components/BlockingViews/BlockingView';
-import Button from '@components/Button';
 import {CHART_CONTENT_MIN_HEIGHT} from '@components/Charts/VictoryTheme';
 import SearchChartView from '@components/Search/SearchChartView';
 import WidgetContainer from '@components/WidgetContainer';
@@ -12,6 +11,8 @@ import useThemeStyles from '@hooks/useThemeStyles';
 
 import Navigation from '@libs/Navigation/Navigation';
 
+import WidgetHeaderMenu from '@pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu';
+
 import variables from '@styles/variables';
 
 import CONST from '@src/CONST';
@@ -41,15 +42,17 @@ function SpendOverTimeSectionContent() {
             title={translate('search.spendOverTime')}
             titleRightContent={
                 state === SPEND_OVER_TIME_STATE.READY ? (
-                    <Button
-                        small
-                        text={translate('common.view')}
-                        onPress={() => Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query}))}
-                        iconRight={icons.Expand}
-                        shouldShowRightIcon
-                        textStyles={styles.pb0}
-                        style={styles.widgetItemButton}
-                        iconWrapperStyles={styles.justifyContentCenter}
+                    <WidgetHeaderMenu
+                        testID="spendOverTimeOverflowMenu"
+                        sentryLabel="SpendOverTimeOverflowMenu"
+                        menuItems={[
+                            {
+                                text: translate('common.view'),
+                                icon: icons.Expand,
+                                onSelected: () => Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query})),
+                                shouldCallAfterModalHide: true,
+                            },
+                        ]}
                     />
                 ) : null
             }
diff --git a/src/pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu.tsx b/src/pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu.tsx
new file mode 100644
index 000000000000..dd9836d90878
--- /dev/null
+++ b/src/pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu.tsx
@@ -0,0 +1,51 @@
+import type {PopoverMenuItem} from '@components/PopoverMenu';
+import ThreeDotsMenu from '@components/ThreeDotsMenu';
+
+import useThemeStyles from '@hooks/useThemeStyles';
+
+import variables from '@styles/variables';
+
+import CONST from '@src/CONST';
+
+import React from 'react';
+
+const ANCHOR_ALIGNMENT = {
+    horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
+    vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
+} as const;
+
+type WidgetHeaderMenuProps = {
+    /** Items shown in the popover opened by the three-dots trigger */
+    menuItems: PopoverMenuItem[];
+
+    /** Test ID for the trigger */
+    testID?: string;
+
+    /** Sentry label for the trigger */
+    sentryLabel?: string;
+};
+
+/**
+ * Widget header three-dots menu: a Medium Ghost trigger whose negative margins let it overflow the header
+ * rather than grow it, so every card header keeps the same height. Built on `ThreeDotsMenu`.
+ */
+function WidgetHeaderMenu({menuItems, testID, sentryLabel}: WidgetHeaderMenuProps) {
+    const styles = useThemeStyles();
+
+    return (
+        <ThreeDotsMenu
+            menuItems={menuItems}
+            shouldSelfPosition
+            anchorAlignment={ANCHOR_ALIGNMENT}
+            iconStyles={[styles.widgetHeaderMenuButton, styles.widgetHeaderMenuButtonWrapper]}
+            iconHoverStyle={styles.widgetHeaderMenuButtonHovered}
+            iconWidth={variables.iconSizeSmall}
+            iconHeight={variables.iconSizeSmall}
+            shouldChangeFillOnOpen={false}
+            testID={testID}
+            sentryLabel={sentryLabel}
+        />
+    );
+}
+
+export default WidgetHeaderMenu;
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 541c0dfc3485..55cb30d98f0a 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -3888,6 +3888,27 @@ const staticStyles = (theme: ThemeColors) =>
             minWidth: 68,
         },
 
+        widgetHeaderMenuButton: {
+            width: variables.componentSizeNormal,
+            height: variables.componentSizeNormal,
+            alignItems: 'center',
+            justifyContent: 'center',
+            borderRadius: variables.buttonBorderRadius,
+        },
+
+        widgetHeaderMenuButtonHovered: {
+            backgroundColor: theme.hoverComponentBG,
+        },
+
+        widgetHeaderMenuButtonWrapper: {
+            // The 40px ghost button overflows the header instead of growing it: these negative margins shrink its
+            // vertical footprint to the title line-height so every card header keeps the same height. The matching
+            // negative right margin keeps the icon's spacing to the card's right edge equal to its top spacing.
+            marginTop: (variables.widgetHeaderTitleLineHeight - variables.componentSizeNormal) / 2,
+            marginBottom: (variables.widgetHeaderTitleLineHeight - variables.componentSizeNormal) / 2,
+            marginRight: (variables.widgetHeaderTitleLineHeight - variables.componentSizeNormal) / 2,
+        },
+
         widgetItemSubtitle: {
             ...FontUtils.fontFamily.platform.EXP_NEUE,
             fontSize: variables.fontSizeLabel,
@@ -5921,9 +5942,10 @@ const staticStyles = (theme: ThemeColors) =>
 
         receiptPreview: {
             position: 'absolute',
+            // Fallback corner; ReceiptPreview overrides left/top to sit beside the hovered thumbnail.
             left: 60,
             top: 60,
-            width: 380,
+            width: variables.receiptPreviewWidth,
             maxHeight: 'calc(100vh - 120px)',
             borderRadius: variables.componentBorderRadiusLarge,
             borderWidth: 1,
@@ -6840,7 +6862,7 @@ const plainStyles = (theme: ThemeColors) =>
             ({
                 ...FontUtils.fontFamily.platform.EXP_NEUE_BOLD,
                 fontSize: 17,
-                lineHeight: 20,
+                lineHeight: variables.widgetHeaderTitleLineHeight,
                 color,
             }) satisfies TextStyle,
 
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index 415134d7e23f..45d7d6afa0db 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -111,6 +111,8 @@ export default {
     receiptPaneRHPMaxWidth: 465,
     receiptPreviewMaxWidth: 440,
     receiptPreviewMaxHeight: 440,
+    receiptPreviewWidth: 380,
+    widgetHeaderTitleLineHeight: 20,
     homePageLeftColumnMaxWidth: 680,
     homePageRightColumnMaxWidth: 488,
     minScanTooltipWidth: 320,
diff --git a/tests/ui/RecentlyAddedSectionTest.tsx b/tests/ui/RecentlyAddedSectionTest.tsx
index 3f6ec178172a..97b9575f00f2 100644
--- a/tests/ui/RecentlyAddedSectionTest.tsx
+++ b/tests/ui/RecentlyAddedSectionTest.tsx
@@ -39,11 +39,10 @@ jest.mock('@react-navigation/native', () => {
     };
 });
 
-jest.mock('@hooks/usePopoverPosition', () =>
-    jest.fn(() => ({
-        calculatePopoverPosition: jest.fn(() => Promise.resolve({horizontal: 0, vertical: 0, width: 0, height: 0})),
-    })),
-);
+jest.mock('@hooks/usePopoverPosition', () => {
+    const calculatePopoverPosition = jest.fn(() => Promise.resolve({horizontal: 0, vertical: 0, width: 0, height: 0}));
+    return jest.fn(() => ({calculatePopoverPosition}));
+});
 
 jest.mock('@hooks/useLocalize', () =>
     jest.fn(() => ({
diff --git a/tests/unit/components/ReceiptPreviewPositionTest.ts b/tests/unit/components/ReceiptPreviewPositionTest.ts
new file mode 100644
index 000000000000..dba8a39f89ab
--- /dev/null
+++ b/tests/unit/components/ReceiptPreviewPositionTest.ts
@@ -0,0 +1,94 @@
+import getAnchoredPreviewPosition, {
+    RECEIPT_PREVIEW_EDGE_MARGIN,
+    RECEIPT_PREVIEW_GAP,
+    RECEIPT_PREVIEW_MIN_VISIBLE_HEIGHT,
+    RECEIPT_PREVIEW_WIDTH,
+} from '@components/TransactionItemRow/ReceiptPreview/getAnchoredPreviewPosition';
+
+const WINDOW_WIDTH = 1440;
+const WINDOW_HEIGHT = 900;
+
+describe('getAnchoredPreviewPosition', () => {
+    it('falls back to the static style (undefined) when there is no anchor', () => {
+        expect(getAnchoredPreviewPosition(undefined, WINDOW_WIDTH, WINDOW_HEIGHT)).toBeUndefined();
+    });
+
+    it('places the preview just to the right of the hovered thumbnail', () => {
+        const anchor = {top: 300, left: 120, width: 68, height: 64};
+
+        const position = getAnchoredPreviewPosition(anchor, WINDOW_WIDTH, WINDOW_HEIGHT);
+
+        expect(position?.left).toBe(anchor.left + anchor.width + RECEIPT_PREVIEW_GAP);
+    });
+
+    it('aligns the preview top with the hovered row before it has been measured', () => {
+        const anchor = {top: 420, left: 120, width: 68, height: 64};
+
+        expect(getAnchoredPreviewPosition(anchor, WINDOW_WIDTH, WINDOW_HEIGHT)?.top).toBe(420);
+    });
+
+    it('flips to the left of the thumbnail when there is not enough room on the right', () => {
+        // A thumbnail hugging the right edge leaves no room for a 380px-wide preview to its right.
+        const anchor = {top: 300, left: WINDOW_WIDTH - 80, width: 68, height: 64};
+
+        const position = getAnchoredPreviewPosition(anchor, WINDOW_WIDTH, WINDOW_HEIGHT);
+
+        expect(position?.left).toBe(anchor.left - RECEIPT_PREVIEW_WIDTH - RECEIPT_PREVIEW_GAP);
+        expect(position?.left).toBeLessThan(anchor.left);
+    });
+
+    it('clamps a row near the top so the preview stays a margin below the viewport top', () => {
+        const anchor = {top: 4, left: 120, width: 68, height: 64};
+
+        expect(getAnchoredPreviewPosition(anchor, WINDOW_WIDTH, WINDOW_HEIGHT)?.top).toBe(RECEIPT_PREVIEW_EDGE_MARGIN);
+    });
+
+    it('clamps a row near the bottom so the preview keeps a visible slice on-screen before it has been measured', () => {
+        const anchor = {top: WINDOW_HEIGHT - 10, left: 120, width: 68, height: 64};
+
+        expect(getAnchoredPreviewPosition(anchor, WINDOW_WIDTH, WINDOW_HEIGHT)?.top).toBe(WINDOW_HEIGHT - RECEIPT_PREVIEW_MIN_VISIBLE_HEIGHT);
+    });
+
+    it("aligns a measured preview's bottom-left corner with the right of the thumbnail (grows upward)", () => {
+        const previewHeight = 200;
+        const anchor = {top: 300, left: 120, width: 68, height: 64};
+
+        const position = getAnchoredPreviewPosition(anchor, WINDOW_WIDTH, WINDOW_HEIGHT, previewHeight);
+        const thumbnailBottom = anchor.top + anchor.height;
+
+        expect(position?.left).toBe(anchor.left + anchor.width + RECEIPT_PREVIEW_GAP);
+        expect(position?.top).toBe(thumbnailBottom - previewHeight);
+        // Bottom edge lands on the thumbnail's bottom, so the preview sits above-right of the row.
+        expect((position?.top ?? 0) + previewHeight).toBe(thumbnailBottom);
+    });
+
+    it('keeps a measured preview on-screen for a row near the bottom by growing upward', () => {
+        const previewHeight = 600;
+        const anchor = {top: WINDOW_HEIGHT - 200, left: 120, width: 68, height: 64};
+
+        const top = getAnchoredPreviewPosition(anchor, WINDOW_WIDTH, WINDOW_HEIGHT, previewHeight)?.top ?? 0;
+        const thumbnailBottom = anchor.top + anchor.height;
+
+        expect(top).toBe(thumbnailBottom - previewHeight);
+        expect(top).toBeGreaterThanOrEqual(RECEIPT_PREVIEW_EDGE_MARGIN);
+        expect(top + previewHeight).toBeLessThanOrEqual(WINDOW_HEIGHT);
+    });
+
+    it('caps the preview to the viewport when the hovered row is scrolled partially off the bottom', () => {
+        const previewHeight = 200;
+        // measureInWindow can report a bottom below the viewport for a row clipped by a scroll container.
+        const anchor = {top: WINDOW_HEIGHT - 20, left: 120, width: 68, height: 64};
+
+        const top = getAnchoredPreviewPosition(anchor, WINDOW_WIDTH, WINDOW_HEIGHT, previewHeight)?.top ?? 0;
+
+        expect(top).toBe(WINDOW_HEIGHT - RECEIPT_PREVIEW_EDGE_MARGIN - previewHeight);
+        expect(top + previewHeight).toBeLessThanOrEqual(WINDOW_HEIGHT);
+    });
+
+    it('pins a preview taller than the space above the row to the top margin', () => {
+        const previewHeight = WINDOW_HEIGHT + 200;
+        const anchor = {top: 500, left: 120, width: 68, height: 64};
+
+        expect(getAnchoredPreviewPosition(anchor, WINDOW_WIDTH, WINDOW_HEIGHT, previewHeight)?.top).toBe(RECEIPT_PREVIEW_EDGE_MARGIN);
+    });
+});
