A Linear-style navigation bar with a hover-driven mega-dropdown that animates content directionally between tabs.
"use client";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { AnimatePresence, motion, Variants } from "motion/react";
import { HTMLAttributes, ReactNode, useState } from "react";
type IconProps = HTMLAttributes<SVGElement>;
const LinearLogo = (props: IconProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="200"
height="200"
fill="currentColor"
stroke="currentColor"
viewBox="0 0 100 100"
{...props}
>
<path d="M1.225 61.523c-.222-.949.908-1.546 1.597-.857l36.512 36.512c.69.69.092 1.82-.857 1.597-18.425-4.323-32.93-18.827-37.252-37.252ZM.002 46.889a.99.99 0 0 0 .29.76L52.35 99.71c.201.2.478.307.76.29 2.37-.149 4.695-.46 6.963-.927.765-.157 1.03-1.096.478-1.648L2.576 39.448c-.552-.551-1.491-.286-1.648.479a50.067 50.067 0 0 0-.926 6.962ZM4.21 29.705a.988.988 0 0 0 .208 1.1l64.776 64.776c.289.29.726.375 1.1.208a49.908 49.908 0 0 0 5.185-2.684.981.981 0 0 0 .183-1.54L8.436 24.336a.981.981 0 0 0-1.541.183 49.896 49.896 0 0 0-2.684 5.185Zm8.448-11.631a.986.986 0 0 1-.045-1.354C21.78 6.46 35.111 0 49.952 0 77.592 0 100 22.407 100 50.048c0 14.84-6.46 28.172-16.72 37.338a.986.986 0 0 1-1.354-.045L12.659 18.074Z" />
</svg>
);
type NullOrNumber = number | null;
type Direction = "left" | "right" | null;
const navItems = [
[
{
title: "Core Features",
className: "flex flex-col gap-2",
items: [
{
title: "Plan",
description: "Set the product direction with project and initiatives",
},
{
title: "Build",
description: "Make progress with issue tracking and cycle planning",
},
],
},
{
title: "More",
className: "grid grid-cols-2 gap-2",
items: [
{
title: "Customer requests",
description: "Manage your feedback",
},
{
title: "Insights",
description: "Realtime analytics",
},
{
title: "Linear ask",
description: "Workspace request",
},
{
title: "Integrations",
description: "Collaborate accross tools",
},
{
title: "Linear app",
description: "Linear in your pocket",
},
{
title: "Changelog",
description: "See what's new",
},
],
},
],
[
{
title: "Company",
className: "flex flex-col gap-2",
items: [
{
title: "About",
description: "Meet the team",
},
{
title: "Careers",
description: "We're hiring",
},
],
},
{
title: "Explore",
className: "grid grid-cols-2 gap-2",
items: [
{
title: "Linear Method",
description: "Practice for building",
},
{
title: "Security",
description: "Safe, Secure and private",
},
{
title: "Docs",
description: "How to use linear",
},
{
title: "Switch to linear",
description: "Migration guide",
},
{
title: "Download",
description: "Get the app",
},
{
title: "Quality",
description: "Conversations on quality",
},
],
},
],
];
const Product = () => {
const item = navItems[0];
return <Item item={item} />;
};
const Resources = () => {
const item = navItems[1];
return <Item item={item} />;
};
interface ItemProps {
item: {
title: string;
className: string;
items: {
title: string;
description: string;
}[];
}[];
}
const Item = ({ item }: ItemProps) => {
return (
<div className="grid grid-cols-3 divide-x size-full gap-y-4 ">
{item.map((i, idx) => (
<div
key={idx}
className={cn("flex flex-col gap-4 p-2", {
"col-span-2": idx === 1,
})}
>
<h1 className="text-sm text-muted-foreground font-light p-2">
{i.title}
</h1>
<ul className={cn(i.className, "gap-2")}>
{i.items.map((item, idx) => (
<li
key={idx}
className={cn(
"flex flex-col p-2 hover:bg-primary/5 transition-all duration-200 border border-transparent hover:border-border rounded-md cursor-pointer",
)}
>
<h2 className="text-sm font-medium">{item.title}</h2>
<p className="text-xs text-muted-foreground ">
{item.description}
</p>
</li>
))}
</ul>
</div>
))}
</div>
);
};
const DropdownNavVariant: Variants = {
hidden: {
scale: 0.8,
opacity: 0,
},
vissible: {
scale: 1,
opacity: 1,
},
};
const DropdownNavVariantElementVariant: Variants = {
initial: (direction: Direction) => ({
opacity: 0,
x: direction === "left" ? 100 : direction === "right" ? -100 : 0,
}),
animate: {
opacity: 1,
x: 0,
},
exit: (direction: Direction) => ({
opacity: 0,
x: direction === "left" ? -100 : direction === "right" ? 100 : 0,
}),
};
const TABS = [
{
title: "Product",
element: Product,
},
{
title: "Resources",
element: Resources,
},
{
title: "Pricing",
element: null,
},
{
title: "Customers",
element: null,
},
].map((t, idx) => ({ ...t, id: idx + 1 }));
const LinearTab = () => {
const [selected, setSelected] = useState<NullOrNumber>(null);
const [direction, setDirection] = useState<Direction>(null);
function handleSelected(value: NullOrNumber) {
if (typeof selected === "number" && typeof value === "number") {
setDirection(selected > value ? "right" : "left");
} else if (value === null) {
setDirection(null);
}
setSelected(value);
}
return (
<div className="size-full rounded-xl bg-background overflow-hidden border">
<header className="h-16 border-b font-medium">
<nav className="flex items-center size-full justify-between px-4">
<div className="flex items-center gap-2">
<LinearLogo className="size-4" />
<span>Linear</span>
</div>
<ul
onMouseLeave={() => handleSelected(null)}
className="flex items-center gap-2 relative"
>
{TABS.map((t, idx) => (
<NavItem
handleSelected={handleSelected}
selected={selected}
item={t.id}
key={idx + t.title}
>
{t.title}
</NavItem>
))}
<AnimatePresence>
{selected && (
<SelectedItemDropDown
direction={direction}
selected={selected}
/>
)}
</AnimatePresence>
</ul>
<div className="flex items-center gap-3">
<button
className={buttonVariants({ size: "sm", variant: "ghost" })}
>
Login
</button>
<button className={buttonVariants({ size: "sm" })}>Sign up</button>
</div>
</nav>
</header>
</div>
);
};
interface NavItemProps {
children: ReactNode;
item: number;
handleSelected: (value: NullOrNumber) => void;
selected: NullOrNumber;
}
const NavItem = ({
children,
item,
handleSelected,
selected,
}: NavItemProps) => {
return (
<li
className={buttonVariants({
size: "sm",
variant: "ghost",
className: cn("cursor-pointer", {
"bg-muted": selected === item,
}),
})}
onMouseEnter={() => handleSelected(item)}
onClick={() => handleSelected(item)}
>
{children}
</li>
);
};
interface SelectedItemDropDownProps {
direction: Direction;
selected: NullOrNumber;
}
const SelectedItemDropDown = ({
selected,
direction,
}: SelectedItemDropDownProps) => {
const tab = TABS.find((t) => t.id === selected!);
if (!tab || tab?.element === null) return null;
return (
<motion.div
variants={DropdownNavVariant}
animate="vissible"
initial="hidden"
exit="hidden"
className="absolute -left-1/2 -right-1/2 mx-auto w-[calc(100%+300px)] border bg-background top-[calc(100%+10px)] rounded-xl p-2"
>
<div className="overflow-hidden w-full bg-muted rounded-lg border">
{tab?.id === selected! && (
<motion.div
key={selected}
custom={direction}
variants={DropdownNavVariantElementVariant}
initial="initial"
animate="animate"
exit="exit"
transition={{
duration: 0.25,
ease: "easeInOut",
}}
>
<tab.element />
</motion.div>
)}
</div>
<div className="absolute -top-[10px] h-2.5 w-full bg-transparent" />
</motion.div>
);
};
export default LinearTab;