68

I have a popup list which is a div that contains a vertical list of child divs. I have added up/down keyboard navigation to change which child is currently highlighted.

Right now, if I press the down key enough times, the highlighted item is no longer visible. The same thing also occurs with the up key if the view is scrolled.

What is the right way in React to automatically scroll a child div into view?

MindJuice
  • 4,121
  • 3
  • 29
  • 41

9 Answers9

83

I assume that you have some sort of List component and some sort of Item component. The way I did it in one project was to let the item know if it was active or not; the item would ask the list to scroll it into view if necessary. Consider the following pseudocode:

class List extends React.Component {
  render() {
    return <div>{this.props.items.map(this.renderItem)}</div>;
  }

  renderItem(item) {
    return <Item key={item.id} item={item}
                 active={item.id === this.props.activeId}
                 scrollIntoView={this.scrollElementIntoViewIfNeeded} />
  }

  scrollElementIntoViewIfNeeded(domNode) {
    var containerDomNode = React.findDOMNode(this);
    // Determine if `domNode` fully fits inside `containerDomNode`.
    // If not, set the container's scrollTop appropriately.
  }
}

class Item extends React.Component {
  render() {
    return <div>something...</div>;
  }

  componentDidMount() {
    this.ensureVisible();
  }

  componentDidUpdate() {
    this.ensureVisible();
  }

  ensureVisible() {
    if (this.props.active) {
      this.props.scrollIntoView(React.findDOMNode(this));
    }
  }
}

