Stop Reach Router scrolling down the page after navigating to new page
Asked Answered
A

7

20

When I navigate to a new page, Reach Router scrolls down to the page content past the header (if the content is long enough). I'm assuming this is for accessibility but it's not necessary for my app and it's actually quite jarring. Can this behaviour be disabled?

Note that I'm talking about Reach Router not React Router.

Reach Router

Akela answered 30/10, 2018 at 5:45 Comment(3)
I had the same problem and I tweeted about it to the maintainers. Here's the response: twitter.com/ryanflorence/status/1057280035810410496Decalogue
Same problem here. Is there a way how to execute some code after each route navigation (or each call to navigate())? I would simply put somwthing like window.scrollTo(0, 0) there.Pronate
Can you create a small demo for your problemRobenarobenia
H
11

The top answer here, while solving the OP's problem, is probably not the solution most people want, since it turns off the most important accessibility feature of Reach router.

The fact Reach router focuses the content of the matched <Route> on a route change is for accessibility reasons - so screen readers etc can be directed to the newly updated, relevant content, when you navigate to a new page.

It uses HTMLElement.focus() to do this - see the MDN docs here.

The problem is that by default, this function scrolls to the element being focused. There is a preventScroll argument which can be used to turn this behaviour off, but the browser support for it is not good, and regardless, Reach Router does not use it.

Setting primary={false} turns this behaviour off for any nested <Router> you may have - it is not intended to set false on your main (primary) <Router> -- hence the name.

So, setting primary={false} on your primary <Router>, as the top answer suggests, 'works' in the sense that it stops the scrolling behaviour, but it achieves this by simply turning off the focusing behaviour completely, which breaks the accessibility feature. As I said, if you do this, you're breaking one of the main reasons to use Reach Router in the first place.

So, what's the solution?

Basically, it seems that this side effect of HTMLElement.focus() - scrolling to the focused element - is unavoidable. So if you want the accessibility feature, you have to take the scrolling behaviour with it.

But with that said, there might be a workaround. If you manually scroll to the top of the page using window.scrollTo(0, 0) on every route change, I believe that will not 'break' the focusing feature from an accessibility perspective, but will 'fix' the scrolling behaviour from a UX perspective.

Of course, it's a bit of a hacky and imperative workaround, but I think it's the best (maybe only) solution to this issue without breaking accessibility.

Here's how I implemented it

class OnRouteChangeWorker extends React.Component {
  componentDidUpdate(prevProps) {
    if (this.props.location.pathname !== prevProps.location.pathname) {
      this.props.action()
    }
  }

  render() {
    return null
  }
}

const OnRouteChange = ({ action }) => (
  {/* 
      Location is an import from @reach/router, 
      provides current location from context 
  */}
  <Location>
    {({ location }) => <OnRouteChangeWorker location={location} action={action} />}
  </Location>
)

