Form Field Scroll Management in React

Nathan Force
UI Engineer at The Palmer Group

Just reading that title makes me a bit nauseous. We often hear that changing your user's scroll position is an unforgivably bad practice and will land you straight in UX Jail. Hijacking a user's expected browser experience is a definite no-no, but in some cases it can help enhance and improve an otherwise confusing flow.

In this post I'm going to discuss a method for enhancing your forms by scrolling to the first invalid field upon submission and the journey of how we arrived at this solution at The Palmer Group.

The Problem

Some of our forms are quite lengthy, sometimes spanning beyond the viewport height. This means that by the time you fill out the form and submit it, you are unable to see the fields at the top of the form. If one of those fields is invalid, you are unable to submit the form but it is not immediately obvious why. We wondered if there was a nice way to highlight these offscreen errors for the user.

react-scroll-into-view-if-needed

As practicing open source members, we first searched around for solutions that have already solved this problem. To our delight, we found react-scroll-into-view-if-needed. This repo seemed like exactly what we wanted. You wrap your field in their wrapper, which accepts any element supported by React.createElement, and when you set the active prop to true, the node will be scrolled into view. Simple as that.

Our problem came when we wanted to focus our field after scrolling. This was a critical feature, because if we didn't focus the control, then page focus would remain on the now offscreen submit button. If a user pressed tab after being scrolled up, the user would be jumped back down to the bottom of the form. The focus has to fire after the scroll is done; otherwise, the browser will jump the scroll position to the focused input. react-scroll-into-view-if-needed currently does not expose hooks to perform a task when the scroll is complete, though it seems they have it in their roadmap.

Luckily, the underlying library, smooth-scroll-into-view-if-needed, does expose a Promise that we can await before focusing. We decided to create a solution that used this library to give us the hooks we needed to coordinate scrolling and focus management.

Desired Outcome

  • When attempting to submit the form, if there are any errors, we should scroll to the first one.
  • Scrolling should be smooth.
  • After the scroll, we should focus the field.
  • Ideally this is done in a declarative manner so that we can reuse the solution throughout our forms.

Do It

There are a few questions we need to answer when thinking about how to do this.

  • Do we have any errors?
  • Which DOM node maps to each error?
  • Which error is the first error, from the user's perspective?

1. Do we have any errors?

This should be straightforward. In my case, I am using Formik, so this is as simple as checking formik.errors. If not using Formik, you'll need some way of knowing an array of field IDs that are invalid.

2. Relationship between DOM nodes and fields

When making forms, we often think of them as just a set of inputs, but you likely have a set of DOM nodes for each field.

Field anatomy

  • Wrapper (green)
  • Label
  • Control
  • Feedback/Error

So which do we scroll to? I think the nicer experience is to scroll to the wrapper of the entire field, which adds a bit of extra work for focusing the actual form control after we scroll, as we now need references to two DOM nodes.

3. Who's on First?

How do we determine which error is first? This is the trickiest part of all, and React does not make it any easier.

React

One thing that I've always found tricky in React is the coordination of two or more components inside of a parent. React's composition model enforces that two children of a parent should not know about each other. For us, this means that an invalid field cannot know whether it is the first invalid field or not. This matter gets worse with further nesting, for example when your form fields are not direct children of the parent container (maybe you have some presentational components between like <FormSection />).

How can we solve this? I decided to create a parent component that uses React's Context feature to allow target fields arbitrarily deep in the tree register themselves. Let's call this component <FirstNodeManager>. Now we need each target field to register itself, so we also have <FirstNodeTarget>. Each field is wrapped in FirstNodeTarget, which registers the field and a callback with the FirstNodeManager and applies a data-* attribute to the field. I think we can safely assume that the fields will register with the parent in the order that they happen, for now. As React moves into Async Land, I don't know how reliable this approach will be.

DOM

I don't think that the rendering order of React will always match the appearance order in the DOM, so we'll need to have the FirstNodeManager run an actual DOM query against all of the nodes when submitting the form. document.querySelector() will give us the first DOM node that matches our query. Perfect! FirstNodeManager has a list of field IDs, so we can assemble a selector by joining our field IDs and querying against our data-* attributes.

react-first-matching-node

We've published a library, react-first-matching-node, to help with this functionality, as we've found it helpful to improve the user experience in our application. It exposes two components, Manager and Target. You provide a function to each Target that will be called when that target is the first field. We use this in combination with smooth-scroll-into-view-if-needed to provide a nice user experience with our forms. The basic idea is to get a React ref for our field control (<input ref={this.control}/>) and then create a Target that fires scrollIntoView and then control.focus() when matched as the first matching node.

import { Target } from 'react-first-matching-node';
import smoothScrollIntoView from 'smooth-scroll-into-view-if-needed';
 
// ...
 
    <Target
        id="firstname"
        onMatch={node => {
          scrollIntoView(node, {
            scrollMode: 'if-needed',
            block: 'center',
            inline: 'center',
          }).then(() => {
            if (this.control.current) {
              this.control.current.focus();
            }
          });
        }}
      >

Notice that this solution does not actually couple itself to scrolling at all. If you just wanted to manage focus or do something entirely different with your first matching node, that is entirely in your control.

Result

Scroll to error

Caveats / Gotchas

  • Presentational order vs DOM order: If you use CSS to alter the visual appearance of your forms, this method will not be reliable. Flexbox, CSS Grid, and absolute positioning are some common examples of this.
Nathan Force
UI Engineer at The Palmer Group
Copyright © 2018 The Palmer Group. All Rights Reserved.