diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx
index 397daaa600..4cbb88d318 100644
--- a/core/src/components/modal/modal.tsx
+++ b/core/src/components/modal/modal.tsx
@@ -102,6 +102,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
private cachedOriginalParent?: HTMLElement;
// Cached ion-page ancestor for child route passthrough
private cachedPageParent?: HTMLElement | null;
+ // Whether to skip coordinate-based safe-area detection (for fullscreen phone modals)
+ private skipSafeAreaCoordinateDetection = false;
lastFocus?: HTMLElement;
animation?: Animation;
@@ -877,42 +879,61 @@ export class Modal implements ComponentInterface, OverlayInterface {
const isCardModal = presentingElement !== undefined;
const isTablet = window.innerWidth >= 768;
- // Sheet modals: always touch bottom, top depends on breakpoint
+ // Sheet modals always touch bottom edge, never top/left/right
if (isSheetModal) {
style.setProperty('--ion-safe-area-top', '0px');
- // Don't override bottom - sheet always touches bottom
style.setProperty('--ion-safe-area-left', '0px');
style.setProperty('--ion-safe-area-right', '0px');
return;
}
- // Card modals are inset from edges (rounded corners), no safe areas needed
+ // Card modals are inset from all edges
if (isCardModal) {
- style.setProperty('--ion-safe-area-top', '0px');
- style.setProperty('--ion-safe-area-bottom', '0px');
- style.setProperty('--ion-safe-area-left', '0px');
- style.setProperty('--ion-safe-area-right', '0px');
+ this.zeroAllSafeAreas();
return;
}
- // Phone modals are fullscreen, need all safe areas
+ // Phone-sized fullscreen modals inherit safe areas and use wrapper padding
if (!isTablet) {
- // Don't set any overrides - inherit from :root
+ this.applyFullscreenSafeArea();
return;
}
- // Default tablet modal: centered dialog, no safe areas needed
- // Check for fullscreen override via CSS custom properties
+ // Check if tablet modal is fullscreen via CSS custom properties
const computedStyle = getComputedStyle(this.el);
const width = computedStyle.getPropertyValue('--width').trim();
const height = computedStyle.getPropertyValue('--height').trim();
+ const isFullscreen = width === '100%' && height === '100%';
- if (width === '100%' && height === '100%') {
- // Fullscreen modal - need safe areas, don't override
- return;
+ if (isFullscreen) {
+ this.applyFullscreenSafeArea();
+ } else {
+ // Centered dialog doesn't touch edges
+ this.zeroAllSafeAreas();
}
+ }
- // Centered dialog - zero out all safe areas
+ /**
+ * Applies safe-area handling for fullscreen modals.
+ * Adds wrapper padding when no footer is present to prevent
+ * content from overlapping system navigation areas.
+ */
+ private applyFullscreenSafeArea() {
+ this.skipSafeAreaCoordinateDetection = true;
+
+ const hasFooter = this.el.querySelector('ion-footer') !== null;
+ if (!hasFooter && this.wrapperEl) {
+ this.wrapperEl.style.setProperty('padding-bottom', 'var(--ion-safe-area-bottom, 0px)');
+ this.wrapperEl.style.setProperty('box-sizing', 'border-box');
+ }
+ }
+
+ /**
+ * Sets all safe-area CSS variables to 0px for modals that
+ * don't touch screen edges.
+ */
+ private zeroAllSafeAreas() {
+ const style = this.el.style;
style.setProperty('--ion-safe-area-top', '0px');
style.setProperty('--ion-safe-area-bottom', '0px');
style.setProperty('--ion-safe-area-left', '0px');
@@ -921,22 +942,27 @@ export class Modal implements ComponentInterface, OverlayInterface {
/**
* Updates safe-area CSS variable overrides based on whether the modal
- * is touching each edge of the viewport. This is called after animation
+ * is touching each edge of the viewport. Called after animation
* and during gestures to handle dynamic position changes.
*/
private updateSafeAreaOverrides() {
+ if (this.skipSafeAreaCoordinateDetection) {
+ return;
+ }
+
const wrapper = this.wrapperEl;
- if (!wrapper) return;
+ if (!wrapper) {
+ return;
+ }
const rect = wrapper.getBoundingClientRect();
- const threshold = 2; // Account for subpixel rendering
+ const threshold = 2;
const touchingTop = rect.top <= threshold;
const touchingBottom = rect.bottom >= window.innerHeight - threshold;
const touchingLeft = rect.left <= threshold;
const touchingRight = rect.right >= window.innerWidth - threshold;
- // Remove override when touching edge (allow inheritance), set to 0 when not touching
const style = this.el.style;
touchingTop ? style.removeProperty('--ion-safe-area-top') : style.setProperty('--ion-safe-area-top', '0px');
touchingBottom
@@ -1058,6 +1084,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
}
this.currentBreakpoint = undefined;
this.animation = undefined;
+ // Reset safe-area detection flag for potential re-presentation
+ this.skipSafeAreaCoordinateDetection = false;
unlock();
diff --git a/core/src/components/popover/animations/ios.enter.ts b/core/src/components/popover/animations/ios.enter.ts
index aa4e056814..22e8da58d4 100644
--- a/core/src/components/popover/animations/ios.enter.ts
+++ b/core/src/components/popover/animations/ios.enter.ts
@@ -61,6 +61,8 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
top,
left,
bottom,
+ checkSafeAreaTop,
+ checkSafeAreaBottom,
checkSafeAreaLeft,
checkSafeAreaRight,
arrowTop,
@@ -118,15 +120,27 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
baseEl.classList.add('popover-bottom');
}
- if (bottom !== undefined) {
- contentEl.style.setProperty('bottom', `${bottom}px`);
- }
-
+ /**
+ * Safe area CSS variable adjustments.
+ * When the popover is positioned near an edge, we add the corresponding
+ * safe-area inset to ensure the popover doesn't overlap with system UI
+ * (status bars, home indicators, navigation bars on Android API 36+, etc.)
+ */
+ const safeAreaTop = ' + var(--ion-safe-area-top, 0)';
+ const safeAreaBottom = ' + var(--ion-safe-area-bottom, 0)';
const safeAreaLeft = ' + var(--ion-safe-area-left, 0)';
const safeAreaRight = ' - var(--ion-safe-area-right, 0)';
+ let topValue = `${top}px`;
+ let bottomValue = bottom !== undefined ? `${bottom}px` : undefined;
let leftValue = `${left}px`;
+ if (checkSafeAreaTop) {
+ topValue = `${top}px${safeAreaTop}`;
+ }
+ if (checkSafeAreaBottom && bottomValue !== undefined) {
+ bottomValue = `${bottom}px${safeAreaBottom}`;
+ }
if (checkSafeAreaLeft) {
leftValue = `${left}px${safeAreaLeft}`;
}
@@ -134,7 +148,11 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
leftValue = `${left}px${safeAreaRight}`;
}
- contentEl.style.setProperty('top', `calc(${top}px + var(--offset-y, 0))`);
+ if (bottomValue !== undefined) {
+ contentEl.style.setProperty('bottom', `calc(${bottomValue})`);
+ }
+
+ contentEl.style.setProperty('top', `calc(${topValue} + var(--offset-y, 0))`);
contentEl.style.setProperty('left', `calc(${leftValue} + var(--offset-x, 0))`);
contentEl.style.setProperty('transform-origin', `${originY} ${originX}`);
diff --git a/core/src/components/popover/animations/md.enter.ts b/core/src/components/popover/animations/md.enter.ts
index e25f745cec..31ec53ec07 100644
--- a/core/src/components/popover/animations/md.enter.ts
+++ b/core/src/components/popover/animations/md.enter.ts
@@ -47,7 +47,7 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
const padding = size === 'cover' ? 0 : POPOVER_MD_BODY_PADDING;
- const { originX, originY, top, left, bottom } = calculateWindowAdjustment(
+ const { originX, originY, top, left, bottom, checkSafeAreaTop, checkSafeAreaBottom } = calculateWindowAdjustment(
side,
results.top,
results.left,
@@ -62,6 +62,25 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
results.referenceCoordinates
);
+ /**
+ * Safe area CSS variable adjustments.
+ * When the popover is positioned near an edge, we add the corresponding
+ * safe-area inset to ensure the popover doesn't overlap with system UI
+ * (status bars, home indicators, navigation bars on Android API 36+, etc.)
+ */
+ const safeAreaTop = ' + var(--ion-safe-area-top, 0)';
+ const safeAreaBottom = ' + var(--ion-safe-area-bottom, 0)';
+
+ let topValue = `${top}px`;
+ let bottomValue = bottom !== undefined ? `${bottom}px` : undefined;
+
+ if (checkSafeAreaTop) {
+ topValue = `${top}px${safeAreaTop}`;
+ }
+ if (checkSafeAreaBottom && bottomValue !== undefined) {
+ bottomValue = `${bottom}px${safeAreaBottom}`;
+ }
+
const baseAnimation = createAnimation();
const backdropAnimation = createAnimation();
const wrapperAnimation = createAnimation();
@@ -81,13 +100,13 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
contentAnimation
.addElement(contentEl)
.beforeStyles({
- top: `calc(${top}px + var(--offset-y, 0px))`,
+ top: `calc(${topValue} + var(--offset-y, 0px))`,
left: `calc(${left}px + var(--offset-x, 0px))`,
'transform-origin': `${originY} ${originX}`,
})
.beforeAddWrite(() => {
- if (bottom !== undefined) {
- contentEl.style.setProperty('bottom', `${bottom}px`);
+ if (bottomValue !== undefined) {
+ contentEl.style.setProperty('bottom', `calc(${bottomValue})`);
}
})
.fromTo('transform', 'scale(0.8)', 'scale(1)');
diff --git a/core/src/components/popover/test/safe-area/index.html b/core/src/components/popover/test/safe-area/index.html
new file mode 100644
index 0000000000..306ac0ed04
--- /dev/null
+++ b/core/src/components/popover/test/safe-area/index.html
@@ -0,0 +1,179 @@
+
+
+
+
+ Popover - Safe Area
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Popover - Safe Area Positioning
+
+
+
+
+ Test that popovers are positioned away from unsafe areas (shown in red).
+ The popover should be moved up/down to avoid overlapping the safe-area zones.
+
+
+
+
+ Small Popover (Center)
+ Floating popover - positioned in center, no adjustment needed
+
+ Present
+
+
+
+
+ Large Popover
+ Tall content that may extend toward bottom safe area
+
+ Present
+
+
+
+ Trigger Near Bottom
+
+ Near Bottom Right
+
+
+
+
+
+ Option 1
+ Option 2
+ Option 3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts b/core/src/components/popover/test/safe-area/popover.e2e.ts
new file mode 100644
index 0000000000..b8d4272f87
--- /dev/null
+++ b/core/src/components/popover/test/safe-area/popover.e2e.ts
@@ -0,0 +1,99 @@
+import { expect } from '@playwright/test';
+import { configs, test } from '@utils/test/playwright';
+
+/**
+ * Safe-area tests verify that popovers are correctly positioned
+ * to avoid overlapping with safe-area zones (status bars, navigation bars, etc.)
+ *
+ * This is especially important for Android API 36+ where edge-to-edge mode
+ * is enforced and apps can no longer opt out.
+ */
+
+configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
+ test.describe(title('popover: safe-area positioning'), () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/src/components/popover/test/safe-area', config);
+ });
+
+ test('popover pinned to bottom should account for safe-area-bottom in position', async ({ page }, testInfo) => {
+ testInfo.annotations.push({
+ type: 'issue',
+ description: 'https://github.com/ionic-team/ionic-framework/issues/30900',
+ });
+
+ // Use a smaller viewport to force the popover to be constrained
+ await page.setViewportSize({ width: 375, height: 500 });
+
+ const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
+
+ // Click the trigger near the bottom of the screen
+ await page.click('#bottom-trigger');
+ await ionPopoverDidPresent.next();
+
+ // Target the specific popover that was presented (the one with trigger="bottom-trigger")
+ const popover = page.locator('ion-popover[trigger="bottom-trigger"]');
+ const popoverContent = popover.locator('.popover-content');
+
+ // Get the computed bottom style - should include safe-area calc
+ const bottomStyle = await popoverContent.evaluate((el) => el.style.bottom);
+
+ // The bottom should include the safe-area-bottom CSS variable
+ // This ensures the popover is positioned above the unsafe area
+ expect(bottomStyle).toContain('var(--ion-safe-area-bottom');
+ });
+
+ test('floating popover should not have safe-area adjustments', async ({ page }) => {
+ const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
+
+ await page.click('#small-popover-trigger');
+ await ionPopoverDidPresent.next();
+
+ // Target the specific popover
+ const popover = page.locator('ion-popover[trigger="small-popover-trigger"]');
+ const popoverContent = popover.locator('.popover-content');
+
+ // Get the computed top and bottom styles
+ const topStyle = await popoverContent.evaluate((el) => el.style.top);
+ const bottomStyle = await popoverContent.evaluate((el) => el.style.bottom);
+
+ // A floating popover in the middle shouldn't have safe-area adjustments
+ // The top should be a simple calc without safe-area
+ expect(topStyle).not.toContain('var(--ion-safe-area-top');
+ // The bottom should not be set for a floating popover
+ expect(bottomStyle).toBe('');
+ });
+ });
+});
+
+configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
+ test.describe(title('popover: safe-area positioning - md mode'), () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/src/components/popover/test/safe-area', config);
+ });
+
+ test('popover pinned to bottom should account for safe-area-bottom in position', async ({ page }, testInfo) => {
+ testInfo.annotations.push({
+ type: 'issue',
+ description: 'https://github.com/ionic-team/ionic-framework/issues/30900',
+ });
+
+ // Use a smaller viewport to force the popover to be constrained
+ await page.setViewportSize({ width: 375, height: 500 });
+
+ const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
+
+ await page.click('#bottom-trigger');
+ await ionPopoverDidPresent.next();
+
+ // Target the specific popover
+ const popover = page.locator('ion-popover[trigger="bottom-trigger"]');
+ const popoverContent = popover.locator('.popover-content');
+
+ // Get the computed bottom style - should include safe-area calc
+ const bottomStyle = await popoverContent.evaluate((el) => el.style.bottom);
+
+ // The bottom should include the safe-area-bottom CSS variable
+ expect(bottomStyle).toContain('var(--ion-safe-area-bottom');
+ });
+ });
+});
diff --git a/core/src/components/popover/utils.ts b/core/src/components/popover/utils.ts
index 794ebb2088..5b960aaecb 100644
--- a/core/src/components/popover/utils.ts
+++ b/core/src/components/popover/utils.ts
@@ -30,6 +30,8 @@ export interface PopoverStyles {
bottom?: number;
originX: string;
originY: string;
+ checkSafeAreaTop: boolean;
+ checkSafeAreaBottom: boolean;
checkSafeAreaLeft: boolean;
checkSafeAreaRight: boolean;
arrowTop: number;
@@ -829,6 +831,8 @@ export const calculateWindowAdjustment = (
let bottom;
let originX = contentOriginX;
let originY = contentOriginY;
+ let checkSafeAreaTop = false;
+ let checkSafeAreaBottom = false;
let checkSafeAreaLeft = false;
let checkSafeAreaRight = false;
const triggerTop = triggerCoordinates
@@ -874,17 +878,32 @@ export const calculateWindowAdjustment = (
* We chose 12 here so that the popover position looks a bit nicer as
* it is not right up against the edge of the screen.
*/
- top = Math.max(12, triggerTop - contentHeight - triggerHeight - (arrowHeight - 1));
+ top = Math.max(bodyPadding, triggerTop - contentHeight - triggerHeight - (arrowHeight - 1));
arrowTop = top + contentHeight;
originY = 'bottom';
addPopoverBottomClass = true;
+ /**
+ * If the popover is positioned near the top edge, account for safe area.
+ * This ensures the popover doesn't overlap with status bars or notches.
+ */
+ if (top <= bodyPadding + safeAreaMargin) {
+ checkSafeAreaTop = true;
+ top = bodyPadding;
+ }
+
/**
* If not enough room for popover to appear
* above trigger, then cut it off.
*/
} else {
bottom = bodyPadding;
+ /**
+ * When the popover is pinned to the bottom, account for safe area.
+ * This ensures the popover doesn't overlap with home indicators
+ * or navigation bars (e.g., Android API 36+ edge-to-edge).
+ */
+ checkSafeAreaBottom = true;
}
}
@@ -894,6 +913,8 @@ export const calculateWindowAdjustment = (
bottom,
originX,
originY,
+ checkSafeAreaTop,
+ checkSafeAreaBottom,
checkSafeAreaLeft,
checkSafeAreaRight,
arrowTop,