diff --git a/.changeset/tidy-popovers-float.md b/.changeset/tidy-popovers-float.md
new file mode 100644
index 000000000..70cb170bf
--- /dev/null
+++ b/.changeset/tidy-popovers-float.md
@@ -0,0 +1,5 @@
+---
+"@cloudflare/kumo": patch
+---
+
+Keep Popover arrows visible during opening and closing transitions when content uses overflow clipping or scrolling.
diff --git a/packages/kumo/src/components/popover/popover.tsx b/packages/kumo/src/components/popover/popover.tsx
index bdaab3c3e..7e575760d 100644
--- a/packages/kumo/src/components/popover/popover.tsx
+++ b/packages/kumo/src/components/popover/popover.tsx
@@ -206,31 +206,40 @@ function PopoverContent({
sideOffset={sideOffset}
positionMethod={positionMethod}
>
- .kumo-popover-popup[data-starting-style])]:scale-90",
+ "[&:has(>.kumo-popover-popup[data-ending-style])]:scale-90",
+ "[&:has(>.kumo-popover-popup[data-instant])]:duration-0",
)}
>
-
-
-
- {children}
-
+
+
+
+ {children}
+
+
);
diff --git a/packages/kumo/src/components/popover/popover.visual.browser.test.tsx b/packages/kumo/src/components/popover/popover.visual.browser.test.tsx
new file mode 100644
index 000000000..8a9da8c46
--- /dev/null
+++ b/packages/kumo/src/components/popover/popover.visual.browser.test.tsx
@@ -0,0 +1,93 @@
+import { describe, expect, test } from "vite-plus/test";
+import { userEvent } from "vite-plus/test/browser";
+import { render } from "vitest-browser-react";
+import { Popover } from "./popover";
+
+describe("Popover", () => {
+ test("keeps the arrow outside scrollable content during the opening scale transition", async () => {
+ const { getByRole } = await render(
+ <>
+
+
+ Open popover
+
+ Scrollable content
+
+
+ >,
+ );
+
+ const trigger = getByRole("button", { name: "Open popover" });
+ await trigger.click();
+
+ const popup = getByRole("dialog").element();
+ await expect.poll(() => getComputedStyle(popup).opacity).toBe("1");
+
+ const motionWrapper = popup.parentElement;
+ const arrow = popup.querySelector("[aria-hidden='true']");
+
+ expect(motionWrapper).not.toBeNull();
+ expect(arrow).not.toBeNull();
+ expect(getComputedStyle(popup).scale).toBe("none");
+ expect(getComputedStyle(popup).transitionProperty).toBe("opacity");
+
+ const wrapperScale = Number.parseFloat(
+ getComputedStyle(motionWrapper!).scale,
+ );
+ expect(wrapperScale).toBeGreaterThanOrEqual(0.9);
+ expect(wrapperScale).toBeLessThan(1);
+
+ expect(popup.scrollHeight).toBeGreaterThan(popup.clientHeight);
+ const arrowTopBeforeScroll = arrow!.getBoundingClientRect().top;
+
+ popup.scrollTop = 64;
+ await new Promise((resolve) =>
+ requestAnimationFrame(() => resolve()),
+ );
+
+ expect(popup.scrollTop).toBe(64);
+ expect(
+ Math.abs(arrow!.getBoundingClientRect().top - arrowTopBeforeScroll),
+ ).toBeLessThan(1);
+
+ const arrowRect = arrow!.getBoundingClientRect();
+ const popupRect = popup.getBoundingClientRect();
+ const hitTarget = document.elementFromPoint(
+ arrowRect.left + arrowRect.width / 2,
+ arrowRect.top + arrowRect.height / 4,
+ );
+
+ expect(arrowRect.top).toBeLessThan(popupRect.top);
+ expect(hitTarget === arrow || arrow!.contains(hitTarget)).toBe(true);
+
+ await trigger.click();
+ await expect.poll(() => popup.hasAttribute("data-ending-style")).toBe(true);
+ expect(document.body.contains(popup)).toBe(true);
+ expect(getComputedStyle(motionWrapper!).scale).toBe("0.9");
+ await expect.element(getByRole("dialog")).not.toBeInTheDocument();
+
+ trigger.element().focus();
+ await userEvent.keyboard("{Enter}");
+ await expect.element(getByRole("dialog")).toBeInTheDocument();
+
+ const instantPopup = getByRole("dialog").element();
+ await expect
+ .poll(() => instantPopup.hasAttribute("data-instant"))
+ .toBe(true);
+ expect(
+ getComputedStyle(instantPopup.parentElement!).transitionDuration,
+ ).toBe("0s");
+ });
+});