"use client";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { ArrowUpRight, ChevronDown, Code2, Menu, X } from "lucide-react";
import { cn } from "@/lib/utils";
gsap.registerPlugin(useGSAP);
export interface FullscreenNavbarLink {
label: string;
href: string;
}
export interface FullscreenNavbarItem {
label: string;
href?: string;
links?: readonly FullscreenNavbarLink[];
}
export interface FullscreenNavbarSpotlight {
title: string;
description: string;
tag?: string;
image: string;
href: string;
}
export interface FullscreenNavbarProps {
brand?: string;
brandHref?: string;
logo?: ReactNode;
items?: readonly FullscreenNavbarItem[];
connectLinks?: readonly FullscreenNavbarLink[];
spotlight?: FullscreenNavbarSpotlight;
actionLabel?: string;
actionHref?: string;
footerText?: string;
contactLabel?: string;
contactHref?: string;
duration?: number;
stiffness?: number;
damping?: number;
stagger?: number;
background?: string;
foreground?: string;
accent?: string;
headerForeground?: string;
hoverDropdowns?: boolean;
contained?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}
export const FULLSCREEN_NAVBAR_ITEMS: readonly FullscreenNavbarItem[] = [
{
label: "Components",
links: [
{ label: "Explore the library", href: "/components" },
{ label: "Animated buttons", href: "/components/animated-button" },
{ label: "Navigation bars", href: "/components/curved-navbar" },
{ label: "Image loaders", href: "/components/grid-image-loader" },
],
},
{
label: "Documentation",
links: [
{ label: "Introduction", href: "/docs/introduction" },
{ label: "Installation", href: "/docs/installation" },
{ label: "Theming", href: "/docs/theming" },
{ label: "CLI & registry", href: "/docs/cli" },
],
},
{
label: "Community",
links: [
{
label: "Dev Club on GitHub",
href: "https://github.com/devclub-nstru/component_library",
},
{
label: "Report an issue",
href: "https://github.com/devclub-nstru/component_library/issues",
},
{
label: "Contribute",
href: "https://github.com/devclub-nstru/component_library/blob/main/CONTRIBUTING.md",
},
],
},
{ label: "Showcase", href: "/components" },
{ label: "Our story", href: "/docs/introduction" },
{
label: "Source code",
href: "https://github.com/devclub-nstru/component_library",
},
];
export const FULLSCREEN_NAVBAR_CONNECT_LINKS: readonly FullscreenNavbarLink[] =
[
{
label: "GitHub",
href: "https://github.com/devclub-nstru/component_library",
},
{ label: "Docs", href: "/docs" },
{ label: "Dev Club", href: "https://devclub.co" },
];
const bounded = (value: number, min: number, max: number, fallback: number) =>
Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
function springEase(stiffness: number, damping: number, duration: number) {
const frequency = Math.sqrt(stiffness);
const ratio = damping / (2 * frequency);
const response = (time: number) => {
if (ratio < 1) {
const rate = frequency * Math.sqrt(1 - ratio * ratio);
return (
1 -
Math.exp((-damping * time) / 2) *
(Math.cos(rate * time) +
(damping / (2 * rate)) * Math.sin(rate * time))
);
}
if (Math.abs(ratio - 1) < 0.001)
return 1 - (1 + frequency * time) * Math.exp(-frequency * time);
const delta = Math.sqrt(ratio * ratio - 1);
const slow = -frequency * (ratio - delta);
const fast = -frequency * (ratio + delta);
return (
1 -
(fast * Math.exp(slow * time) - slow * Math.exp(fast * time)) /
(fast - slow)
);
};
const end = response(duration);
return (progress: number) =>
progress === 1 ? 1 : response(progress * duration) / Math.max(0.001, end);
}
function NavbarHeader({
brand,
brandHref,
logo,
actionLabel,
actionHref,
menuButton,
onNavigate,
}: {
brand: string;
brandHref: string;
logo?: ReactNode;
actionLabel: string;
actionHref: string;
menuButton: ReactNode;
onNavigate?: () => void;
}) {
return (
);
}
const HOVER_CLICK_GRACE_MS = 350;
const menuButtonClass =
"flex min-h-10 min-w-24 cursor-pointer items-center justify-center gap-2 rounded-full border border-current/15 bg-current/5 px-4 text-xs font-medium backdrop-blur-lg hover:bg-current/10 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--fullnav-accent)]";
function NavbarRow({
item,
index,
expanded,
onExpand,
onNavigate,
mobile,
duration,
stiffness,
damping,
hoverDropdowns,
}: {
item: FullscreenNavbarItem;
index: number;
expanded: boolean;
onExpand: () => void;
onNavigate: () => void;
mobile: boolean;
duration: number;
stiffness: number;
damping: number;
hoverDropdowns: boolean;
}) {
const rootRef = useRef(null);
const panelRef = useRef(null);
const contentRef = useRef(null);
const arrowRef = useRef(null);
const animateRef = useRef<((next: boolean) => void) | null>(null);
const expandedRef = useRef(expanded);
const hoverOpenedAtRef = useRef(0);
const id = useId();
const hasLinks = Boolean(item.links?.length);
useEffect(() => {
expandedRef.current = expanded;
}, [expanded]);
useGSAP(
() => {
const panel = panelRef.current;
const content = contentRef.current;
const arrow = arrowRef.current;
if (!panel || !content || !arrow) return;
const media = gsap.matchMedia();
media.add(
{ reduced: "(prefers-reduced-motion: reduce)", all: "all" },
(context) => {
const reduced = Boolean(context.conditions?.reduced);
const animate = context.add("animate", (next: boolean) => {
gsap.killTweensOf([panel, arrow, content]);
if (reduced) {
gsap.set(panel, {
height: next ? "auto" : 0,
autoAlpha: next ? 1 : 0,
});
gsap.set(arrow, { rotation: next ? 180 : 0 });
gsap.set(content, { y: 0, opacity: 1 });
return;
}
gsap.to(panel, {
height: next ? "auto" : 0,
autoAlpha: next ? 1 : 0,
duration: duration * 0.55,
ease: "power3.inOut",
overwrite: "auto",
});
gsap.to(arrow, {
rotation: next ? 180 : 0,
duration: duration * 0.65,
ease: springEase(stiffness, damping, duration * 0.65),
overwrite: "auto",
});
gsap.fromTo(
content,
{ y: next ? 8 : 0, opacity: next ? 0 : 1 },
{
y: 0,
opacity: next ? 1 : 0,
duration: duration * 0.45,
ease: "power3.out",
},
);
});
animateRef.current = (next) => animate(next);
animate(expandedRef.current);
return () => {
animateRef.current = null;
};
},
);
return () => media.revert();
},
{
scope: rootRef,
dependencies: [duration, stiffness, damping, item.links],
revertOnUpdate: true,
},
);
useGSAP(
() => {
animateRef.current?.(expanded);
},
{ scope: rootRef, dependencies: [expanded] },
);
const label = (
<>
{String(index + 1).padStart(2, "0")}
{item.label}
{hasLinks ? (
) : (
)}
>
);
const rowClass = cn(
"group relative z-10 flex w-full items-center rounded-lg py-2.5 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--fullnav-accent)]",
expanded && "text-[var(--fullnav-accent)]",
);
return (
{hasLinks ? (
) : (
{label}
)}
{hasLinks && (
)}
);
}
function FullscreenPanel({
open,
onExit,
onClose,
mobile,
settings,
}: {
open: boolean;
onExit: () => void;
onClose: () => void;
mobile: boolean;
settings: Required<
Pick<
FullscreenNavbarProps,
| "brand"
| "brandHref"
| "items"
| "connectLinks"
| "actionLabel"
| "actionHref"
| "footerText"
| "contactLabel"
| "contactHref"
| "duration"
| "stiffness"
| "damping"
| "stagger"
| "background"
| "foreground"
| "accent"
| "hoverDropdowns"
| "contained"
>
> &
Pick;
}) {
const rootRef = useRef(null);
const sheetRef = useRef(null);
const overlayRef = useRef(null);
const closeRef = useRef(null);
const motionRef = useRef<((next: boolean) => void) | null>(null);
const enteredRef = useRef(false);
const openRef = useRef(open);
const [active, setActive] = useState(null);
const {
brand,
brandHref,
logo,
items,
connectLinks,
spotlight,
actionLabel,
actionHref,
footerText,
contactLabel,
contactHref,
duration,
stiffness,
damping,
stagger,
background,
foreground,
accent,
hoverDropdowns,
contained,
} = settings;
useEffect(() => {
openRef.current = open;
}, [open]);
useGSAP(
() => {
const sheet = sheetRef.current;
const overlay = overlayRef.current;
const root = rootRef.current;
if (!sheet || !overlay || !root) return;
const entries = root.querySelectorAll("[data-fullnav-entry]");
const side = root.querySelector("[data-fullnav-side]");
const media = gsap.matchMedia();
media.add(
{ reduced: "(prefers-reduced-motion: reduce)", all: "all" },
(context) => {
const reduced = Boolean(context.conditions?.reduced);
gsap.set(sheet, {
xPercent: mobile ? 0 : 100,
yPercent: mobile ? -100 : 0,
});
gsap.set(entries, { y: 24, opacity: 0 });
if (side) gsap.set(side, { y: 16, opacity: 0 });
gsap.set(overlay, { opacity: 0 });
const timeline = gsap
.timeline({
paused: true,
onComplete: () => {
enteredRef.current = true;
},
onReverseComplete: onExit,
})
.to(
sheet,
{ xPercent: 0, yPercent: 0, duration, ease: "power3.inOut" },
0,
)
.to(overlay, { opacity: 1, duration: duration * 0.6 }, 0)
.to(
entries,
{
y: 0,
opacity: 1,
duration: duration * 0.6,
ease: springEase(stiffness, damping, duration * 0.6),
stagger,
},
duration * 0.25,
);
if (side)
timeline.to(
side,
{
y: 0,
opacity: 1,
duration: duration * 0.6,
ease: "power3.out",
},
duration * 0.4,
);
const nav = root.querySelector("[data-fullnav-list]");
const highlight = root.querySelector(
"[data-fullnav-highlight]",
);
const hoverDuration = Math.min(0.5, Math.max(0.25, duration * 0.45));
const hoverEase = springEase(stiffness, damping, hoverDuration);
let selected: HTMLElement | null = null;
const quick =
highlight && !reduced
? {
x: gsap.quickTo(highlight, "x", {
duration: hoverDuration,
ease: hoverEase,
}),
y: gsap.quickTo(highlight, "y", {
duration: hoverDuration,
ease: hoverEase,
}),
width: gsap.quickTo(highlight, "width", {
duration: hoverDuration,
ease: "power3.out",
}),
height: gsap.quickTo(highlight, "height", {
duration: hoverDuration,
ease: "power3.out",
}),
}
: null;
const positionHighlight = context.add("positionHighlight", () => {
if (!nav || !highlight || !selected) return;
const bounds = selected.getBoundingClientRect();
const parent = nav.getBoundingClientRect();
const geometry = {
x: bounds.left - parent.left - 8,
y: bounds.top - parent.top - 2,
width: bounds.width + 16,
height: bounds.height + 4,
};
if (quick) {
quick.x(geometry.x);
quick.y(geometry.y);
quick.width(geometry.width);
quick.height(geometry.height);
} else gsap.set(highlight, geometry);
});
const select = context.add("select", (target: HTMLElement | null) => {
if (!highlight || !nav || target === selected) return;
if (selected) {
const oldLabel = selected.querySelector("[data-fullnav-label]");
const oldArrow = selected.querySelector(
"[data-fullnav-arrow]",
);
if (oldLabel)
gsap.to(oldLabel, {
x: 0,
duration: reduced ? 0 : hoverDuration,
ease: hoverEase,
overwrite: "auto",
});
if (oldArrow)
gsap.to(oldArrow, {
x: 0,
opacity: oldArrow.dataset.fullnavArrow === "link" ? 0 : 0.35,
duration: reduced ? 0 : hoverDuration,
ease: "power3.out",
overwrite: "auto",
});
const previous = selected;
gsap.to(previous, {
color:
previous.getAttribute("aria-expanded") === "true"
? accent
: foreground,
duration: reduced ? 0 : 0.2,
overwrite: "auto",
onComplete: () => previous.style.removeProperty("color"),
});
}
const first = selected === null;
selected = target;
if (first && target) {
const bounds = target.getBoundingClientRect();
const parent = nav.getBoundingClientRect();
gsap.set(highlight, {
x: bounds.left - parent.left - 8,
y: bounds.top - parent.top - 2,
width: bounds.width + 16,
height: bounds.height + 4,
});
}
positionHighlight();
gsap.to(highlight, {
opacity: target ? 1 : 0,
duration: reduced ? 0 : 0.2,
overwrite: "auto",
});
if (!target) return;
const label = target.querySelector("[data-fullnav-label]");
const arrow = target.querySelector("[data-fullnav-arrow]");
if (label)
gsap.to(label, {
x: reduced ? 0 : 6,
duration: reduced ? 0 : hoverDuration,
ease: hoverEase,
overwrite: "auto",
});
if (arrow)
gsap.to(arrow, {
x: reduced ? 0 : 3,
opacity: 0.65,
duration: reduced ? 0 : hoverDuration,
ease: hoverEase,
overwrite: "auto",
});
gsap.to(target, {
color: accent,
duration: reduced ? 0 : 0.2,
overwrite: "auto",
});
});
const point = (event: Event) => {
if (
event instanceof PointerEvent &&
(event.pointerType !== "mouse" || mobile)
)
return;
const target =
event.target instanceof Element
? event.target.closest("[data-fullnav-hit]")
: null;
if (target && nav?.contains(target)) select(target);
};
const leave = () => {
if (!nav?.contains(document.activeElement)) select(null);
};
const blur = (event: FocusEvent) => {
if (
!(event.relatedTarget instanceof Node) ||
!nav?.contains(event.relatedTarget)
)
select(null);
};
nav?.addEventListener("pointerover", point);
nav?.addEventListener("focusin", point);
nav?.addEventListener("pointerleave", leave);
nav?.addEventListener("focusout", blur);
const hoverObserver = new ResizeObserver(() => positionHighlight());
if (nav) hoverObserver.observe(nav);
const move = context.add("move", (next: boolean) => {
if (reduced) {
gsap.set(sheet, { xPercent: 0, yPercent: 0 });
gsap.set(entries, { y: 0, opacity: 1 });
if (side) gsap.set(side, { y: 0, opacity: 1 });
gsap.set(overlay, { opacity: 1 });
if (!next) onExit();
} else if (next) timeline.play();
else timeline.timeScale(1.25).reverse();
});
motionRef.current = (next) => move(next);
if (enteredRef.current && openRef.current && !reduced)
timeline.progress(1);
else move(openRef.current);
return () => {
motionRef.current = null;
nav?.removeEventListener("pointerover", point);
nav?.removeEventListener("focusin", point);
nav?.removeEventListener("pointerleave", leave);
nav?.removeEventListener("focusout", blur);
hoverObserver.disconnect();
};
},
);
return () => media.revert();
},
{
scope: rootRef,
dependencies: [
mobile,
duration,
stiffness,
damping,
stagger,
items,
accent,
foreground,
onExit,
],
revertOnUpdate: true,
},
);
useGSAP(
() => {
motionRef.current?.(open);
},
{ scope: rootRef, dependencies: [open] },
);
return (
{
event.preventDefault();
closeRef.current?.focus({ preventScroll: true });
}}
onEscapeKeyDown={(event) => {
event.preventDefault();
onClose();
}}
onPointerDownOutside={(event) => event.preventDefault()}
>
{brand} navigation
Explore components, documentation, and community resources.
Close
}
/>
{footerText}
);
}
export function FullscreenNavbar({
brand = "devclub",
brandHref = "/",
logo,
items = FULLSCREEN_NAVBAR_ITEMS,
connectLinks = FULLSCREEN_NAVBAR_CONNECT_LINKS,
spotlight,
actionLabel = "Start building",
actionHref = "/docs/installation",
footerText = "Built by Dev Club. Made for your next idea.",
contactLabel = "Let's build something together.",
contactHref = "https://github.com/devclub-nstru/component_library/issues",
duration = 0.8,
stiffness = 220,
damping = 26,
stagger = 0.045,
background = "#f7f5ef",
foreground = "#171717",
accent = "#d34b30",
headerForeground = "#ffffff",
hoverDropdowns = true,
contained = false,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
className,
}: FullscreenNavbarProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const open = controlledOpen ?? internalOpen;
const [present, setPresent] = useState(open);
const [host, setHost] = useState(null);
const [mobile, setMobile] = useState(false);
const finishExit = useCallback(() => setPresent(false), []);
if (open && !present) setPresent(true);
const changeOpen = (next: boolean) => {
if (controlledOpen === undefined) setInternalOpen(next);
onOpenChange?.(next);
};
useEffect(() => {
if (!host) return;
const observer = new ResizeObserver(() =>
setMobile(host.clientWidth < 760),
);
observer.observe(host);
return () => observer.disconnect();
}, [host]);
const settings = {
brand,
brandHref,
logo,
items,
connectLinks,
spotlight,
actionLabel,
actionHref,
footerText,
contactLabel,
contactHref,
duration: bounded(duration, 0.25, 1.5, 0.8),
stiffness: bounded(stiffness, 80, 500, 220),
damping: bounded(damping, 12, 50, 26),
stagger: bounded(stagger, 0, 0.12, 0.045),
background,
foreground,
accent,
hoverDropdowns,
contained,
};
return (
}
/>
changeOpen(false)}
mobile={mobile}
settings={settings}
/>
);
}
export default FullscreenNavbar;