A better solution is probably to make the list responsible for scrolling the item into view (without the item being aware that it's even in a list). To do so, you could add a ref attribute to a certain item and find it with that:

class List extends React.Component {
  render() {
    return <div>{this.props.items.map(this.renderItem)}</div>;
  }

  renderItem(item) {
    var active = item.id === this.props.activeId;
    var props = {
      key: item.id,
      item: item,
      active: active
    };
    if (active) {
      props.ref = "activeItem";
    }
    return <Item {...props} />
  }

  componentDidUpdate(prevProps) {
    // only scroll into view if the active item changed last render
    if (this.props.activeId !== prevProps.activeId) {
      this.ensureActiveItemVisible();
    }
  }

  ensureActiveItemVisible() {
    var itemComponent = this.refs.activeItem;
    if (itemComponent) {
      var domNode = React.findDOMNode(itemComponent);
      this.scrollElementIntoViewIfNeeded(domNode);
    }
  }

  scrollElementIntoViewIfNeeded(domNode) {
    var containerDomNode = React.findDOMNode(this);
    // Determine if `domNode` fully fits inside `containerDomNode`.
    // If not, set the container's scrollTop appropriately.
  }
}

If you don't want to do the math to determine if the item is visible inside the list node, you could use the DOM method scrollIntoView() or the Webkit-specific scrollIntoViewIfNeeded, which has a polyfill available so you can use it in non-Webkit browsers.

TinyTimZamboni
  • 5,275
  • 3
  • 28
  • 24
Michelle Tilley
  • 157,729
  • 40
  • 374
  • 311
  • Thanks, your first example is what I was thinking of, but I prefer your second example where the child nodes don't need to worry about it. – MindJuice May 28 '15 at 21:44
  • 3
    Wow, `Element.scrollIntoView()` is super awesome. – js-coder Mar 08 '16 at 09:46
  • 14
    Just a note: in React 15.x and greater you will want to use `ReactDom.findDOMNode` instead of `React.findDOMNode` – knowuh Aug 12 '16 at 20:12
  • I show another similar approach where I add an empty element at the bottom of the list to which I can always scrollIntoView in [this answer](http://stackoverflow.com/a/41700815/544059). – metakermit Jan 17 '17 at 15:23
  • 1
    Setting the ref to a string is [deprecated](https://reactjs.org/docs/refs-and-the-dom.html#legacy-api-string-refs). It is recommended to use either the [callback pattern](https://reactjs.org/docs/refs-and-the-dom.html#callback-refs) or the [createRef API](https://reactjs.org/docs/refs-and-the-dom.html#creating-refs) instead. – Pitt Sep 07 '18 at 07:37
  • While this works, I find eon's answer cleaner and more up-to-date. – Elron Nov 28 '20 at 02:37
25

For React 16, the correct answer is different from earlier answers:

class Something extends Component {
  constructor(props) {
    super(props);
    this.boxRef = React.createRef();
  }

  render() {
    return (
      <div ref={this.boxRef} />
    );
  }
}

Then to scroll, just add (after constructor):

componentDidMount() {
  if (this.props.active) { // whatever your test might be
    this.boxRef.current.scrollIntoView();
  }
}

Note: You must use '.current,' and you can send options to scrollIntoView:

scrollIntoView({
  behavior: 'smooth',
  block: 'center',
  inline: 'center',
});

(Found at http://www.albertgao.xyz/2018/06/07/scroll-a-not-in-view-component-into-the-view-using-react/)

Reading the spec, it was a little hard to suss out the meaning of block and inline, but after playing with it, I found that for a vertical scrolling list, block: 'end' made sure the element was visible without artificially scrolling the top of my content off the viewport. With 'center', an element near the bottom would be slid up too far and empty space appeared below it. But my container is a flex parent with justify: 'stretch' so that may affect the behavior. I didn't dig too much further. Elements with overflow hidden will impact how the scrollIntoView acts, so you'll probably have to experiment on your own.

My application has a parent that must be in view and if a child is selected, it then also scrolls into view. This worked well since parent DidMount happens before child's DidMount, so it scrolls to the parent, then when the active child is rendered, scrolls further to bring that one in view.

eon
  • 1,868
  • 1
  • 23
  • 24
  • AFAIK, the didMount of parent happens after the child https://stackoverflow.com/questions/32814970/is-componentdidmount-of-parent-called-after-all-componentdidmount-of-children – gaurav5430 Dec 19 '18 at 09:43
  • Yeah, that's actually true, @gaurav5430. Don't know what I was thinking! – eon Mar 18 '19 at 20:02
  • 1
    If you get an error that says "scrollIntoView not a function," remember to assign the `ref` to a standard DOM element such as `div`, and not a React component. More details [here](https://stackoverflow.com/a/42448466/11838196). – srk Nov 25 '20 at 17:04
8

Another example which uses function in ref rather than string

class List extends React.Component {
  constructor(props) {
    super(props);
    this.state = { items:[], index: 0 };
    this._nodes = new Map();

    this.handleAdd = this.handleAdd.bind(this);
    this.handleRemove = this.handleRemove.bind(this);
   }

  handleAdd() {
    let startNumber = 0;
    if (this.state.items.length) {
      startNumber = this.state.items[this.state.items.length - 1];
    }

    let newItems = this.state.items.splice(0);
    for (let i = startNumber; i < startNumber + 100; i++) {
      newItems.push(i);
    }

    this.setState({ items: newItems });
  }

  handleRemove() {
    this.setState({ items: this.state.items.slice(1) });
  }

  handleShow(i) {
    this.setState({index: i});
    const node = this._nodes.get(i);
    console.log(this._nodes);
    if (node) {
      ReactDOM.findDOMNode(node).scrollIntoView({block: 'end', behavior: 'smooth'});
    }
  }

  render() {
    return(
      <div>
        <ul>{this.state.items.map((item, i) => (<Item key={i} ref={(element) => this._nodes.set(i, element)}>{item}</Item>))}</ul>
        <button onClick={this.handleShow.bind(this, 0)}>0</button>
        <button onClick={this.handleShow.bind(this, 50)}>50</button>
        <button onClick={this.handleShow.bind(this, 99)}>99</button>
        <button onClick={this.handleAdd}>Add</button>
        <button onClick={this.handleRemove}>Remove</button>
        {this.state.index}
      </div>
    );
  }
}

class Item extends React.Component
{
  render() {
    return (<li ref={ element => this.listItem = element }>
      {this.props.children}
    </li>);
  }
}

Demo: https://codepen.io/anon/pen/XpqJVe

Steven
  • 2,258
  • 1
  • 22
  • 22
7

To build on @Michelle Tilley's answer, I sometimes want to scroll if the user's selection changes, so I trigger the scroll on componentDidUpdate. I also did some math to figure out how far to scroll and whether scrolling was needed, which for me looks like the following:

  componentDidUpdate() {
    let panel, node;
    if (this.refs.selectedSection && this.refs.selectedItem) {
      // This is the container you want to scroll.          
      panel = this.refs.listPanel;
      // This is the element you want to make visible w/i the container
      // Note: You can nest refs here if you want an item w/i the selected item          
      node = ReactDOM.findDOMNode(this.refs.selectedItem);
    }

    if (panel && node &&
      (node.offsetTop > panel.scrollTop + panel.offsetHeight || node.offsetTop < panel.scrollTop)) {
      panel.scrollTop = node.offsetTop - panel.offsetTop;
    }
  }
Yonatan Kogan
  • 298
  • 3
  • 7
3

With reacts Hooks:

  1. Import
import ReactDOM from 'react-dom';
import React, {useRef} from 'react';
  1. Make new hook:
const divRef = useRef<HTMLDivElement>(null);
  1. Add new Div
<div ref={divRef}/>
  1. Scroll function:
const scrollToDivRef  = () => {
    let node = ReactDOM.findDOMNode(divRef.current) as Element;
    node.scrollIntoView({block: 'start', behavior: 'smooth'});
}
2

Just in case someone stumbles here, I did it this way

  componentDidMount(){
    const node = this.refs.trackerRef;
    node && node.scrollIntoView({block: "end", behavior: 'smooth'})
  }
  componentDidUpdate() {
    const node = this.refs.trackerRef;
    node && node.scrollIntoView({block: "end", behavior: 'smooth'})
  }

  render() {
    return (

      <div>
        {messages.map((msg, index) => {
          return (
            <Message key={index} msgObj={msg}
              {/*<p>some test text</p>*/}
            </Message>
          )
        })}

        <div style={{height: '30px'}} id='#tracker' ref="trackerRef"></div>
      </div>
    )
  }

scrollIntoView is native DOM feature link

It will always shows tracker div

Aishwat Singh
  • 4,331
  • 2
  • 26
  • 48
1

In you keyup/down handler you just need to set the scrollTop property of the div you want to scroll to make it scroll down (or up).

For example:

JSX:

<div ref="foo">{content}</div>

keyup/down handler:

this.refs.foo.getDOMNode().scrollTop += 10

If you do something similar to above, your div will scroll down 10 pixels (assuming the div is set to overflow auto or scroll in css, and your content is overflowing of course).

You will need to expand on this to find the offset of the element inside your scrolling div that you want to scroll the div down to, and then modify the scrollTop to scroll far enough to show the element based on it's height.

Have a look at MDN's definitions of scrollTop, and offsetTop here:

https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTop

https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetTop

Mike Driver
  • 8,481
  • 3
  • 36
  • 37
0

I'm just adding another bit of info for others searching for a Scroll-To capability in React. I had tied several libraries for doing Scroll-To for my app, and none worked from my use case until I found react-scrollchor, so I thought I'd pass it on. https://github.com/bySabi/react-scrollchor

MartinDuo
  • 663
  • 12
  • 25
  • Similarly, I've used https://www.npmjs.com/package/scroll-into-view to good effect. Very simple to implement. Inside the component that should be made visible: componentDidMount() { scrollIntoView(React.findDOMNode(this)) } – jsaven Jan 14 '18 at 00:30
  • @jsaven I think you mean `ReactDOM.findDOMNode(this)`: https://reactjs.org/docs/react-dom.html#finddomnode – Sgnl Feb 15 '18 at 09:52
0

I had a NavLink that I wanted to when clicked will scroll to that element like named anchor does. I implemented it this way.

 <NavLink onClick={() => this.scrollToHref('plans')}>Our Plans</NavLink>
  scrollToHref = (element) =>{
    let node;
    if(element === 'how'){
      node = ReactDom.findDOMNode(this.refs.how);
      console.log(this.refs)
    }else  if(element === 'plans'){
      node = ReactDom.findDOMNode(this.refs.plans);
    }else  if(element === 'about'){
      node = ReactDom.findDOMNode(this.refs.about);
    }

    node.scrollIntoView({block: 'start', behavior: 'smooth'});

  }

I then give the component I wanted to scroll to a ref like this

<Investments ref="plans"/>
Everistus Olumese
  • 384
  • 1
  • 4
  • 16