diff --git a/.changeset/hungry-mammals-move.md b/.changeset/hungry-mammals-move.md
new file mode 100644
index 000000000..c3f304754
--- /dev/null
+++ b/.changeset/hungry-mammals-move.md
@@ -0,0 +1,5 @@
+---
+"@launchpad-ui/components": patch
+---
+
+Add `HoverTrigger`
diff --git a/packages/components/src/HoverTrigger.tsx b/packages/components/src/HoverTrigger.tsx
new file mode 100644
index 000000000..7e3f8e9fd
--- /dev/null
+++ b/packages/components/src/HoverTrigger.tsx
@@ -0,0 +1,55 @@
+import type { TooltipTriggerComponentProps } from 'react-aria-components';
+
+import { PressResponder } from '@react-aria/interactions';
+import { useRef } from 'react';
+import { useHover } from 'react-aria';
+import { OverlayTriggerStateContext, PopoverContext, Provider } from 'react-aria-components';
+import { useTooltipTriggerState } from 'react-stately';
+
+interface HoverTriggerProps extends TooltipTriggerComponentProps {}
+
+/**
+ * A hover popover allows sighted users to preview content available behind a link (inaccessible to keyboard users).
+ */
+const HoverTrigger = ({ children, ...props }: HoverTriggerProps) => {
+ const ref = useRef(null);
+ const triggerRef = useRef(null);
+ const { delay = 500, closeDelay = 250 } = props;
+
+ const state = {
+ ...useTooltipTriggerState({ delay, closeDelay, ...props }),
+ setOpen: () => {},
+ toggle: () => (state.isOpen ? state.close(true) : state.open(true)),
+ };
+
+ const { hoverProps } = useHover({
+ onHoverStart: () => state?.open(),
+ onHoverEnd: () => state?.close(),
+ });
+
+ return (
+
+ state.close(true)} ref={triggerRef}>
+
+ {children}
+
+
+
+ );
+};
+
+export { HoverTrigger };
+export type { HoverTriggerProps };
diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts
index 0128325b2..d4bb19b6f 100644
--- a/packages/components/src/index.ts
+++ b/packages/components/src/index.ts
@@ -33,6 +33,7 @@ export type { FormProps } from './Form';
export type { GridListItemProps, GridListProps } from './GridList';
export type { GroupProps } from './Group';
export type { HeadingProps } from './Heading';
+export type { HoverTriggerProps } from './HoverTrigger';
export type { IconButtonProps } from './IconButton';
export type { InputProps } from './Input';
export type { LabelProps } from './Label';
@@ -160,6 +161,7 @@ export {
export { Group, GroupContext, groupStyles } from './Group';
export { Header, HeaderContext, headerStyles } from './Header';
export { Heading, HeadingContext, headingStyles } from './Heading';
+export { HoverTrigger } from './HoverTrigger';
export { IconButton, IconButtonContext, iconButtonStyles } from './IconButton';
export { Input, InputContext, inputStyles } from './Input';
export { Keyboard } from './Keyboard';
diff --git a/packages/components/stories/Popover.stories.tsx b/packages/components/stories/Popover.stories.tsx
index f47f015f2..a0f273ca0 100644
--- a/packages/components/stories/Popover.stories.tsx
+++ b/packages/components/stories/Popover.stories.tsx
@@ -7,6 +7,8 @@ import { expect, userEvent, within } from 'storybook/test';
import { Button } from '../src/Button';
import { Dialog, DialogTrigger } from '../src/Dialog';
import { Heading } from '../src/Heading';
+import { HoverTrigger } from '../src/HoverTrigger';
+import { Link } from '../src/Link';
import { OverlayArrow, Popover } from '../src/Popover';
import { Pressable } from '../src/Pressable';
@@ -101,3 +103,26 @@ export const CustomTrigger: Story = {
},
play,
};
+
+export const Hover: Story = {
+ render: (args) => {
+ return (
+
+ Link
+
+ Title
+ Message
+ View more
+
+
+ );
+ },
+ play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
+ const canvas = within(canvasElement);
+
+ await userEvent.hover(canvasElement);
+ await userEvent.hover(canvas.getByRole('link'));
+ const body = canvasElement.ownerDocument.body;
+ await expect(await within(body).findByTestId('popover'));
+ },
+};