Positioning issues while animating CSS & anime.js

126 views Asked by At

I am currently trying to design an animation for a frontend. I am using react, anime.js & css. The initial positonings are absolute. When a certain point is passed, the elements become fixed and should then be animated. However, I can't get the elements to animate so that they end up centered in a fixed header. Somehow I don't get on here for a long time and am already so stiff in my head that a few opinions and tips would be very helpful. Many thanks already!

The code for the calculation is in the useEffect in which animation.current is set. The initial styles were set with tailwind.

Initial position of the page Init pos

Result position after scrolling down Final pos

"use client"

import anime from "animejs";
import {
  useEffect,
  useRef,
  useState,
  useCallback,
  useLayoutEffect,
} from "react";
import { Artist } from "@/types/spotify";
import Image from "next/image";
import { convertRemToPixels } from "@common/layout";

export const useWindowSize = () => {
  const [size, setSize] = useState([0, 0]);

  useLayoutEffect(() => {
    const updateSize = () => {
      setSize([document.documentElement.clientWidth, document.documentElement.clientHeight]);
    };

    window.addEventListener("resize", updateSize);
    updateSize();
    return () => window.removeEventListener("resize", updateSize);
  }, []);

  return size;
}

export const useSticky = (ref: HTMLElement | null, offsetTop: string) => {
  const [isSticky, setIsSticky] = useState(false);

  const scrollCB = useCallback(() => {
    const stickyElement = ref;

    if (!stickyElement) {
      return;
    }

    if (stickyElement) {
      let offset = parseFloat(offsetTop.replace("px", ""));

      if (offsetTop.endsWith('rem')) {
        offset = convertRemToPixels(parseFloat(offsetTop.replace("rem", "")));
      }

      if (window.scrollY >= offset) {
        setIsSticky(true);
      } else {
        setIsSticky(false);
      }
    }
  }, [offsetTop, ref])

  useEffect(() => {
    window.addEventListener('scroll', scrollCB);
    return () => window.removeEventListener('scroll', scrollCB);
  }, [scrollCB]);

  return isSticky
}

export const getAbsoluteValueForClientWidthPercentage = (
  widht: number,
  pct: number
): number => {
  return widht * pct;
};

export const getAbsoluteValueForClientHeightPercentage = (
  height: number,
  pct: number
): number => {
  return height * pct;
};

export const getOffsetTop = (el: HTMLElement | null): number => {
  if (!el) {
    return 0;
  }
  const scrollTop = window.scrollY ?? document.documentElement.scrollTop;
  return el.getBoundingClientRect().top + scrollTop;
};

export const scrollPercent = (
  target: HTMLElement | null,
  duration: number
): number => {
  if (!target) return 0;

  const fullOffset = getOffsetTop(target); // Already scrolled + top of target
  const offsetEnd = fullOffset + duration;
  const relativeOffset = target.getBoundingClientRect().top;
  const h = target.getBoundingClientRect().height;

  const bodyST = document.body.scrollTop;
  const docST = window.scrollY ?? document.documentElement.scrollTop;

  // Scroll value is negative
  // Offset possibly not yet over-scrolled
  if (docST + bodyST - fullOffset < 0) return 0;

  if (docST + bodyST - fullOffset > offsetEnd) return 100;

  const pct = ((docST + bodyST - fullOffset) / (offsetEnd - ((docST + bodyST - relativeOffset)) + h)) * 100;

  return Math.min(Math.abs(pct), 100);
};

export type Props = {
  artistName: string;
  opener: string;
  artist: Artist
}

