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.
import { SideMenu, type MenuLink } from "@/components/ui/side-menu";
const links: MenuLink[] = [
{ label: "Work", href: "#work", meta: "24" },
{ label: "Studio", href: "#studio" },
{ label: "Journal", href: "#journal", meta: "08" },
{ label: "Contact", href: "#contact" },
];
export default function SideMenuDemo() {
return (
<SideMenu
links={links}
title="jswnth/ui"
footer={
<p className="text-sm text-muted">
Esc, the backdrop, or the close button all dismiss it.
</p>
}
/>
);
}Installation
1. Install the dependencies.
terminal npm install motion clsx tailwind-merge2. Add the
cnhelper, 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. 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
| Prop | Type | Default | Description |
|---|---|---|---|
| links | MenuLink[] | — | Each entry needs a `label` and `href`, plus an optional `meta` line. |
| label | string | "Menu" | Text on the trigger. |
| title | string | "Navigation" | Heading inside the panel, also its accessible name. |
| accent | string | "#7c5cff" | Fill that floods each arrow on hover. |
| width | number | 380 | Panel width in pixels, capped at 86vw. |
| footer | ReactNode | — | Pinned to the bottom of the panel. |