React - check if element is visible in DOM

JavascriptReactjs

Javascript Problem Overview


I'm building a form - series of questions (radio buttons) the user needs to answer before he can move on to the next screen. For fields validation I'm using yup (npm package) and redux as state management.

For one particular scenario/combination a new screen (div) is revealed asking for a confirmation (checkbox) before the user can proceed. I want to apply the validation for this checkbox only if displayed.

How can I check if an element (div) is displayed in the DOM using React?

The way I thought of doing it was to set a varibale 'isScreenVisible' to false and if the conditions are met I would change the state to 'true'.

I'm doing that check and setting 'isScreenVisible' to true or false in _renderScreen() but for some reason it's going into an infinite loop.

My code:

class Component extends React.Component {

  constructor(props) {
    super(props);

    this.state = {
      formisValid: true,
      errors: {},
      isScreenVisible: false
    }

    this.FormValidator = new Validate();
    this.FormValidator.setValidationSchema(this.getValidationSchema());
  }

  areThereErrors(errors) {
    var key, er = false;
    for(key in errors) {
      if(errors[key]) {er = true}
    }
    return er;
  }

  getValidationSchema() {
    return yup.object().shape({
      TravelInsurance: yup.string().min(1).required("Please select an option"),
      MobilePhoneInsurance: yup.string().min(1).required("Please select an option"),
      Confirmation: yup.string().min(1).required("Please confirm"),
    });
  }

  //values of form fields
  getValidationObject() {
    let openConfirmation = (this.props.store.Confirmation === true)? 'confirmed': ''

    return {
      TravelInsurance: this.props.store.TravelInsurance,
      MobilePhoneInsurance: this.props.store.MobilePhoneInsurance,
      Confirmation: openConfirmation,
    }
  }

  setSubmitErrors(errors) {
    this.setState({errors: errors});
  }

  submitForm() {
    var isErrored, prom, scope = this, obj = this.getValidationObject();
    prom = this.FormValidator.validateSubmit(obj);

    prom.then((errors) => {
      isErrored = this.FormValidator.isFormErrored();

      scope.setState({errors: errors}, () => {
        if (isErrored) {
        } else {
          this.context.router.push('/Confirm');
        }
      });
    });
  }

  saveData(e) {
    let data = {}
    data[e.target.name] = e.target.value

    this.props.addData(data)

    this.props.addData({
      Confirmation: e.target.checked
    })
  }

  _renderScreen = () => {
    const {
      Confirmation
    } = this.props.store

	if(typeof(this.props.store.TravelInsurance) !== 'undefined' && typeof(this.props.store.MobilePhoneInsurance) !== 'undefined') &&
	((this.props.store.TravelInsurance === 'Yes' && this.props.store.MobilePhoneInsurance === 'No') ||
	(this.props.store.TravelInsurance === 'No' && this.props.store.MobilePhoneInsurance === 'Yes')){
        
		this.setState({
			isScreenVisible: true
		})
    
		  return(
		    <div>
				<p>Please confirm that you want to proceed</p>
		     
		          <CheckboxField
		            id="Confirmation"
		            name="Confirmation"
		            value={Confirmation}
		            validationMessage={this.state.errors.Confirmation}
		            label="I confirm that I would like to continue"
		            defaultChecked={!!Confirmation}
		            onClick={(e)=> {this.saveData(e)} }
		          />
		        </FormLabel>
		    </div>
		  )
	  }
	  else{
	  	this.setState({
			isScreenVisible: false
		})
	  }
  }

  render(){
    const {
      TravelInsurance,
      MobilePhoneInsurance
    } = this.props.store

    return (
      <div>           
          <RadioButtonGroup
            id="TravelInsurance"
            name="TravelInsurance"
            checked={TravelInsurance}
            onClick={this.saveData.bind(this)}
            options={{
              'Yes': 'Yes',
              'No': 'No'
            }}
            validationMessage={(this.state.errors.TravelInsurance) ? this.state.errors.TravelInsurance : null }
          />

		<RadioButtonGroup
		  id="MobilePhoneInsurance"
		  name="MobilePhoneInsurance"
		  checked={MobilePhoneInsurance}
		  onClick={this.saveData.bind(this)}
		  options={{
		    'Yes': 'Yes',
		    'No': 'No'
		  }}
		  validationMessage={(this.state.errors.MobilePhoneInsurance) ? this.state.errors.MobilePhoneInsurance : null }
		/>

        this._renderScreen()
       
		<ButtonRow
			primaryProps={{
				children: 'Continue',
				onClick: e=>{
				this.submitForm();
			}
		}}
      </div>
    )
  }
}