const Routes = () => (
  <>
    <Router>
      <LayoutWithHeaderBar path="/">
        <Home path="/" />
        <Foo path="/foo" />
        <Bar path="/bar" />
      </LayoutWithHeaderBar>
    </Router>

    {/* 
        must come *after* <Router> else Reach router will call focus() 
        on the matched route after action is called, undoing the behaviour!
    */}
    <OnRouteChange action={() => { window.scrollTo(0, 0) } />
  </>
)
Hellion answered 11/7, 2019 at 20:39 Comment(0)
S
16

Try using <Router primary={false}> which will not focus on the route component.

https://reach.tech/router/api/Router

primary: bool

Defaults to true. Primary Routers will manage focus on location transitions. If false, focus will not be managed. This is useful for Routers rendered as asides, headers, breadcrumbs etc. but not the main content.

WARNING: If you are concerned about breaking accessibility please see this answer: https://mcmap.net/q/618130/-stop-reach-router-scrolling-down-the-page-after-navigating-to-new-page

Sketch answered 12/11, 2018 at 14:43 Comment(5)
Does not help, still scrolls to some strange offsetPronate
Create a codesandbox with your code so we can see itSketch
Warning - I would not recommend this approach. It breaks accessibility, which is one of the main reasons for using Reach Router in the first place. See my answer for more info.Hellion
@Hellion I understand you're concerned about other people using this solution but as the OP stated: "I'm assuming this is for accessibility but it's not necessary for my app", so this IS the right solution for him.Sketch
@Sketch Yeah, that's fair. And not trying to say you've not solved the OP's problem. I just wanted to flag up that this probably isn't the best general solution here for most people using Reach router, for anyone that comes here and just picks the answer with vastly more votes than the others on trust, perhaps missing that detail because they're busy / in a rush.Hellion
H
11

The top answer here, while solving the OP's problem, is probably not the solution most people want, since it turns off the most important accessibility feature of Reach router.

The fact Reach router focuses the content of the matched <Route> on a route change is for accessibility reasons - so screen readers etc can be directed to the newly updated, relevant content, when you navigate to a new page.

It uses HTMLElement.focus() to do this - see the MDN docs here.

The problem is that by default, this function scrolls to the element being focused. There is a preventScroll argument which can be used to turn this behaviour off, but the browser support for it is not good, and regardless, Reach Router does not use it.

Setting primary={false} turns this behaviour off for any nested <Router> you may have - it is not intended to set false on your main (primary) <Router> -- hence the name.

So, setting primary={false} on your primary <Router>, as the top answer suggests, 'works' in the sense that it stops the scrolling behaviour, but it achieves this by simply turning off the focusing behaviour completely, which breaks the accessibility feature. As I said, if you do this, you're breaking one of the main reasons to use Reach Router in the first place.

So, what's the solution?

Basically, it seems that this side effect of HTMLElement.focus() - scrolling to the focused element - is unavoidable. So if you want the accessibility feature, you have to take the scrolling behaviour with it.

But with that said, there might be a workaround. If you manually scroll to the top of the page using window.scrollTo(0, 0) on every route change, I believe that will not 'break' the focusing feature from an accessibility perspective, but will 'fix' the scrolling behaviour from a UX perspective.

Of course, it's a bit of a hacky and imperative workaround, but I think it's the best (maybe only) solution to this issue without breaking accessibility.

Here's how I implemented it

class OnRouteChangeWorker extends React.Component {
  componentDidUpdate(prevProps) {
    if (this.props.location.pathname !== prevProps.location.pathname) {
      this.props.action()
    }
  }

  render() {
    return null
  }
}

const OnRouteChange = ({ action }) => (
  {/* 
      Location is an import from @reach/router, 
      provides current location from context 
  */}
  <Location>
    {({ location }) => <OnRouteChangeWorker location={location} action={action} />}
  </Location>
)

const Routes = () => (
  <>
    <Router>
      <LayoutWithHeaderBar path="/">
        <Home path="/" />
        <Foo path="/foo" />
        <Bar path="/bar" />
      </LayoutWithHeaderBar>
    </Router>

    {/* 
        must come *after* <Router> else Reach router will call focus() 
        on the matched route after action is called, undoing the behaviour!
    */}
    <OnRouteChange action={() => { window.scrollTo(0, 0) } />
  </>
)
Hellion answered 11/7, 2019 at 20:39 Comment(0)
I
4

Building off of @Marcus answer, you can get rid of the jank with useLayoutEffect() instead of useEffect() - this way the scroll action happens after the DOM has been fully rendered, so you don't get the weird "bounce."

// ScrollToTop.js
import React from 'react'

export const ScrollToTop = ({ children, location }) => {
  React.useLayoutEffect(() => window.scrollTo(0, 0), [location.pathname])
  return children
}
Intumesce answered 21/8, 2019 at 19:15 Comment(2)
Perfect, with scroll behaviour set to smooth it even has a nice animation. Other answers did not solve the issue for me, even primary={false}, in a react-static app.Schuller
I'd like to add that if you're using Code Splitting and React.Suspense, you need to add the hook without the location useLayoutEffect(() => window.scrollTo(0,0)) to the fallback component you're using. Otherwise, it won't scroll to the top.Plano
B
3

I had to use a combination of things to make it work. Even with setting primary={false} there were still cases where the pages would not scroll to the top.

      <Router primary={false}>
        <ScrollToTop path="/">
          <Home path="/" />
          <Contact path="contact-us" />
          <ThankYou path="thank-you" />
          <WhoWeAre path="who-we-are" />
        </ScrollToTop>
      </Router>

This is based off of React Router's scroll restoration guide.

The scroll to top component will still work with you don't have primary={false}, but it causes jank from where it focuses the route and then calls window.scrollTo.

// ScrollToTop.js
import React from 'react'

export const ScrollToTop = ({ children, location }) => {
  React.useEffect(() => window.scrollTo(0, 0), [location.pathname])
  return children
}

Burgage answered 11/5, 2019 at 19:27 Comment(1)
That works well, but you'll see a flash of content, before the effect is executedEberhard
H
0

There must be another thing to cause this. Like @Vinicius said. Because for my application primary={false} really works. I have a small application and my routes below.

<Router primary={false}>
  <Home path="/" />
  <Dashboard path="dashboard" />
  <Users path="users" />
  <Sales path="sales" />
  <Settings path="settings" />
</Router>
Hairraising answered 11/4, 2019 at 5:38 Comment(3)
How do you actually trigger navigation? Using Link or using navigate or some orher way (e.g. Redirect)? Does it work with all those methods?Pronate
Also, do you actually have at least 2 pages long enought so that they can be scrolled down? Without that the issue is not noticeable.Pronate
I am using "Link" from @reach/router. And yes Sales, Users pages are really long pages, there is no pagination. But it doesn't scroll to the content.Stays top.Hairraising
L
0

Using primary={false} does not solve all the cases. A common solution is to wrap the page and use useEffect to achieve the result. There might be a flash issue as someone pointed out, although it never happened to me, so try your luck.

<PageWrapper Component={About} path="/about" />

const PageWrapper = ({ path, Component }) => {
  useEffect(() => {
    window.scrollTo(0, 0)
  }, [path])

  return <Component path={path} />
}

As a notice, in React Router, you could use history.listen as explained here. I did not check if there is a similar solution in Reach Router, but that solution would be more optimal.

Lacquer answered 10/1, 2020 at 17:35 Comment(0)
H
0

I made an npm package out of it to make integration simple: https://www.npmjs.com/package/reach-router-scroll-top

Handset answered 22/10, 2020 at 15:15 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.