Experiments

About

Back

The Responsive-est Reponsive Menu

July 2023

  • CSS

  • Javascript

  • Responsive

Imagine for a second that we're building a navigation bar. On larger screen widths, we want to display the navigation items directly on the UI, perhaps in left and right sections. On smaller screen widths though, this wont work. We'll need to use a more responsive pattern - perhaps a hamburger or dropdown menu of some sort. Now we could use CSS Media queries for this. Or use Javascript to measure the window width and swap out components that way. However, these solutions would ultimately just evaluate the current screen width against a static width value (breakpoint) that we define. In the majority of cases that's fine. We don' need any more. It has flaws in some cases though - what if we were to render a varying number of navigation items based on the user's role? Or perhaps the items are subject to change in the future? Then we could end up with a bit of a problem, our static breakpoint values possibly don't really work anymore, or maybe wont work in the future..

A solution? Lets create a responsive navigation component that's a bit more dynamic. We want to switch to the responsive component only when & where we need to. The trick is to use an invisible div to measure the amount of available space. We set the div to expand and fill the available space in the container and query it's width. When the width gets to 0, update some state that changes the component patterns being used. Simple. This way the component it will be flexible to the width of it' child elements - if we add or remove stuff, it'll still work.

In the (very simple) example below, imagine the left and right sections of a navigation bar. On larger screen widths we display the nav items directly on the UI. On smaller screen widths, once the space inbetween the left and right sections runs out we hide them behind a menu.

Have a go yourself, narrow the width of the browser window and see what happens. *Note: If you're viewing on a mobile device, it's probably already in the responsive variant!

Narrow the browser window and see the component change:

Item 1

Item 2

Item 3

Item 4

Invisible div

Item 5

Item 6

Here's the component code (it could be optimised a bit!):

import { Typography } from "@/app/components/Typography";
import { CaretUp, GearSix, IconWeight, List } from "@phosphor-icons/react";

import {
  useCallback,
  useLayoutEffect,
  useEffect,
  useRef,
  useState,
} from "react";

const wndw = typeof window !== "undefined";

const ResponsiveComp = ({ children }: { children: JSX.Element }) => {
  return (
    <div className="w-30 bg-slate-200  border border-slate-300 p-2 flex items-center">
      {children}
    </div>
  );
};

const initResponsiveMode = () => {
  if (wndw) {
    return Boolean(localStorage.getItem("responsiveMode"));
  }
  return false;
};

export default function ResponsiveMenu() {
  const invisibleDivRef = useRef<HTMLDivElement>(null);
  const [isResponsiveMode, setIsResponsiveMode] = useState<boolean>(
    initResponsiveMode()
  );
  const snapshotWidthRef = useRef<number>(0);

  const handleSetWidth = useCallback(() => {
    if (invisibleDivRef.current) {
      const width = invisibleDivRef.current.offsetWidth;
      if (width <= 0) {
        setIsResponsiveMode(true);
        localStorage.responsiveMode = true;
        snapshotWidthRef.current = document.body.clientWidth; // Take a snapshot of the current width
      } else if (
        document.body.clientWidth > snapshotWidthRef.current &&
        isResponsiveMode
      ) {
        setIsResponsiveMode(false);
        localStorage.responsiveMode = false;
      }
    }
  }, [isResponsiveMode]);

  useLayoutEffect(() => {
    handleSetWidth();
  }, [handleSetWidth]);

  useEffect(() => {
    wndw && window.addEventListener("resize", handleSetWidth);

    return () => {
      wndw && window.removeEventListener("resize", handleSetWidth);
    };
  }, [handleSetWidth]);

  const iconProps = {
    weight: "bold" as IconWeight,
    size: 20,
    className: "text-slate-600",
  };

  return (
    <div className="grid grid-cols-[auto_1fr_auto] items-center gap-2">
      <div className="w-full bg-slate-100 border border-slate-200 p-2 gap-4 grid grid-cols-[auto_1fr_auto] col-span-3">
        {isResponsiveMode ? (
          <ResponsiveComp>
            <List {...iconProps} />
          </ResponsiveComp>
        ) : (
          <div className="bg-slate-200 p-4 flex items-center gap-6 min-w-max">
            <Typography color="secondary">Item 1</Typography>
            <Typography color="secondary">Item 2</Typography>
            <Typography color="secondary">Item 3</Typography>
            <Typography color="secondary">Item 4</Typography>
          </div>
        )}
        <div
          ref={invisibleDivRef}
          className="flex-1 bg-yellow-300/10 flex items-center justify-center relative"
        >
          {!isResponsiveMode && (
            <div className="flex flex-col items-center translate-y-14 absolute w-max">
              <CaretUp weight="bold" size={16} className="text-slate-600" />
              <Typography variant="caption" color="secondary">
                Invisible div
              </Typography>
            </div>
          )}
        </div>
        {isResponsiveMode ? (
          <ResponsiveComp>
            <GearSix {...iconProps} />
          </ResponsiveComp>
        ) : (
          <div className="bg-slate-200 p-4 flex items-center gap-6 min-w-max">
            <Typography color="secondary">Item 5</Typography>
            <Typography color="secondary">Item 6</Typography>
          </div>
        )}
      </div>
    </div>
  );
}