const mapStateToProps = (state) => {
  return {
    store: state.Insurance,
  }
}

const Insurance = connect(mapStateToProps,{addData})(Component)

export default Insurance

Javascript Solutions


Solution 1 - Javascript

Here is a reusable hook that takes advantage of the IntersectionObserver API.

The hook

export default function useOnScreen(ref) {

  const [isIntersecting, setIntersecting] = useState(false)

  const observer = new IntersectionObserver(
    ([entry]) => setIntersecting(entry.isIntersecting)
  )

  useEffect(() => {
    observer.observe(ref.current)
    // Remove the observer as soon as the component is unmounted
    return () => { observer.disconnect() }
  }, [])

  return isIntersecting
}

Usage

const DummyComponent = () => {
  
  const ref = useRef()
  const isVisible = useOnScreen(ref)
  
  return <div ref={ref}>{isVisible && `Yep, I'm on screen`}</div>
}

Solution 2 - Javascript

You can attach a ref to the element that you want to check if it is on the viewport and then have something like:

  /**
   * Check if an element is in viewport
   *
   * @param {number} [offset]
   * @returns {boolean}
   */
  isInViewport(offset = 0) {
    if (!this.yourElement) return false;
    const top = this.yourElement.getBoundingClientRect().top;
    return (top + offset) >= 0 && (top - offset) <= window.innerHeight;
  }


  render(){

     return(<div ref={(el) => this.yourElement = el}> ... </div>)

  }

You can attach listeners like onScroll and check when the element will be on the viewport.

You can also use the Intersection Observer API with a polyfil or use a HoC component that does the job

Solution 3 - Javascript

Based on Avraam's answer I wrote a Typescript-compatible small hook to satisfy the actual React code convention.

import { useRef, useEffect, useState } from "react";
import throttle from "lodash.throttle";

/**
 * Check if an element is in viewport

 * @param {number} offset - Number of pixels up to the observable element from the top
 * @param {number} throttleMilliseconds - Throttle observable listener, in ms
 */
export default function useVisibility<Element extends HTMLElement>(
  offset = 0,
  throttleMilliseconds = 100
): [Boolean, React.RefObject<Element>] {
  const [isVisible, setIsVisible] = useState(false);
  const currentElement = useRef<Element>();

  const onScroll = throttle(() => {
    if (!currentElement.current) {
      setIsVisible(false);
      return;
    }
    const top = currentElement.current.getBoundingClientRect().top;
    setIsVisible(top + offset >= 0 && top - offset <= window.innerHeight);
  }, throttleMilliseconds);

  useEffect(() => {
    document.addEventListener('scroll', onScroll, true);
    return () => document.removeEventListener('scroll', onScroll, true);
  });

  return [isVisible, currentElement];
}

Usage example:

const Example: FC = () => {
  const [ isVisible, currentElement ] = useVisibility<HTMLDivElement>(100);

  return <Spinner ref={currentElement} isVisible={isVisible} />;
};

You can find the example on Codesandbox. I hope you will find it helpful!

Solution 4 - Javascript

I have had the same problem, and, looks, I found the pretty good solution in pure react jsx, without installing any libraries.

import React, {Component} from "react";
    
    class OurReactComponent extends Component {

    //attach our function to document event listener on scrolling whole doc
    componentDidMount() {
        document.addEventListener("scroll", this.isInViewport);
    }

    //do not forget to remove it after destroyed
    componentWillUnmount() {
        document.removeEventListener("scroll", this.isInViewport);
    }

    //our function which is called anytime document is scrolling (on scrolling)
    isInViewport = () => {
        //get how much pixels left to scrolling our ReactElement
        const top = this.viewElement.getBoundingClientRect().top;

        //here we check if element top reference is on the top of viewport
        /*
        * If the value is positive then top of element is below the top of viewport
        * If the value is zero then top of element is on the top of viewport
        * If the value is negative then top of element is above the top of viewport
        * */
        if(top <= 0){
            console.log("Element is in view or above the viewport");
        }else{
            console.log("Element is outside view");
        }
    };

    render() {
        // set reference to our scrolling element
        let setRef = (el) => {
            this.viewElement = el;
        };
        return (
            // add setting function to ref attribute the element which we want to check
            <section ref={setRef}>
                {/*some code*/}
            </section>
        );
    }
}

