Skip to content
jjswnth/ui

Navigation

Side Menu

A left drawer that slides out slowly behind a rounded right edge, with links that step aside on hover and an arrow that floods with colour.

Installation

  1. 1. Install the dependencies.

    terminal
    npm install motion clsx tailwind-merge
  2. 2. Add the cn helper, if you don't already have it.

    lib/utils.ts
    import { clsx, type ClassValue } from "clsx";
    import { twMerge } from "tailwind-merge";
    
    export function cn(...inputs: ClassValue[]) {
      return twMerge(clsx(inputs));
    }
  3. 3. Copy the component into your project.

    components/ui/side-menu.tsx
    "use client";
    
    import { AnimatePresence, motion } from "motion/react";
    import { useEffect, useRef, useState, type ReactNode } from "react";
    import { cn } from "@/lib/utils";
    
    export type MenuLink = {
      label: string;
      href: string;
      /** Small line beside the label — a count, a section, a year. */
      meta?: string;
    };
    
    type SideMenuProps = {
      links: MenuLink[];
      /** Text on the trigger. */
      label?: string;
      /** Heading inside the panel. */
      title?: string;
      /** Fill that floods each arrow on hover. */
      accent?: string;
      /** Panel width in pixels. */
      width?: number;
      footer?: ReactNode;
      className?: string;
    };
    
    /** Slow and heavy on both ends — the panel should feel weighty. */
    const EASE = [0.76, 0, 0.24, 1] as const;
    const SLIDE = 0.7;
    
    export function SideMenu({
      links,
      label = "Menu",
      title = "Navigation",
      accent = "#7c5cff",
      width = 380,
      footer,
      className,
    }: SideMenuProps) {
      const [open, setOpen] = useState(false);
      const triggerRef = useRef<HTMLButtonElement>(null);
      const panelRef = useRef<HTMLDivElement>(null);
    
      useEffect(() => {
        if (!open) return;
    
        const onKeyDown = (event: KeyboardEvent) => {
          if (event.key === "Escape") setOpen(false);
        };
    
        // Hold the page still underneath, and put focus where the reader is.
        const { overflow } = document.body.style;
        document.body.style.overflow = "hidden";
        document.addEventListener("keydown", onKeyDown);
        panelRef.current?.focus();
    
        return () => {
          document.body.style.overflow = overflow;
          document.removeEventListener("keydown", onKeyDown);
        };
      }, [open]);
    
      return (
        <>
          <button
            ref={triggerRef}
            type="button"
            onClick={() => setOpen(true)}
            aria-expanded={open}
            aria-haspopup="dialog"
            className={cn(
              "inline-flex items-center gap-2.5 rounded-full border border-line bg-surface px-4 py-2 text-sm font-medium transition-colors hover:border-accent/50",
              className,
            )}
          >
            <span className="flex flex-col gap-[3px]">
              <span className="block h-[1.5px] w-4 bg-current" />
              <span className="block h-[1.5px] w-4 bg-current" />
            </span>
            {label}
          </button>
    
          <AnimatePresence onExitComplete={() => triggerRef.current?.focus()}>
            {open && (
              <div className="fixed inset-0 z-50">
                <motion.button
                  type="button"
                  aria-label="Close menu"
                  onClick={() => setOpen(false)}
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                  exit={{ opacity: 0 }}
                  transition={{ duration: SLIDE * 0.7, ease: "easeOut" }}
                  className="absolute inset-0 h-full w-full cursor-default bg-black/45 backdrop-blur-[2px]"
                />
    
                <motion.div
                  ref={panelRef}
                  role="dialog"
                  aria-modal="true"
                  aria-label={title}
                  tabIndex={-1}
                  initial={{ x: "-100%" }}
                  animate={{ x: 0 }}
                  exit={{ x: "-100%" }}
                  transition={{ duration: SLIDE, ease: EASE }}
                  style={{ width }}
                  className="absolute inset-y-0 left-0 flex max-w-[86vw] flex-col rounded-r-[30px] bg-surface p-8 outline-none"
                >
                  <div className="flex items-center justify-between">
                    <p className="text-xs tracking-[0.16em] text-faint uppercase">
                      {title}
                    </p>
                    <button
                      type="button"
                      onClick={() => setOpen(false)}
                      aria-label="Close menu"
                      className="grid size-9 place-items-center rounded-full border border-line text-muted transition-colors hover:text-ink"
                    >
                      <svg
                        className="size-4"
                        viewBox="0 0 24 24"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth={2}
                        strokeLinecap="round"
                        aria-hidden="true"
                      >
                        <path d="M18 6 6 18M6 6l12 12" />
                      </svg>
                    </button>
                  </div>
    
                  <nav className="mt-10 flex flex-col">
                    {links.map((link, index) => (
                      <motion.a
                        key={link.href}
                        href={link.href}
                        initial={{ x: -28, opacity: 0 }}
                        animate={{ x: 0, opacity: 1 }}
                        transition={{
                          duration: 0.5,
                          ease: EASE,
                          delay: 0.22 + index * 0.06,
                        }}
                        className="group flex items-center justify-between gap-4 border-b border-line py-4"
                      >
                        <span className="flex items-baseline gap-3 transition-transform duration-300 ease-out group-hover:translate-x-2.5">
                          <span className="text-[26px] leading-tight font-semibold tracking-tight">
                            {link.label}
                          </span>
                          {link.meta ? (
                            <span className="font-mono text-xs text-faint">
                              {link.meta}
                            </span>
                          ) : null}
                        </span>
    
                        {/* The fill runs left to right from a single edge, the same
                            motion as the ink sweep. */}
                        <span className="relative grid size-10 shrink-0 place-items-center overflow-hidden rounded-[10px] border border-line">
                          <span
                            aria-hidden
                            className="absolute inset-0 origin-left scale-x-0 transition-transform duration-500 group-hover:scale-x-100"
                            style={{
                              backgroundColor: accent,
                              transitionTimingFunction: "cubic-bezier(0.76,0,0.24,1)",
                            }}
                          />
                          <svg
                            className="relative size-4 text-muted transition-colors duration-300 group-hover:text-white"
                            viewBox="0 0 24 24"
                            fill="none"
                            stroke="currentColor"
                            strokeWidth={2}
                            strokeLinecap="round"
                            strokeLinejoin="round"
                            aria-hidden="true"
                          >
                            <path d="M7 17 17 7" />
                            <path d="M8 7h9v9" />
                          </svg>
                        </span>
                      </motion.a>
                    ))}
                  </nav>
    
                  {footer ? <div className="mt-auto pt-8">{footer}</div> : null}
                </motion.div>
              </div>
            )}
          </AnimatePresence>
        </>
      );
    }

Props

PropTypeDefaultDescription
linksMenuLink[]Each entry needs a `label` and `href`, plus an optional `meta` line.
labelstring"Menu"Text on the trigger.
titlestring"Navigation"Heading inside the panel, also its accessible name.
accentstring"#7c5cff"Fill that floods each arrow on hover.
widthnumber380Panel width in pixels, capped at 86vw.
footerReactNodePinned to the bottom of the panel.