export const Header = ({ artistName, opener, artist }: Props) => {
  const animationTriggerElement = useRef<HTMLDivElement>(null);
  const headingElement = useRef<HTMLHeadingElement>(null);
  const imageElement = useRef<HTMLDivElement>(null);

  const isInitiated = useRef(false);

  const [headerElement, setHeaderElement] = useState<HTMLDivElement | null>(null);
  const [headerInitialRect, setHeaderInitialRect] = useState<DOMRect | null>(null);
  const [headingInitialRect, setHeadingInitialRect] = useState<DOMRect | null>(null);
  const [imgInitialRect, setImgInitialRect] = useState<DOMRect | null>(null);

  const isHeadingSticky = useSticky(headingElement.current, "1.5rem")
  const isImgSticky = useSticky(imageElement.current, "1.5rem")

  const [scrollValue, setScrollValue] = useState(0);

  const [textH1Offset, setTextH1Offset] = useState("10%");
  const [imgOffset, setImgOffset] = useState("30%");

  const animation = useRef<anime.AnimeTimelineInstance>();
  const wiggleAnimation = useRef<anime.AnimeInstance>();

  const [ww, wh] = useWindowSize();

  const [pixelDuration, setPixelDuration] = useState(1000);

  const headerElementRef = useCallback((node: any) => {
    if (node !== null)
      setHeaderElement(node);
  }, []);

  useEffect(() => {
    setPixelDuration(wh * 0.95);
  }, [wh])

  const animationCallback = useCallback(() => {
    if (!animationTriggerElement.current) return;

    const scrollProgress = scrollPercent(animationTriggerElement.current, pixelDuration) / 100;

    if (animation.current !== undefined)
      animation.current.seek(scrollProgress * animation.current.duration);

    if (headingElement.current === null || imageElement.current === null) return;

    if (scrollProgress > 0 && scrollProgress < 1) {
      setTextH1Offset("calc(10% - 1.5rem)");
      setImgOffset("calc(30% - 1.5rem)")
    } else if (scrollProgress <= 0) {
      setTextH1Offset("calc(10%)");
      setImgOffset("calc(30%)")
    } else if (scrollProgress >= 1) {
      // TODO some header opts
    }
  }, [pixelDuration]);

  useEffect(() => {
    const val = window.scrollY;

    if (val > 0) {
      setScrollValue(val);
      window.scrollTo({ top: 0 });
    }
  }, []);

  useEffect(() => {
    if (scrollValue > 0) {
      window.scrollTo({ top: scrollValue, behavior: "smooth" });
    }
  }, [scrollValue]);


  // Set base heights for later use
  useEffect(() => {
    if (isInitiated.current) return;

    if (headerElement === null) return;

    if (imageElement.current === null || headingElement.current === null)
      throw new Error("Null error");

    setHeaderInitialRect(headerElement.getBoundingClientRect());
    setHeadingInitialRect(headingElement.current.getBoundingClientRect());
    setImgInitialRect(imageElement.current.getBoundingClientRect());

    isInitiated.current = true;

    console.log("T " + headingElement.current.getBoundingClientRect().top + " " + imageElement.current.getBoundingClientRect().top)
    console.log("H " + headingElement.current.getBoundingClientRect().height + " " + imageElement.current.getBoundingClientRect().height)
  }, [headerElement]);

  useEffect(() => {
    if (headerInitialRect === null || headingInitialRect === null || imgInitialRect === null) return;

    animation.current = anime
      .timeline({
        autoplay: false,
        duration: 100,
        easing: "easeInOutCubic",
      })
      .add(
        {
          targets: headingElement.current,
          translateY: [
            "0",
            `calc(-${headingInitialRect.height * 0.5}px - ${getAbsoluteValueForClientHeightPercentage(wh, 0.05)}px + ${headerInitialRect.height / 2 - (headingInitialRect.height / 2) * 0.5}px + 1.5rem)`,
          ],
          translateX: [
            "-50%",
            `calc(0% - ${getAbsoluteValueForClientWidthPercentage(ww, 0.5)}px - ${headingInitialRect.height * 0.5}px)`,
          ],
          scale: [1, 0.5],
          easing: "easeInOutCubic",
          autoplay: false,
          duration: 600,
        },
        0
      )
      .add(
        {
          targets: imageElement.current,
          translateY: [
            "0",
            // TODO: Fix values here they are currently kind of random
            `calc(-${imgInitialRect.height * 0.2}px - ${getAbsoluteValueForClientHeightPercentage(wh, 0.3)}px - ${headerInitialRect.height / 2 - (imgInitialRect.height / 2) * 0.2}px + 1.5rem)`,
          ],
          translateX: [
            "50%",
            `calc(0% + ${imgInitialRect.height * 0.2}px + ${getAbsoluteValueForClientWidthPercentage(ww, 0.5)}px)`,
          ],
          scale: [1, 0.2],
          easing: "easeInOutCubic",
          autoplay: false,
          duration: 600,
        },
        50
      );
  }, [ww, wh, headerInitialRect, headingInitialRect, imgInitialRect]);

  useEffect(() => {
    wiggleAnimation.current = anime({
      targets: imageElement.current?.firstChild,
      keyframes: [
        { translateX: 20 },
        { translateX: -20 },
        { translateX: 20 },
        { translateX: -20 },
        { translateX: 20 },
        { translateX: -20 },
        { translateX: 0 },
      ],
      duration: 500,
      easing: "easeInOutCubic",
      autoplay: false
    });
  }, [])

  useEffect(() => {
    document.addEventListener("scroll", animationCallback);
    setTimeout(() => animationCallback(), 1);

    return () => document.removeEventListener("scroll", animationCallback);
  }, [animationCallback]);

  useEffect(() => {
    window.addEventListener("resize", animationCallback);

    return () => window.removeEventListener("resize", animationCallback);
  }, [animationCallback]);

  const onSticky = useCallback((isSticky: boolean) => {
    if (headerElement === null) {
      return;
    }

    const headerEl = headerElement.querySelector("#sticky-main-header-wrapper")

    if (headerEl === null) {
      console.log("headerEl is null")
      return;
    }


    if (isSticky) {
      headerEl.classList.remove("rounded-t-2xl");
    } else {
      headerEl.classList.add("rounded-t-2xl");
    }
  }, [headerElement]);

  const onEnterAvatarWiggle = () => {
    if (imageElement.current === null) return;

    if (wiggleAnimation.current !== undefined)
      wiggleAnimation.current.play();
  }

  useEffect(() => {
    if (!headingElement.current) return;

    if (isHeadingSticky) {
      headingElement.current.classList.add("fixed");
      headingElement.current.classList.remove("absolute");
      headingElement.current.style.top = "5%";
    } else {
      headingElement.current.classList.add("absolute");
      headingElement.current.classList.remove("fixed");
      headingElement.current.style.top = "";
    }
  }, [isHeadingSticky])

  useEffect(() => {
    if (!imageElement.current) return;

    if (isImgSticky) {
      imageElement.current.classList.add("fixed");
      imageElement.current.classList.remove("absolute");
      imageElement.current.style.top = "25%";
    } else {
      imageElement.current.classList.add("absolute");
      imageElement.current.classList.remove("fixed");
      imageElement.current.style.top = "";
    }
  }, [isImgSticky])

  return (
    <section className="bg-gradient-main min-h-[90vh] w-full rounded-2xl">
      <div
        className="targetPoint h-1 width-full bg-transparent absolute top-[calc(5%_-_1.5rem)] left-0 right-0 z-modal"
        ref={animationTriggerElement}
      />
      <StickyElement ref={headerElementRef} stickyCB={onSticky}>
        <div className="h-20 z-10 w-full transition-border-radius rounded-t-2xl rounded-b-2xl bg-gradient-main" id="sticky-main-header-wrapper" />
      </StickyElement>
      <h1 className="text-5xl xxs:text-6xl xs:text-7xl sm:text-8xl font-bold text-center text tracking-widest absolute left-[50%] w-fit z-[21] -translate-x-[50%] top-[calc(10%)] w-[calc(100%_-_3rem)" id="artist-main-heading" ref={headingElement}>{artistName}</h1>
      <div ref={imageElement} className="absolute top-[calc(30%)] right-[50%] translate-x-[50%] z-20 h-56 w-56 xs:h-80 xs:w-80">
        <a href={artist.external_urls.spotify} target="_blank" referrerPolicy="no-referrer">
          <Image id="artist-main-image" src={artist.images[0].url} alt={artist.name} fill className="rounded-[50%] object-cover border-4 border-blue-300 shadow-material-4" onPointerEnter={onEnterAvatarWiggle} />
        </a>
      </div>
      <h2 className="text-xl xs:text-2xl absolute top-[80%] left-[50%] -translate-x-[50%] italic text-center tracking-wide w-[calc(100%_-_3rem)]">{opener}</h2>
    </section>
  );
};
0

There are 0 answers