export default OurReactComponent;

I was trying to figure out how to animate elements if the are in viewport.

Here is work project on CodeSandbox.

Solution 5 - Javascript

@Alex Gusev answer without lodash and using useRef

import { MutableRefObject, useEffect, useRef, useState } from 'react'

/**
 * Check if an element is in viewport
 * @param {number} offset - Number of pixels up to the observable element from the top
 */
export default function useVisibility<T>(
  offset = 0,
): [boolean, MutableRefObject<T>] {
  const [isVisible, setIsVisible] = useState(false)
  const currentElement = useRef(null)

  const onScroll = () => {
    if (!currentElement.current) {
      setIsVisible(false)
      return
    }
    const top = currentElement.current.getBoundingClientRect().top
    setIsVisible(top + offset >= 0 && top - offset <= window.innerHeight)
  }

  useEffect(() => {
    document.addEventListener('scroll', onScroll, true)
    return () => document.removeEventListener('scroll', onScroll, true)
  })

  return [isVisible, currentElement]
}

usage example:

 const [beforeCheckoutSubmitShown, beforeCheckoutSubmitRef] = useVisibility<HTMLDivElement>()

 return (
     <div ref={beforeCheckoutSubmitRef} />

Solution 6 - Javascript

Answer based on the post from @Alex Gusev

React hook to check whether the element is visible with a few fixes and based on the rxjs library.

import React, { useEffect, createRef, useState } from 'react';
import { Subject, Subscription } from 'rxjs';
import { debounceTime, throttleTime } from 'rxjs/operators';

/**
 * Check if an element is in viewport
 * @param {number} offset - Number of pixels up to the observable element from the top
 * @param {number} throttleMilliseconds - Throttle observable listener, in ms
 * @param {boolean} triggerOnce - Trigger renderer only once when element become visible
 */
export default function useVisibleOnScreen<Element extends HTMLElement>(
  offset = 0,
  throttleMilliseconds = 1000,
  triggerOnce = false,
  scrollElementId = ''
): [boolean, React.RefObject<Element>] {
  const [isVisible, setIsVisible] = useState(false);
  const currentElement = createRef<Element>();

  useEffect(() => {
    let subscription: Subscription | null = null;
    let onScrollHandler: (() => void) | null = null;
    const scrollElement = scrollElementId
      ? document.getElementById(scrollElementId)
      : window;
    const ref = currentElement.current;
    if (ref && scrollElement) {
      const subject = new Subject();
      subscription = subject
        .pipe(throttleTime(throttleMilliseconds))
        .subscribe(() => {
          if (!ref) {
            if (!triggerOnce) {
              setIsVisible(false);
            }
            return;
          }

          const top = ref.getBoundingClientRect().top;
          const visible =
            top + offset >= 0 && top - offset <= window.innerHeight;
          if (triggerOnce) {
            if (visible) {
              setIsVisible(visible);
            }
          } else {
            setIsVisible(visible);
          }
        });
      onScrollHandler = () => {
        subject.next();
      };
      if (scrollElement) {
        scrollElement.addEventListener('scroll', onScrollHandler, false);
      }
      // Check when just loaded:
      onScrollHandler();
    } else {
      console.log('Ref or scroll element cannot be found.');
    }

    return () => {
      if (onScrollHandler && scrollElement) {
        scrollElement.removeEventListener('scroll', onScrollHandler, false);
      }
      if (subscription) {
        subscription.unsubscribe();
      }
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [offset, throttleMilliseconds, triggerOnce, scrollElementId]);

  return [isVisible, currentElement];
}

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionJohnView Question on Stackoverflow
Solution 1 - JavascriptCreaforgeView Answer on Stackoverflow
Solution 2 - JavascriptAvraam MavridisView Answer on Stackoverflow
Solution 3 - JavascriptAlex GusevView Answer on Stackoverflow
Solution 4 - JavascriptBigButovskyiView Answer on Stackoverflow
Solution 5 - JavascriptEmanuelView Answer on Stackoverflow
Solution 6 - JavascriptIevgenView Answer on Stackoverflow