Navigation
SideFlow
A side panel slides in and its links flow in one after another. Esc, the backdrop or a link closes it; Tab stays inside while it is open.
Download
- React
- Tailwind CSS v4
- motion
components/ui/side-flow.tsx
"use client";
import { useEffect, useId, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
export type SideFlowItem = { label: string; href: string; note?: string };
type SideFlowProps = {
items: SideFlowItem[];
/** href of the active link at start. Defaults to the first item. */
active?: string;
/** Called on click. Call e.preventDefault() to stop the browser following the link. */
onSelect?: (item: SideFlowItem, e: MouseEvent<HTMLAnchorElement>) => void;
side?: "left" | "right";
/** Keep the panel inside the nearest `relative` parent instead of the whole screen. */
contained?: boolean;
/** Heading at the top of the panel. */
title?: ReactNode;
/** Anything at the bottom of the panel, e.g. contact links. */
footer?: ReactNode;
/** Text on the open button. */
trigger?: ReactNode;
triggerClassName?: string;
/** Colour of the active marker and numbers. */
accent?: string;
/** Name read out by screen readers. */
label?: string;
/** Panel look. */
className?: string;
};
/* SideFlow: a side panel slides in and its links flow in one after another. Esc, the backdrop or a link closes it. */
export function SideFlow({
items,
active,
onSelect,
side = "left",
contained = false,
title = "Menu",
footer,
trigger = "Menu",
triggerClassName = "inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-4 py-2 text-sm text-white outline-none hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white/70",
accent = "#a78bfa",
label = "Site",
className = "border-white/10 bg-[#120f22] text-white",
}: SideFlowProps) {
const reduce = useReducedMotion();
const uid = useId();
const button = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [current, setCurrent] = useState(active ?? items[0]?.href);
const pos = contained ? "absolute" : "fixed";
const from = side === "left" ? "-100%" : "100%";
useEffect(() => {
if (!open) return;
panel.current?.querySelector<HTMLElement>("a[href]")?.focus();
if (contained) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open, contained]);
const close = () => {
setOpen(false);
button.current?.focus();
};
// Keep Tab inside the panel while it is open.
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") return close();
if (e.key !== "Tab") return;
const f = panel.current?.querySelectorAll<HTMLElement>("a[href], button");
if (!f?.length) return;
const first = f[0];
const last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
return (
<>
<button
ref={button}
type="button"
aria-expanded={open}
aria-controls={`${uid}-panel`}
onClick={() => setOpen(true)}
className={triggerClassName}
>
<span aria-hidden className="grid gap-1">
<span className="block h-px w-4 bg-current" />
<span className="block h-px w-4 bg-current" />
<span className="block h-px w-2.5 bg-current" />
</span>
{trigger}
</button>
<AnimatePresence>
{open && (
<>
<motion.div
key="backdrop"
aria-hidden
onClick={close}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.25 }}
className={`${pos} inset-0 z-40 bg-black/55`}
/>
<motion.div
key="panel"
ref={panel}
id={`${uid}-panel`}
role="dialog"
aria-modal="true"
aria-label={label}
onKeyDown={onKeyDown}
initial={{ x: from }}
animate={{ x: 0 }}
exit={{ x: from }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 320, damping: 34 }}
className={`${pos} inset-y-0 ${side === "left" ? "left-0 border-r" : "right-0 border-l"} z-50 flex w-[min(320px,85%)] flex-col overflow-y-auto p-6 ${className}`}
>
<div className="flex items-center justify-between">
<p className="font-mono text-[11px] uppercase tracking-[0.2em] opacity-60">{title}</p>
<button
type="button"
onClick={close}
aria-label="Close menu"
className="relative grid size-9 place-items-center rounded-full outline-none hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white/70"
>
<span aria-hidden className="absolute h-px w-4 rotate-45 bg-current" />
<span aria-hidden className="absolute h-px w-4 -rotate-45 bg-current" />
</button>
</div>
<nav aria-label={label} className="mt-6">
<ul className="grid gap-1">
{items.map((item, i) => {
const on = item.href === current;
return (
<motion.li
key={item.href}
initial={{ opacity: 0, x: side === "left" ? -24 : 24 }}
animate={{ opacity: 1, x: 0 }}
transition={reduce ? { duration: 0 } : { delay: 0.1 + i * 0.05, duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
>
<a
href={item.href}
aria-current={on ? "page" : undefined}
onClick={(e) => {
setCurrent(item.href);
onSelect?.(item, e);
close();
}}
className="group relative flex items-baseline gap-3 rounded-xl px-3 py-2 outline-none hover:bg-white/5 focus-visible:ring-2 focus-visible:ring-white/70"
>
{on && (
<span
aria-hidden
className="absolute top-2 bottom-2 left-0 w-0.5 rounded-full"
style={{ background: accent }}
/>
)}
<span className="font-mono text-[11px]" style={{ color: accent }}>
{String(i + 1).padStart(2, "0")}
</span>
<span className="min-w-0">
<span className="block text-xl font-medium transition-transform duration-200 group-hover:translate-x-1">
{item.label}
</span>
{item.note && <span className="block text-xs opacity-55">{item.note}</span>}
</span>
</a>
</motion.li>
);
})}
</ul>
</nav>
{footer && <div className="mt-auto pt-6 text-sm opacity-70">{footer}</div>}
</motion.div>
</>
)}
</AnimatePresence>
</>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/side-flow.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/side-flow-demo.tsx
"use client"; import { SideFlow } from "@/components/ui/side-flow"; const ITEMS = [ { label: "Work", href: "#work", note: "Selected projects" }, { label: "Services", href: "#services" }, { label: "About", href: "#about", note: "The people behind it" }, { label: "Contact", href: "#contact" }, ]; export default function SideFlowExample() { // `contained` keeps the panel inside this box. On a real page, leave it out. return ( <div className="relative grid h-full place-items-center overflow-hidden bg-[#0b0a14] p-6"> <SideFlow contained items={ITEMS} title="Navigate" footer={<a href="mailto:hello@example.com">hello@example.com</a>} onSelect={(_, e) => e.preventDefault()} /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | { label, href, note? }[] | โ | The links. note = small line under the label. |
| active | string | first href | href of the active link at start. |
| onSelect | (item, e) => void | โ | Click handler. Call e.preventDefault() to stay on the page. |
| side | "left" | "right" | "left" | Which edge the panel comes from. |
| contained | boolean | false | Keep the panel inside the nearest relative parent, not the whole screen. |
| title | ReactNode | "Menu" | Heading at the top of the panel. |
| footer | ReactNode | โ | Anything at the bottom of the panel. |
| trigger | ReactNode | "Menu" | Text on the open button. |
| triggerClassName | string | glass pill | Open button look. |
| accent | string | "#a78bfa" | Active marker and number colour. |
| label | string | "Site" | Name read out by screen readers. |
| className | string | dark panel | Panel look. |