Dirck Mulder
Components||4 min read

Build a Flowing Menu in React

Create an animated navigation menu where items reveal a marquee strip from the closest edge on hover.

Most navigation menus are completely static until you click something. They work fine, but they also feel lifeless. A flowing menu turns navigation into a moment worth noticing. Each item reveals a scrolling marquee strip that slides in from whichever edge you entered from.

The final result

What we are building

A full-height navigation menu where each item is a horizontal band. On hover, a marquee strip slides in from the nearest edge (top or bottom), revealing the menu item label and a preview image in an infinite scroll. GSAP drives the animation.

Setting up

bash
npm install gsap
tsx
import React, { useRef, useEffect, useState } from 'react';
import { gsap } from 'gsap';

Building the component

Finding the closest edge

When the cursor enters a menu item, we calculate whether it entered from the top or bottom edge by comparing distances to each edge:

tsx
const findClosestEdge = (
  mouseX: number,
  mouseY: number,
  width: number,
  height: number
): 'top' | 'bottom' => {
  const topEdgeDist = Math.pow(mouseX - width / 2, 2) + Math.pow(mouseY, 2);
  const bottomEdgeDist =
    Math.pow(mouseX - width / 2, 2) + Math.pow(mouseY - height, 2);
  return topEdgeDist < bottomEdgeDist ? 'top' : 'bottom';
};

This uses the squared distance formula without the square root since we only need to compare the two values.

Sliding the marquee in

The marquee div starts off-screen. On hover, both the container and its inner content animate to y: 0%. The inner content starts at the opposite offset to create a parallax layer:

tsx
const handleMouseEnter = (ev: React.MouseEvent<HTMLAnchorElement>) => {
  const rect = itemRef.current.getBoundingClientRect();
  const edge = findClosestEdge(
    ev.clientX - rect.left,
    ev.clientY - rect.top,
    rect.width,
    rect.height
  );
  gsap
    .timeline({ defaults: animationDefaults })
    .set(marqueeRef.current, { y: edge === 'top' ? '-101%' : '101%' }, 0)
    .set(marqueeInnerRef.current, { y: edge === 'top' ? '101%' : '-101%' }, 0)
    .to([marqueeRef.current, marqueeInnerRef.current], { y: '0%' }, 0);
};

The same edge detection runs on mouseleave to animate the marquee out in the correct direction:

tsx
const handleMouseLeave = (ev: React.MouseEvent<HTMLAnchorElement>) => {
  const rect = itemRef.current.getBoundingClientRect();
  const edge = findClosestEdge(ev.clientX - rect.left, ev.clientY - rect.top, rect.width, rect.height);
  gsap
    .timeline({ defaults: animationDefaults })
    .to(marqueeRef.current, { y: edge === 'top' ? '-101%' : '101%' }, 0)
    .to(marqueeInnerRef.current, { y: edge === 'top' ? '101%' : '-101%' }, 0);
};

The infinite marquee scroll

The marquee content repeats enough times to fill the viewport width. We calculate repetitions based on actual content width:

tsx
useEffect(() => {
  const calculateRepetitions = () => {
    const marqueeContent = marqueeInnerRef.current?.querySelector('.marquee-part') as HTMLElement;
    const contentWidth = marqueeContent.offsetWidth;
    const viewportWidth = window.innerWidth;
    const needed = Math.ceil(viewportWidth / contentWidth) + 2;
    setRepetitions(Math.max(4, needed));
  };
  calculateRepetitions();
  window.addEventListener('resize', calculateRepetitions);
  return () => window.removeEventListener('resize', calculateRepetitions);
}, [text, image]);

GSAP then scrolls the inner wrapper by exactly one content width in an infinite loop:

tsx
animationRef.current = gsap.to(marqueeInnerRef.current, {
  x: -contentWidth,
  duration: speed,
  ease: 'none',
  repeat: -1,
});

How to use it

tsx
<FlowingMenu
  items={[
    { text: 'Work', link: '/work', image: '/preview-work.jpg' },
    { text: 'About', link: '/about', image: '/preview-about.jpg' },
    { text: 'Contact', link: '/contact', image: '/preview-contact.jpg' },
  ]}
  speed={15}
  textColor="#fff"
  bgColor="#060010"
  marqueeBgColor="#fff"
  marqueeTextColor="#060010"
/>

| Prop | Default | Description | |------|---------|-------------| | speed | 15 | Seconds for one full marquee loop | | textColor | #fff | Color of the menu item labels | | marqueeBgColor | #fff | Background color of the revealed strip | | marqueeTextColor | #060010 | Text color inside the marquee |

Key takeaways

  • Using 101% instead of 100% for the off-screen position ensures a 1px overlap that prevents a gap appearing during the slide-in on subpixel screens.
  • The animationDefaults object is passed to gsap.timeline() so the duration and ease apply to every tween in the timeline without repeating them.
  • Setting up animationRef.current = gsap.to(...) and calling animationRef.current.kill() on cleanup is the correct pattern for managing GSAP animations in React effects.