Reputation: 530
On my website I have a drawer (a modal) containing form with inputs inside. And whenever user clicks on inputs browser tries to center them, but since a drawer is absolutely positioned, it scrolls underlying content instead and absolutely positioned element jumps. This thing happens on iOS devices, both chrome and safari.
Is there a way to fix it? Everything I found online so far doesn't work.
Here is a codesandbox https://codesandbox.io/s/small-water-v1m1tq
And here is a gif
Actually this is an old problem that I can't fix for a long time, but recently I noticed that notion has such modals, and they somehow managed to fix this issue.
This modal doesn't have top margin, but it doesn't matter. Changed the layout to be like theirs, but nothing helps. Here is a link: https://leather-colby-f69.notion.site/Job-Board-4323320545bb4484bfc1008cff34cebd
Upvotes: 3
Views: 2913
Reputation: 11440
From my testing it would seem the flickering is caused by Safari resizing the window before computing the layout again. My guess is some combination of the fixed layout containing the input and some obscure bug.
From your code I simply added
html, body {
position: relative;
width: 100%;
height: 100%;
overflow: auto;
}
and the issues went away. I suspect by making the html and body elements positioned relative
(and not static
which is default) it would correctly recomputed the layout when Safari was doing its resize shenanigans on keyboard show/hide. These are just guesses though based on observation.
This type of style is also similar to what you see in css reset sheets. The reason they do it might be a clue to this behavior.
Upvotes: 2
Reputation: 366
The solution is pretty simple in my opinion.
I have a custom hook usePreventScroll
that can be expanded to a reusable wrap component something like <PreventScroll></PreventScroll>
for components such as Modal, custom Dialog Box, Context Menu etc.
Doing quick search I came across this [article][1] from useHooks that explains it better and has good example.
Here's the code for quick reference though
function useLockBodyScroll() {
useLayoutEffect(() => {
// Get original body overflow
const originalStyle = window.getComputedStyle(document.body).overflow;
// Prevent scrolling on mount
document.body.style.overflow = "hidden";
// Re-enable scrolling when component unmounts
return () => (document.body.style.overflow = originalStyle);
}, []); // Empty array ensures effect is only run on mount and unmount
}
Another thing I noticed when working was to pass a variable to enable this behavior conditionally so something like
export function useLockBodyScroll(enable) {
useLayoutEffect(() => {
// Get original body overflow
const originalStyle = window.getComputedStyle(document.body).overflow
// Prevent scrolling on mount
if (enable) {
document.body.style.overflow = 'hidden'
}
// Re-enable scrolling when component unmounts
return () => (document.body.style.overflow = originalStyle)
}, []) // Empty array ensures effect is only run on mount and unmount
}
A reusable wrapper component would look/work something like
const PreventScrollWrap = (children) => {
useLockBodyScroll()
return <>{children}</>
}
[1]: https://usehooks.com/useLockBodyScroll/
Upvotes: 0
Reputation: 91
If you want to not use overflow: hidden, you can probably change the display property to none. Basically, when you will click the button, it will appear the modal full screen, so you don't have to see the div on the back.
You can see the results here: https://codesandbox.io/embed/vigorous-dream-v22o6b?fontsize=14&hidenavigation=1&theme=dark
Simply, you have to wrap the div and the button that scrolls, and every time you click the button you change the display property of that wrapper to none and vice-versa when you close the modal.
import "./styles.css";
import { useState } from "react";
export default function App() {
const [modalVisible, setModalVisible] = useState(false);
const rows = new Array(80).fill();
return (
<div className="App">
<div className="wrapper"> //this is the wrapper
<div className="lorem">
{rows.map((_, index) => (
<div key={index}>{index}</div>
))}
</div>
<button
onClick={() => {
setModalVisible(true);
document.querySelector(".wrapper").style.display = "none"; //here you change the property of the wrapper to none
}}
style={{ "font-size": "16px" }}
>
open drawer
</button>
</div>
{modalVisible && (
<div className="drawer-root">
<div
className="drawer-mask"
style={{
position: "fixed",
height: "100%",
"background-color": "#00000073",
inset: "0"
}}
/>
<div
className="drawer"
style={{
"box-sizing": "border-box",
position: "fixed",
bottom: "0",
height: "calc(100% - 64px)",
width: "100%",
background: "white",
padding: "16px",
display: "flex",
"flex-direction": "column",
"justify-content": "space-between"
}}
>
<input placeholder="Search..." style={{ "font-size": "16px" }} />
<button
onClick={() => {
setModalVisible(false);
document.querySelector(".wrapper").style.display =
"block"; //here you set again the display property to block
}}
style={{ "font-size": "16px" }}
>
close drawer
</button>
</div>
</div>
)}
</div>
);
}
Upvotes: 0
Reputation: 3227
You can disable scrolling and temporarily push the page off the top of the screen by giving it a negative top
value equal to the scroll offset. Then when hiding the modal you scroll back to the original value with JS. As long as there's no smooth scrolling set through CSS, this should happen instantaneous.
let scrollPosition;
const freezeScroll = () => {
scrollPosition = window.scrollY;
document.body.style.top = `-${scrollPosition}px`;
document.body.style.overflow = 'hidden';
};
const restoreScroll = () => {
if (scrollPosition === null) {
// Nothing to do as scroll was never frozen.
return;
}
document.body.style.top = 0;
document.body.style.overflow = 'auto';
window.scrollTo({
top: scrollPosition,
behavior: 'auto'
});
};
function App() {
const [modalVisible, setModalVisible] = useState(false);
// ...
useEffect(() => {
modalVisible ? freezeScroll() : restoreScroll();
}, [modalVisible]);
// ...
}
I got this working for your example: https://codesandbox.io/s/nifty-edison-h0bwnm?file=/src/App.js
Note that because CSS properties were added with dashes and not camel case, the style looks quite broken, but it's enough to see the solution working. It should play well with the CSS if you restore it.
Upvotes: 0
Reputation: 645
You should use body-scroll-lock package like this:
import "./styles.css";
import { useState, useEffect } from "react";
import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock';
export default function App() {
const [modalVisible, setModalVisible] = useState(false);
const rows = new Array(80).fill();
useEffect(() => {
modalVisible ? disableBodyScroll(document) : enableBodyScroll(document)
}, [modalVisible]);
return (
<div className="App">
<div className="lorem">
{rows.map((_, index) => (
<div key={index}>{index}</div>
))}
</div>
<button
onClick={() => setModalVisible(true)}
style={{ "font-size": "16px" }}
>
open drawer
</button>
{modalVisible && (
<div className="drawer-root">
<div
className="drawer-mask"
style={{
position: "fixed",
height: "100%",
"background-color": "#00000073",
inset: "0"
}}
/>
<div
className="drawer"
style={{
"box-sizing": "border-box",
position: "fixed",
bottom: "0",
height: "calc(100% - 64px)",
width: "100%",
background: "white",
padding: "16px",
display: "flex",
"flex-direction": "column",
"justify-content": "space-between"
}}
>
<input placeholder="Search..." style={{ "font-size": "16px" }} />
<button
onClick={() => setModalVisible(false)}
style={{ "font-size": "16px" }}
>
close drawer
</button>
</div>
</div>
)}
</div>
);
}
Upvotes: 1