Search code examples
javascriptcssreactjstypescriptintersection-observer

Intersection Observer in Typescript throws error in useRef


I have here a text animation that is working perfect. What I want to add now is an Intersection Observer so that the animation only starts once I scroll down to the Box.

So what I did to achieve this is: I used the react hook useRef to use as reference to the element I want to observe and applied it to my Box with ref={containerRef}. Then declared a callback function that receives an array of IntersectionObserverEntries as a parameter, inside this function I take the first and only entry and check if it is intersecting with the viewport and if it is then it calls setIsVisible with the value of entry.isIntersecting (true/false). After that I added the react hook useEffect and created an observer contructor using the callback function and the options I just created before. I implemented the logic in a new hook that I called useElementOnscreen

But Typescript is telling me an error at containerRef?.current:

Argument of type 'IntersectionObserver' is not assignable to parameter of type 'Element'.
  Type 'IntersectionObserver' is missing the following properties from type 'Element': attributes, classList, className, clientHeight, and 160 more.

And I am not sure how to solve this error. I think this is also the reason that my ref={containerRef} is throwing an error too

The expected type comes from property 'ref' which is declared here on type 'IntrinsicAttributes & { component: ElementType<any>; } & SystemProps<Theme> & { children?: ReactNode; component?: ElementType<...> | undefined; ref?: Ref<...> | undefined; sx?: SxProps<...> | undefined; } & CommonProps & Omit<...>'

The animation: So, TopAnimateBlock and BottomAnimateBlock have numOfLine property hence how many lines is inside the block. The second property in BottomAnimateBlock is delayTopLine, it should have the same numbers as a numOfLine in TopAnimateBlock, because we need to wait for top lines to play.

TextAnimation.tsx

import { Box, Stack, Typography } from '@mui/material';
import React, { useRef, useEffect, useState } from 'react';
import styled, { keyframes } from 'styled-components';

const showTopText = keyframes`
  0% { transform: translate3d(0, 100% , 0); }
  40%, 60% { transform: translate3d(0, 50%, 0); }
  100% { transform: translate3d(0, 0, 0); }
`;
const showBottomText = keyframes`
  0% { transform: translate3d(0, -100%, 0); }
  100% { transform: translate3d(0, 0, 0); }
`;

const Section = styled.section`
  width: calc(100% + 10vmin);
  display: flex;
  flex-flow: column;
  padding: 2vmin 0;
  overflow: hidden;
  &:last-child {
    border-top: 1vmin solid white;
  }
`;

const Block = styled.div<{ numOfLine: number }>`
  position: relative;
`;
const TopAnimateBlock = styled(Block)`
  animation: ${showTopText} calc(0.5s * ${props => props.numOfLine}) forwards;
  animation-delay: 0.5s;
  transform: translateY(calc(100% * ${props => props.numOfLine}));
`;
const BottomAnimateBlock = styled(Block)<{ delayTopLine: number }>`
  animation: ${showBottomText} calc(0.5s * ${props => props.numOfLine}) forwards;
  animation-delay: calc(0.7s * ${props => props.delayTopLine});
  transform: translateY(calc(-100% * ${props => props.numOfLine}));
`;

const TextStyle = styled.p<{ color: string }>`
  font-family: Roboto, Arial, sans-serif;
  font-size: 12vmin;
  color: ${props => props.color};
`;


const useElementOnScreen = (options) => {
    const containerRef = useRef<IntersectionObserver | null>(null);
    const [isVisible, setIsVisible] = useState(false);

    const callbackFunction = (entries) => {
        const [entry] = entries;
        setIsVisible(entry.isIntersecting);
    };

    useEffect(() => {
        const observer = new IntersectionObserver(callbackFunction, options);

        if (containerRef.current) observer.observe(containerRef?.current);

        return () => {
            if (containerRef.current) observer.unobserve(containerRef?.current);
        };
    }, [containerRef, options]);

    return [containerRef, isVisible];
};

export function Details() {
    const [containerRef, isVisible] = useElementOnScreen({
        root: null,
        rootMargin: '0px',
        threshold: 1.0,
    });

    return (
    <>
    <Typography>Scroll Down</Typography>
     <Box ref={containerRef}>
      <Section>
        <TopAnimateBlock numOfLine={2}>
          <TextStyle color="grey">mimicking</TextStyle>
          <TextStyle color="white">apple's design</TextStyle>
        </TopAnimateBlock>
      </Section>
      <Section>
        <BottomAnimateBlock numOfLine={1} delayTopLine={2}>
          <TextStyle color="white">for the win!</TextStyle>
        </BottomAnimateBlock>
      </Section>
    </Box>
</>
  );
};

Solution

  • I can broadly find 2 issues in the code:

    First is this statement:

     const containerRef = useRef<IntersectionObserver | null>(null);
    

    The implementation of the generic useRef is being done with IntersectionObserver | null. This indicates that the ref container will hold either an instance of IntersectionObserver or null. But instead the ref is being used with a Box element, (for those not versed with material-UI, this will be something similar to a div).

    This statement could be changed to something like:

      const containerRef = useRef<HTMLDivElement | null>(null);
    

    Second, the return type of the hook is not declared and TS auto detects it to be an array by looking at what is being returned ([containerRef, isVisible]). The type inferred by Typescript becomes:

    Typescript return type for the hook

    (boolean | React.MutableRefObject<HTMLDivElement | null>)[]. This means that the return type is an array of elements each can have one of the above 3 mentioned types.

    Since the type is actually a tuple and both the returned array elements are of different types (known to us before hand), the type inferred is incorrect and TS complains.

    Explicitly declaring this while defining the hook would prevent Typescript from complaining.

    const useOnScreen = <T,>(options : T): [MutableRefObject<HTMLDivElement | null>, boolean] => {
      const containerRef = useRef<HTMLDivElement | null>(null);
      const [isVisible, setIsVisible] = useState(false);
    
      const callbackFunction = (entries :IntersectionObserverEntry[]) => {
        const [entry] = entries;
        setIsVisible(entry.isIntersecting);
      };
    
      useEffect(() => {
        const observer = new IntersectionObserver(callbackFunction, options);
    
        if (containerRef.current) observer.observe(containerRef?.current);
    
        return () => {
          if (containerRef.current) observer.unobserve(containerRef?.current);
        };
      }, [containerRef, options]);
    
      return [containerRef, isVisible];
    };
    

    A link explaining the difference between tuple return type and array return type.