{"id":2293,"date":"2022-01-26T09:26:15","date_gmt":"2022-01-26T09:26:15","guid":{"rendered":"https:\/\/lvboard.infostore.in.ua\/?p=2293"},"modified":"2022-01-26T09:26:15","modified_gmt":"2022-01-26T09:26:15","slug":"create-an-advanced-scroll-lock-react-hook","status":"publish","type":"post","link":"https:\/\/lvboard.infostore.in.ua\/?p=2293","title":{"rendered":"Create an advanced scroll lock React Hook"},"content":{"rendered":"\n<h2>Introduction<\/h2>\n\n\n\n<p>Scroll lock is a technique used on websites when we don\u2019t want a user to scroll the page. This sounds counterintuitive; why would we ever want to prevent a user from scrolling our web page to see content!?<\/p>\n\n\n\n<!--more-->\n\n\n\n<p>In this article, we\u2019ll explore scroll lock, and attempt to create a cross-device React Hook that will handle layout shift caused by applying it. As we go through demonstrations of web pages that don\u2019t have scroll lock, it will become clear when and why we would want to prevent a user from scrolling our pages.<\/p>\n\n\n\n<p>The best way to get an appreciation for scroll lock is to demonstrate the experience that a user will get when scroll lock isn\u2019t considered:<\/p>\n\n\n\n<div class=\"wp-block-image\"><figure class=\"aligncenter\"><img src=\"https:\/\/blog.logrocket.com\/wp-content\/uploads\/2021\/12\/webpage-without-scroll-lock-psd.gif\" alt=\"Animation Displaying Background Page Scrolling while modal is open\" class=\"wp-image-83534\"\/><\/figure><\/div>\n\n\n\n<p>In the image above, we can see a user opening a quick view modal. When the modal opens, the user places their cursor over the modal content and scrolls their mouse wheel; the background page moves! This can be very disorienting to a user as it\u2019s not what they would expect to happen.<\/p>\n\n\n\n<p>What happens if the quick view container has some long content itself, and has its own scrollbar?:<\/p>\n\n\n\n<div class=\"wp-block-image\"><figure class=\"aligncenter\"><img src=\"https:\/\/blog.logrocket.com\/wp-content\/uploads\/2021\/12\/webpage-without-scroll-lock-modal-psd.gif\" alt=\"Animation Displaying Scrolling Contexts\" class=\"wp-image-83545\"\/><\/figure><\/div>\n\n\n\n<p>In the capture above, we can see that the modal gets opened, and the scrollable content within that modal is scrolled. When we get to the bottom of that content, the background page then starts to scroll.<\/p>\n\n\n\n<p>Attempting to scroll back up only scrolls the background page up, not the content that the mouse is hovering over. It\u2019s not until scrolling pauses for a second, that the browser will allow the cursor to scroll the content in the modal.<\/p>\n\n\n\n<p>A scrolling background is also a nuisance when dealing with a mobile menu. Oftentimes the mobile menu will sit completely over the top of the content, or take up 90 percent of the viewport.<a href=\"https:\/\/blog.logrocket.com\/create-advanced-scroll-lock-react-hook\/\"><\/a><\/p>\n\n\n\n<p>As we demonstrated above, the browser will still allow a page underneath an element to scroll, which means it\u2019s very easy for a user to open the menu, accidentally scroll the background page, close the menu without making any selections, and be shown completely different content.<\/p>\n\n\n\n<h2>Implementing scroll lock<\/h2>\n\n\n\n<p>Lets update our application to account for users scrolling when we wouldn\u2019t expect them to scroll. We\u2019ll start by creating a Hook, importing it into our component, and then setting up the scroll lock implementation.<\/p>\n\n\n\n<p>First, the structure of our Hook:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">import React from 'react';\nexport const useScrollLock = () =&gt; { \n  const lockScroll = React.useCallback(() =&gt; { \n    \/* ... *\/\n  }, [])\n\n  const unlockScroll = React.useCallback(() =&gt; { \n    \/* ... *\/\n  }, []);\n\n  return {\n    lockScroll,\n    unlockScroll\n  };  \n}<\/pre>\n\n\n\n<p>Next, lets import that Hook into our component:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">const PLP = () =&gt; {\n  const [quickViewProductId, setQuickViewProductId] = React.useState(0);\n  const { lockScroll, unlockScroll } = useScrollLock();\n\n  const displayQuickView = (productId) =&gt; {\n    lockScroll();\n    setQuickViewProductId(productId);\n  }\n\n  const hideQuickView = () =&gt; {\n    unlockScroll();\n    setQuickViewProductId(0);\n  }\n\n  return (\n    \/* Products list and conditionally rendered quickview modal *\/\n  );\n};<\/pre>\n\n\n\n<p>Now that we have the bones of our application, lets implement the <code>lockScroll<\/code> and <code>unlockScroll<\/code> functions:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">const lockScroll = React.useCallback(() =&gt; {\n  document.body.style.overflow = 'hidden';\n}, [])\n\nconst unlockScroll = React.useCallback(() =&gt; {\n  document.body.style.overflow = '';\n}, [])<\/pre>\n\n\n\n<p>That\u2019s it! Our scroll lock functions are set up and working as expected. We could call it done and start using it in our app. But there are still a few details that need to be addressed.<\/p>\n\n\n\n<div class=\"wp-block-image\"><figure class=\"aligncenter\"><img src=\"https:\/\/blog.logrocket.com\/wp-content\/uploads\/2021\/12\/scroll-lock-layout-shift.gif\" alt=\"Animation showing layout shift when scroll lock is applied\" class=\"wp-image-83547\"\/><\/figure><\/div>\n\n\n\n<p>Above, you might notice a slight issue when the <code>lockScroll<\/code> function is called. Take a close look at the right side of the image below, and you\u2019ll notice the scrollbar disappears. Nothing wrong with it disappearing, this is exactly what we want, as that tells the browser that the user can\u2019t scroll.<\/p>\n\n\n\n<p>However, with the scrollbar disappearing, the width of the page has increased, so any centered content is no longer centered and needs to shift across slightly. This slight shift is very noticeable to a user.<\/p>\n\n\n\n<h2>Fixing layout shift<\/h2>\n\n\n\n<p>In order to prevent the layout shift from happening, let\u2019s compensate for the width of the browser scrollbar.<\/p>\n\n\n\n<p>Start by measuring the width of our browser scrollbar. We\u2019ll pull out a pixel ruler and check just how wide that scrollbar is:<\/p>\n\n\n\n<div class=\"wp-block-image\"><figure class=\"aligncenter\"><img src=\"https:\/\/blog.logrocket.com\/wp-content\/uploads\/2021\/12\/measuring-width-scrollbar.png\" alt=\"Measuring width of scrollbar\" class=\"wp-image-83243\"\/><\/figure><\/div>\n\n\n\n<p>My browser window is giving me a width of 17px. Great, lets make use of this value in our Hook:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">const lockScroll = React.useCallback(() =&gt; {\n  document.body.style.overflow = 'hidden';\n  document.body.style.paddingRight = '17px'\n}, [])\n\nconst unlockScroll = React.useCallback(() =&gt; {\n  document.body.style.overflow = '';\n  document.body.style.paddingRight = ''\n}, [])<\/pre>\n\n\n\n<p>And the result:<\/p>\n\n\n\n<div class=\"wp-block-image\"><figure class=\"aligncenter\"><img src=\"https:\/\/blog.logrocket.com\/wp-content\/uploads\/2021\/12\/scroll-lock-without-layout-shift.gif\" alt=\"Animation showing layout shift has been prevented for scroll lock\" class=\"wp-image-83550\"\/><\/figure><\/div>\n\n\n\n<p>Looking pretty good! We can see that the scrollbar disappears, and the content isn\u2019t shifting at all.<\/p>\n\n\n\n<p>Lets just run a quick check in another browser, in this case, Opera:<\/p>\n\n\n\n<div class=\"wp-block-image\"><figure class=\"aligncenter\"><img src=\"https:\/\/blog.logrocket.com\/wp-content\/uploads\/2021\/12\/scroll-lock-cross-browser-layout-shift.gif\" alt=\"Animation showing that the scrollbar compensation doesn\u2019t work cross browser\" class=\"wp-image-83554\"\/><\/figure><\/div>\n\n\n\n<p>Ah, it seems this doesn\u2019t work in Opera, the content is shifting again, the other way! That must mean the scrollbar width isn\u2019t consistent between browsers even on the same OS. I\u2019m sure most people would have already known this, but it\u2019s still worth demonstrating the point.<\/p>\n\n\n\n<p>Now when I mention that macOS, iOS, and Android are likely going to have very different default scrollbar widths, it can be more easily appreciated that we can\u2019t just hard code a value for compensation. We will need to calculate the scrollbar\u2019s width and use that result as the padding value on the body element.<\/p>\n\n\n\n<h2>Calculating scrollbar width<\/h2>\n\n\n\n<p>To dynamically calculate the width of the scrollbar, we can use the inner width of the browser window (inner because we need to allow for a user who is browsing with a viewport that isn\u2019t maximized to their monitor), and the width of the body element. The difference between these two widths will be the width of the scrollbar itself:<\/p>\n\n\n\n<div class=\"wp-block-image\"><figure class=\"aligncenter\"><img src=\"https:\/\/blog.logrocket.com\/wp-content\/uploads\/2021\/12\/scrollbar-width-measurements.png\" alt=\"Diagram showing the parts of the UI that are measured to get scrollbar width\" class=\"wp-image-83250\"\/><\/figure><\/div>\n\n\n\n<p>Let\u2019s update our Hook to use this value for the padding applied to the body element, and then recheck our app in Opera:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">const lockScroll = React.useCallback(\n  () =&gt; {\n    const scrollBarCompensation = window.innerWidth - document.body.offsetWidth;\n    document.body.style.overflow = 'hidden';\n    document.body.style.paddingRight = `${scrollBarCompensation}px`;\n  }, [])<\/pre>\n\n\n\n<div class=\"wp-block-image\"><figure class=\"aligncenter\"><img src=\"https:\/\/blog.logrocket.com\/wp-content\/uploads\/2021\/12\/cross-browser-scroll-lock-layout-shift-fixed.gif\" alt=\"Animation showing layout shift fixed cross browser also\" class=\"wp-image-83561\"\/><\/figure><\/div>\n\n\n\n<p>That\u2019s much better! The different width of the scrollbar used by default in Opera is now being appropriately compensated for. I\u2019ve checked Chrome too, and it\u2019s working as before. You\u2019ll have to take my word for it, or you can download the code from <a href=\"https:\/\/github.com\/denno020\/useScrollLock\" target=\"_blank\" rel=\"noreferrer noopener\">GitHub<\/a> and test it out yourself!<\/p>\n\n\n\n<p>This Hook is looking great, we\u2019re pretty much ready for production! However, there are a couple more things we\u2019ll want to consider, like iOS Safari and sticky elements.<\/p>\n\n\n\n<h2>Scroll lock for sticky elements<\/h2>\n\n\n\n<p>Ecommerce websites use sticky elements all the time: headers, promo bars, filters, modals, footers, and the live chat or floating action buttons (FAB).<\/p>\n\n\n\n<p>Lets look at the FAB to extend our scroll lock implementation. First, how is the FAB positioned?<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">.button--help {\n  position: fixed;\n  right: 10px;\n  top: 90vh;\n  \/* ... *\/\n}<\/pre>\n\n\n\n<p>We\u2019ve placed the FAB in the bottom right corner of the viewport. We want it always to be visible, because we want our users to be able to access help as quick as possible.<\/p>\n\n\n\n<p>What happens to this button when we open our quick view modal and enable scroll lock?<\/p>\n\n\n\n<div class=\"wp-block-image\"><figure class=\"aligncenter\"><img src=\"https:\/\/blog.logrocket.com\/wp-content\/uploads\/2021\/12\/sticky-element-layout-shift.gif\" alt=\"Animation showing that fixed position elements still suffer from layout shift\" class=\"wp-image-83566\"\/><\/figure><\/div>\n\n\n\n<p>It appears the button is shifting when scroll lock is applied! As the element is no longer placed within the document flow of the body element, the scrollbar compensation doesn\u2019t have any effect.<\/p>\n\n\n\n<p>At this point, we need to branch out from just our Hook in order to prevent this layout shift, and the layout shift that would occur for any sticky elements.<\/p>\n\n\n\n<p>To do that, we\u2019re going to use our Hook to set a CSS custom property on the body element, which will be used within the styling of any element that we give a fixed position, as an offset on the ride side.<\/p>\n\n\n\n<p>Some code will make that description clearer:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">export const useScrollLock = () =&gt; {\n  const lockScroll = React.useCallback(\n    () =&gt; {\n      \/\/ ...\n      document.body.style.paddingRight = 'var(--scrollbar-compensation)';\n      document.body.dataset.scrollLock = 'true';\n    }, [])\n\n  const unlockScroll = React.useCallback(\n  () =&gt; {\n    \/\/ ....\n    delete document.body.dataset.scrollLock;\n  }, []);\n\n  React.useLayoutEffect(() =&gt; {\n    const scrollBarCompensation = window.innerWidth - document.body.offsetWidth;\n    document.body.style.setProperty('--scrollbar-compensation', `${scrollBarCompensation}px`);\n  }, [])\n\n  \/\/ ...\n}<\/pre>\n\n\n\n<p>We\u2019ve added a <code>useLayoutEffect<\/code> to our Hook that will set the CSS custom property on the body element, and seeing as though we now have that compensation value available, we\u2019re making use of it when adding padding to the body, rather than calculating it again. We\u2019re also adding a data property onto the body element that we can use as a trigger to conditionally use the <code>--scrollbar-compensation<\/code> variable.<\/p>\n\n\n\n<p>There is the potential for the <code>--scrollbar-compensation<\/code> value to be set on the body element multiple times if there are multiple components being rendered that make use of the <code>useScrollLock<\/code> Hook, but setting a CSS custom property on an element doesn\u2019t appear to cause a browser repaint, so there should be minimal performance drawbacks.<\/p>\n\n\n\n<p>Now that we have <code>--scrollbar-compensation<\/code> available to any element that is a child of the body element (which is every element), we can use it when styling those elements!<\/p>\n\n\n\n<p>Here is our styling for the FAB again, with the CSS custom property being put to use, and the result when applying scroll lock:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">[data-scroll-lock] .button--help {\n  margin-right: var(--scrollbar-compensation);\n}<\/pre>\n\n\n\n<div class=\"wp-block-image\"><figure class=\"aligncenter\"><img src=\"https:\/\/blog.logrocket.com\/wp-content\/uploads\/2021\/12\/sticky-element-layout-shift-fixed-1.gif\" alt=\"Animation showing fixed position elements no longer shifting when scoll lock applied\" class=\"wp-image-83571\"\/><\/figure><\/div>\n\n\n\n<p>The FAB isn\u2019t going anywhere! Our modal is opening, scroll lock is being applied, and none of the UI is shifting at all. We\u2019re very close to the finish line now! We\u2019ve done a cross-browser check, now we have to do a quick cross-device check.<\/p>\n\n\n\n<h2>Scroll lock for iOS<\/h2>\n\n\n\n<p>It appears that the scroll lock function isn\u2019t working on iOS.<\/p>\n\n\n\n<div class=\"wp-block-image\"><figure class=\"aligncenter\"><img src=\"https:\/\/blog.logrocket.com\/wp-content\/uploads\/2021\/12\/broken-scroll-lock-ios.gif\" alt=\"Animation showing scroll lock not working on iOS\" class=\"wp-image-83575\"\/><\/figure><\/div>\n\n\n\n<p>Opening the modal does apply our scroll lock that we\u2019ve developed thus far, but that scroll lock doesn\u2019t have any effect in iOS.<\/p>\n\n\n\n<p>As with all browser quirks we\u2019ve had to hack around over the years, there are many ways to solve for iOS. We\u2019re going to handle iOS specifically, with a user agent sniff and an adaption of an approach originally presented by <a href=\"https:\/\/markus.oberlehner.net\/blog\/simple-solution-to-prevent-body-scrolling-on-ios\/\" target=\"_blank\" rel=\"noreferrer noopener\">Markus Oberlehner<\/a>:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">const lockScroll = React.useCallback(\n  () =&gt; {\n    document.body.dataset.scrollLock = 'true';\n    document.body.style.overflow = 'hidden';\n    document.body.style.paddingRight = 'var(--scrollbar-compensation)';\n\n    if (isiOS) {\n      scrollOffset.current = window.pageYOffset;\n      document.body.style.position = 'fixed';\n      document.body.style.top = `-${scrollOffset.current}px`;\n      document.body.style.width = '100%';\n    }\n  }, [])\n\nconst unlockScroll = React.useCallback(\n  () =&gt; {\n    document.body.style.overflow = '';\n    document.body.style.paddingRight = '';\n\n    if (isiOS) {\n      document.body.style.position = '';\n      document.body.style.top = ``;\n      document.body.style.width = '';\n      window.scrollTo(0, scrollOffset.current);\n    }\n    delete document.body.dataset.scrollLock;\n  }, []);<\/pre>\n\n\n\n<p>The idea of the approach is to set the body to <code>position<\/code> <code>=<\/code> <code>'fixed'<\/code> and then programmatically offset the body to match the current scroll distance, which will compensate for the browser wanting to display the top of the body content at the top of the viewport.<\/p>\n\n\n\n<p>When scroll lock is disabled, we use the scroll offset value to jump the browser window down to the same place that it was before the user opened the modal. All of these changes result in an effect that mimics the scroll lock that is much easier in other browsers.<\/p>\n\n\n\n<div class=\"wp-block-image\"><figure class=\"aligncenter\"><img src=\"https:\/\/blog.logrocket.com\/wp-content\/uploads\/2021\/12\/scroll-lock-ios-fixed.gif\" alt=\"Animation showing scroll lock fixed for iOS\" class=\"wp-image-83263\"\/><\/figure><\/div>\n\n\n\n<h2>Conclusion<\/h2>\n\n\n\n<p>There we are, we now have our completed Hook, and we\u2019ve tried our best to ensure it will work on as many devices as possible. Hopefully now you\u2019ll have a better appreciation for the times that we want to prevent a user from being able to scroll our web page \u2013 to avoid that user getting disoriented.<\/p>\n\n\n\n<p>We might think that users wouldn\u2019t try to keep scrolling a section of a modal when the scrollbar is clearly at the end, or try scrolling a menu when there is clearly no indication that there is more content to scroll to. However, users use our websites in weird and wonderful ways, and the best we can do is to help them not get into a situation where they\u2019re lost, disoriented, or frustrated, as that could directly lead to them leaving the website and finding another.<\/p>\n\n\n\n<h2>Full visibility into production React apps<\/h2>\n\n\n\n<p>Debugging React applications can be difficult, especially when users experience issues that are hard to reproduce. If you\u2019re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, <a href=\"https:\/\/www2.logrocket.com\/react-performance-monitoring\" target=\"_blank\" rel=\"noreferrer noopener\">try LogRocket<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Introduction Scroll lock is a technique used on websites when we don\u2019t want a user to scroll the page. This sounds counterintuitive; why would we ever want to prevent a user from scrolling our web page to see content!?<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":[],"categories":[30],"tags":[65],"_links":{"self":[{"href":"https:\/\/lvboard.infostore.in.ua\/index.php?rest_route=\/wp\/v2\/posts\/2293"}],"collection":[{"href":"https:\/\/lvboard.infostore.in.ua\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/lvboard.infostore.in.ua\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/lvboard.infostore.in.ua\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/lvboard.infostore.in.ua\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=2293"}],"version-history":[{"count":1,"href":"https:\/\/lvboard.infostore.in.ua\/index.php?rest_route=\/wp\/v2\/posts\/2293\/revisions"}],"predecessor-version":[{"id":2294,"href":"https:\/\/lvboard.infostore.in.ua\/index.php?rest_route=\/wp\/v2\/posts\/2293\/revisions\/2294"}],"wp:attachment":[{"href":"https:\/\/lvboard.infostore.in.ua\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=2293"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/lvboard.infostore.in.ua\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=2293"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/lvboard.infostore.in.ua\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=2293"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}