bapafes482
bapafes482

Reputation: 530

Prevent page scroll when focusing input located inside of a modal

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

bug recording

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.

notion recording

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

Answers (5)

ug_
ug_

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

user18821127
user18821127

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

Marco Scapin
Marco Scapin

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

inwerpsel
inwerpsel

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

mansoureh.hedayat
mansoureh.hedayat

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

Related Questions