Problem Description
Users are experiencing a critical layout issue in iOS 16.6 Safari where elements with position: fixed
or position: sticky
are being displaced from their intended positions. This bug is causing:
- Fixed headers/footers appearing in the wrong location
- Sticky navigation elements not adhering to their designated scroll positions
- Overlapping content and broken layouts
- Inconsistent behavior when the viewport changes (e.g., keyboard appearance)
This issue appears to be related to Safari’s handling of viewport units and the Visual Viewport API in iOS 16.6.
Root Cause
The issue stems from several factors in iOS Safari:
- Viewport Calculation Changes: iOS 16.6 Safari changed how it calculates viewport dimensions, particularly when dealing with the mobile browser chrome (address bar, toolbar)
- Visual vs Layout Viewport: Conflicts between the visual viewport and layout viewport when using fixed/sticky positioning
- Dynamic Viewport Units: Inconsistent support for new viewport units (svh, lvh, dvh) alongside traditional units
- Keyboard Interaction: The virtual keyboard triggering unexpected viewport recalculations
Solutions
Solution 1: Use CSS Environment Variables for Safe Areas
.fixed-header {
position: fixed;
top: 0;
left: 0;
right: 0;
/* Add safe area insets for iOS */
padding-top: env(safe-area-inset-top);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
.sticky-nav {
position: sticky;
top: env(safe-area-inset-top);
}
Solution 2: Use -webkit-fill-available for Height
.full-height-container {
height: 100vh;
/* Fallback for iOS Safari */
height: -webkit-fill-available;
}
.fixed-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
/* Ensure it respects the actual viewport */
min-height: -webkit-fill-available;
}
Solution 3: Implement Dynamic Viewport Units (New Standard)
/* Use the new dynamic viewport units */
.container {
/* Small viewport height - smallest possible viewport */
min-height: 100svh;
/* Large viewport height - largest possible viewport */
max-height: 100lvh;
/* Dynamic viewport height - adjusts with browser chrome */
height: 100dvh;
}
.fixed-element {
position: fixed;
top: 0;
height: 100dvh; /* Dynamically adjusts */
}
Solution 4: JavaScript Viewport Detection and Correction
// Function to fix viewport height calculation
function setViewportHeight() {
// Get the actual viewport height
const vh = window.visualViewport?.height || window.innerHeight;
// Set CSS custom property
document.documentElement.style.setProperty('--vh', `${vh * 0.01}px`);
}
// Initial call
setViewportHeight();
// Update on resize and visual viewport changes
window.addEventListener('resize', setViewportHeight);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', setViewportHeight);
window.visualViewport.addEventListener('scroll', setViewportHeight);
}
// Then use in CSS:
// .fixed-header {
// height: calc(var(--vh, 1vh) * 100);
// }
Solution 5: Handle Keyboard Appearance
// Detect keyboard appearance and adjust fixed elements
const isKeyboardOpen = () => {
if (!window.visualViewport) return false;
const viewport = window.visualViewport;
const screenHeight = window.screen.height;
// If visual viewport is significantly smaller, keyboard is likely open
return viewport.height < screenHeight * 0.75;
};
// Adjust layout when keyboard opens
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => {
if (isKeyboardOpen()) {
document.body.classList.add('keyboard-open');
} else {
document.body.classList.remove('keyboard-open');
}
});
}
/* Adjust fixed elements when keyboard is open */
body.keyboard-open .fixed-footer {
position: absolute; /* Change from fixed to absolute */
}
body.keyboard-open .sticky-nav {
position: relative; /* Temporarily disable sticky */
}
Solution 6: CSS Fallback Chain
.fixed-element {
position: fixed;
top: 0;
/* Multiple fallbacks for maximum compatibility */
height: 100vh; /* Fallback for older browsers */
height: -webkit-fill-available; /* Safari-specific */
height: 100dvh; /* Modern standard */
/* Alternatively, use @supports for progressive enhancement */
}
@supports (height: 100dvh) {
.fixed-element {
height: 100dvh;
}
}
@supports (height: -webkit-fill-available) and (not (height: 100dvh)) {
.fixed-element {
height: -webkit-fill-available;
}
}
Solution 7: Transform-based Positioning (Workaround)
/* Sometimes transform can be more reliable than fixed positioning */
.pseudo-fixed {
position: absolute;
top: 0;
left: 0;
right: 0;
will-change: transform;
}
/* Use JavaScript to update transform based on scroll */
let ticking = false;
function updateFixedPosition() {
const scrollY = window.scrollY || window.pageYOffset;
const fixedElement = document.querySelector('.pseudo-fixed');
fixedElement.style.transform = `translateY(${scrollY}px)`;
ticking = false;
}
window.addEventListener('scroll', () => {
if (!ticking) {
window.requestAnimationFrame(updateFixedPosition);
ticking = true;
}
});
Recommended Approach
For best cross-browser compatibility, use a combination approach:
:root {
/* CSS custom property for dynamic viewport */
--viewport-height: 100vh;
}
.fixed-header {
position: fixed;
top: 0;
left: 0;
right: 0;
/* Safe area insets */
padding-top: env(safe-area-inset-top);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
/* Z-index to ensure it stays on top */
z-index: 1000;
}
.main-content {
/* Use multiple viewport units with fallbacks */
min-height: 100vh;
min-height: -webkit-fill-available;
min-height: 100dvh;
}
.sticky-sidebar {
position: sticky;
top: calc(env(safe-area-inset-top) + 20px);
/* Ensure it has proper height constraints */
max-height: calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom));
max-height: calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom));
overflow-y: auto;
}
// Enhanced viewport height setter
function updateViewportHeight() {
const vh = window.visualViewport?.height || window.innerHeight;
const vw = window.visualViewport?.width || window.innerWidth;
document.documentElement.style.setProperty('--viewport-height', `${vh}px`);
document.documentElement.style.setProperty('--viewport-width', `${vw}px`);
}
// Initialize
updateViewportHeight();
// Listen to various events
window.addEventListener('resize', updateViewportHeight);
window.addEventListener('orientationchange', updateViewportHeight);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', updateViewportHeight);
window.visualViewport.addEventListener('scroll', updateViewportHeight);
}
// iOS-specific: Handle page show event
window.addEventListener('pageshow', updateViewportHeight);
Testing
After implementing the fix, test thoroughly:
- Device Testing: Test on actual iOS 16.6 devices (iPhone with Safari)
- Orientation Changes: Rotate device from portrait to landscape
- Keyboard Interaction: Focus on input fields to trigger keyboard
- Scrolling: Scroll the page up and down to verify sticky behavior
- Browser Chrome: Test with and without the address bar visible
- Different Screen Sizes: Test on various iPhone models
Additional Resources
Browser Compatibility
- dvh, lvh, svh units: iOS Safari 15.4+, Chrome 108+, Firefox 101+
- -webkit-fill-available: iOS Safari 13+
- env(safe-area-inset-*): iOS Safari 11.2+
- Visual Viewport API: iOS Safari 13+
Conclusion
The iOS 16.6 Safari fixed/sticky positioning bug requires a multi-pronged approach combining modern CSS viewport units, safe area environment variables, and JavaScript-based viewport detection. By implementing the recommended combination approach, you can ensure consistent behavior across iOS Safari versions while maintaining compatibility with other browsers.