Fazekit

Code

"use client";
import * as React from "react";
import {
  AnimatePresence,
  LayoutGroup,
  motion,
  useMotionValueEvent,
  useReducedMotion,
  useScroll,
} from "motion/react";
import {
  ArrowRight,
  BarChart3,
  Blocks,
  ChevronDown,
  Cpu,
  ShieldCheck,
  Workflow,
  type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";

export interface NavbarFloatingLink {
  label: string;
  href?: string;
}

export interface NavbarFloatingProduct {
  title: string;
  description: string;
  icon: LucideIcon;
  href?: string;
}

export interface NavbarFloatingProps {
  brand?: string;
  brandHref?: string;
  /** Top-level links shown after the Products dropdown. */
  links?: NavbarFloatingLink[];
  /** Label of the dropdown trigger. */
  productsLabel?: string;
  products?: NavbarFloatingProduct[];
  cta?: NavbarFloatingLink;
  signIn?: NavbarFloatingLink;
  /** Scroll distance (px) after which the bar shrinks. */
  threshold?: number;
  /** `fixed` (default) floats over the page; `sticky` stays in the flow. */
  position?: "fixed" | "sticky";
  className?: string;
}

const DEFAULT_PRODUCTS: NavbarFloatingProduct[] = [
  { title: "Analytics", description: "Realtime dashboards for every team", icon: BarChart3, href: "#" },
  { title: "Automations", description: "Trigger workflows from any event", icon: Workflow, href: "#" },
  { title: "Integrations", description: "120+ connectors, zero glue code", icon: Blocks, href: "#" },
  { title: "Edge compute", description: "Run logic 20ms from every user", icon: Cpu, href: "#" },
  { title: "Security", description: "SSO, audit logs and SOC 2 reports", icon: ShieldCheck, href: "#" },
];

const DEFAULT_LINKS: NavbarFloatingLink[] = [
  { label: "Customers", href: "#" },
  { label: "Pricing", href: "#" },
  { label: "Changelog", href: "#" },
  { label: "Docs", href: "#" },
];

const SPRING = { type: "spring", stiffness: 380, damping: 32 } as const;

export function NavbarFloating({
  brand = "Orbit",
  brandHref = "#",
  links = DEFAULT_LINKS,
  productsLabel = "Products",
  products = DEFAULT_PRODUCTS,
  cta = { label: "Get started", href: "#" },
  signIn = { label: "Log in", href: "#" },
  threshold = 24,
  position = "fixed",
  className,
}: NavbarFloatingProps) {
  const reduce = useReducedMotion();
  const uid = React.useId();
  const { scrollY } = useScroll();
  const [scrolled, setScrolled] = React.useState(false);
  const [hovered, setHovered] = React.useState<string | null>(null);
  const [active, setActive] = React.useState<string>(links[0]?.label ?? "");
  const [dropdown, setDropdown] = React.useState(false);
  const [mobile, setMobile] = React.useState(false);
  const dropRef = React.useRef<HTMLLIElement>(null);
  const triggerRef = React.useRef<HTMLButtonElement>(null);
  const closeTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);

  useMotionValueEvent(scrollY, "change", (v) => setScrolled(v > threshold));
  React.useEffect(() => {
    // eslint-disable-next-line react-hooks/set-state-in-effect -- sync with the initial scroll offset after mount
    setScrolled(window.scrollY > threshold);
  }, [threshold]);

  // Close dropdown on outside click / Escape.
  React.useEffect(() => {
    if (!dropdown) return;
    const onDown = (e: PointerEvent) => {
      if (!dropRef.current?.contains(e.target as Node)) setDropdown(false);
    };
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        setDropdown(false);
        triggerRef.current?.focus();
      }
    };
    document.addEventListener("pointerdown", onDown);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("pointerdown", onDown);
      document.removeEventListener("keydown", onKey);
    };
  }, [dropdown]);

  // Mobile sheet: Escape closes, body scroll locked.
  React.useEffect(() => {
    if (!mobile) return;
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && setMobile(false);
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    document.addEventListener("keydown", onKey);
    return () => {
      document.body.style.overflow = prev;
      document.removeEventListener("keydown", onKey);
    };
  }, [mobile]);

  const openDrop = () => {
    if (closeTimer.current) clearTimeout(closeTimer.current);
    setDropdown(true);
  };
  const scheduleClose = () => {
    closeTimer.current = setTimeout(() => setDropdown(false), 140);
  };

  const pill = hovered ?? active;
  const panelId = `${uid}-products`;
  const sheetId = `${uid}-sheet`;

  return (
    <header
      className={cn(
        "pointer-events-none inset-x-0 top-0 z-50 flex w-full justify-center px-3 sm:px-4",
        position === "fixed" ? "fixed" : "sticky",
        className,
      )}
    >
      <motion.nav
        aria-label="Main"
        initial={false}
        animate={{ maxWidth: scrolled ? 860 : 1180, marginTop: scrolled ? 12 : 16, height: scrolled ? 54 : 64 }}
        transition={reduce ? { duration: 0 } : SPRING}
        className={cn(
          "pointer-events-auto relative flex w-full items-center gap-2 rounded-full border pl-4 pr-2 transition-[background-color,border-color,box-shadow] duration-300",
          scrolled || mobile
            ? "border-foreground/10 bg-background/70 shadow-lg shadow-black/5 backdrop-blur-xl backdrop-saturate-150 dark:shadow-black/30"
            : "border-transparent bg-transparent",
        )}
      >
        <a href={brandHref} className="flex shrink-0 items-center gap-2 rounded-full pr-2 font-semibold tracking-tight focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
          <BrandMark />
          <span>{brand}</span>
        </a>

        {/* Desktop links */}
        <LayoutGroup id={uid}>
          <ul className="mx-auto hidden items-center md:flex" onMouseLeave={() => setHovered(null)}>
            <li ref={dropRef} className="relative" onMouseEnter={openDrop} onMouseLeave={scheduleClose}>
              <button
                ref={triggerRef}
                type="button"
                aria-expanded={dropdown}
                aria-controls={panelId}
                onClick={() => setDropdown((v) => !v)}
                onMouseEnter={() => setHovered(productsLabel)}
                onFocus={() => setHovered(productsLabel)}
                onBlur={() => setHovered(null)}
                onKeyDown={(e) => {
                  if (e.key === "ArrowDown") {
                    e.preventDefault();
                    setDropdown(true);
                    requestAnimationFrame(() => document.getElementById(panelId)?.querySelector("a")?.focus());
                  }
                }}
                className="relative flex items-center gap-1 rounded-full px-3.5 py-2 text-sm font-medium text-foreground/70 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-expanded:text-foreground"
              >
                {pill === productsLabel && <Pill layoutId="pill" />}
                <span className="relative">{productsLabel}</span>
                <ChevronDown className={cn("relative size-3.5 transition-transform duration-200", dropdown && "rotate-180")} aria-hidden />
              </button>
              <AnimatePresence>
                {dropdown && (
                  <motion.div
                    id={panelId}
                    initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97, filter: "blur(4px)" }}
                    animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
                    exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97, filter: "blur(4px)" }}
                    transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
                    style={{ transformOrigin: "20% 0%" }}
                    className="absolute left-0 top-full pt-3"
                  >
                    <div className="w-[540px] overflow-hidden rounded-2xl border border-foreground/10 bg-popover text-popover-foreground shadow-2xl shadow-black/10">
                      <ul className="grid grid-cols-2 gap-1 p-2">
                        {products.map((p, i) => (
                          <motion.li
                            key={p.title}
                            initial={{ opacity: 0, y: 6 }}
                            animate={{ opacity: 1, y: 0 }}
                            transition={{ delay: 0.03 * i, duration: 0.25 }}
                          >
                            <a
                              href={p.href ?? "#"}
                              onClick={() => setDropdown(false)}
                              className="group flex gap-3 rounded-xl p-3 transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
                            >
                              <span className="grid size-9 shrink-0 place-items-center rounded-lg border bg-background text-foreground/80 transition-colors group-hover:border-primary/30 group-hover:text-primary">
                                <p.icon className="size-4" aria-hidden />
                              </span>
                              <span>
                                <span className="block text-sm font-medium">{p.title}</span>
                                <span className="block text-xs leading-snug text-muted-foreground">{p.description}</span>
                              </span>
                            </a>
                          </motion.li>
                        ))}
                        <li className="flex">
                          <a
                            href="#"
                            className="group flex flex-1 flex-col justify-between rounded-xl bg-linear-to-br from-primary/15 via-primary/5 to-transparent p-3 transition-colors hover:from-primary/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                          >
                            <span className="text-xs font-medium text-primary">New</span>
                            <span className="flex items-center gap-1 text-sm font-medium">
                              See the 2026 roadmap
                              <ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" aria-hidden />
                            </span>
                          </a>
                        </li>
                      </ul>
                    </div>
                  </motion.div>
                )}
              </AnimatePresence>
            </li>
            {links.map((l) => (
              <li key={l.label}>
                <a
                  href={l.href ?? "#"}
                  aria-current={active === l.label ? "page" : undefined}
                  onClick={() => setActive(l.label)}
                  onMouseEnter={() => setHovered(l.label)}
                  onFocus={() => setHovered(l.label)}
                  onBlur={() => setHovered(null)}
                  className={cn(
                    "relative block rounded-full px-3.5 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
                    active === l.label || pill === l.label ? "text-foreground" : "text-foreground/70",
                  )}
                >
                  {pill === l.label && <Pill layoutId="pill" />}
                  <span className="relative">{l.label}</span>
                  {active === l.label && (
                    <motion.span layoutId="dot" transition={SPRING} className="absolute bottom-0.5 left-1/2 size-1 -translate-x-1/2 rounded-full bg-primary" />
                  )}
                </a>
              </li>
            ))}
          </ul>
        </LayoutGroup>

        <div className="ml-auto hidden items-center gap-1 md:flex">
          <a href={signIn.href ?? "#"} className="rounded-full px-3.5 py-2 text-sm font-medium text-foreground/70 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
            {signIn.label}
          </a>
          <a
            href={cta.href ?? "#"}
            className="group inline-flex h-10 items-center gap-1.5 rounded-full bg-foreground px-4 text-sm font-semibold text-background transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
          >
            {cta.label}
            <ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" aria-hidden />
          </a>
        </div>

        {/* Mobile toggle */}
        <button
          type="button"
          aria-label={mobile ? "Close menu" : "Open menu"}
          aria-expanded={mobile}
          aria-controls={sheetId}
          onClick={() => setMobile((v) => !v)}
          className="ml-auto grid size-10 place-items-center rounded-full transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:hidden"
        >
          <span className="relative block h-3 w-4" aria-hidden>
            <motion.span
              className="absolute left-0 top-0 h-0.5 w-4 rounded-full bg-foreground"
              animate={mobile ? { top: 5, rotate: 45 } : { top: 0, rotate: 0 }}
              transition={{ duration: 0.25 }}
            />
            <motion.span
              className="absolute left-0 top-2.5 h-0.5 w-4 rounded-full bg-foreground"
              animate={mobile ? { top: 5, rotate: -45 } : { top: 10, rotate: 0 }}
              transition={{ duration: 0.25 }}
            />
          </span>
        </button>
      </motion.nav>

      {/* Mobile sheet */}
      <AnimatePresence>
        {mobile && (
          <>
            <motion.div
              key="backdrop"
              aria-hidden
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              onClick={() => setMobile(false)}
              className="pointer-events-auto fixed inset-0 -z-10 bg-background/60 backdrop-blur-sm md:hidden"
            />
            <motion.div
              key="sheet"
              id={sheetId}
              initial={reduce ? { opacity: 0 } : { opacity: 0, y: -16, scale: 0.98 }}
              animate={{ opacity: 1, y: 0, scale: 1 }}
              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -12, scale: 0.98 }}
              transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
              style={{ transformOrigin: "50% 0%" }}
              className="pointer-events-auto fixed inset-x-3 top-[84px] max-h-[calc(100dvh-100px)] overflow-y-auto rounded-3xl border border-foreground/10 bg-popover p-3 text-popover-foreground shadow-2xl md:hidden"
            >
              <p className="px-3 pb-1 pt-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">{productsLabel}</p>
              <ul className="grid gap-0.5">
                {products.map((p, i) => (
                  <motion.li key={p.title} initial={{ opacity: 0, x: -8 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.04 * i + 0.05 }}>
                    <a href={p.href ?? "#"} onClick={() => setMobile(false)} className="flex items-center gap-3 rounded-xl px-3 py-2.5 transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none">
                      <span className="grid size-8 place-items-center rounded-lg border bg-background">
                        <p.icon className="size-4" aria-hidden />
                      </span>
                      <span className="text-sm font-medium">{p.title}</span>
                    </a>
                  </motion.li>
                ))}
              </ul>
              <div className="my-2 h-px bg-border" />
              <ul className="grid gap-0.5">
                {links.map((l, i) => (
                  <motion.li key={l.label} initial={{ opacity: 0, x: -8 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.04 * (i + products.length) + 0.05 }}>
                    <a
                      href={l.href ?? "#"}
                      onClick={() => (setActive(l.label), setMobile(false))}
                      className="flex items-center justify-between rounded-xl px-3 py-3 text-base font-medium transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
                    >
                      {l.label}
                      {active === l.label && <span className="size-1.5 rounded-full bg-primary" />}
                    </a>
                  </motion.li>
                ))}
              </ul>
              <div className="mt-3 grid grid-cols-2 gap-2">
                <a href={signIn.href ?? "#"} className="inline-flex h-11 items-center justify-center rounded-full border text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
                  {signIn.label}
                </a>
                <a href={cta.href ?? "#"} className="inline-flex h-11 items-center justify-center rounded-full bg-foreground text-sm font-semibold text-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
                  {cta.label}
                </a>
              </div>
            </motion.div>
          </>
        )}
      </AnimatePresence>
    </header>
  );
}

function Pill({ layoutId }: { layoutId: string }) {
  return <motion.span layoutId={layoutId} transition={SPRING} className="absolute inset-0 rounded-full bg-foreground/[0.06] dark:bg-foreground/10" />;
}

function BrandMark() {
  return (
    <svg viewBox="0 0 24 24" className="size-7" aria-hidden>
      <defs>
        <linearGradient id="nf-brand" x1="0" y1="0" x2="1" y2="1">
          <stop offset="0%" stopColor="#8b5cf6" />
          <stop offset="100%" stopColor="#06b6d4" />
        </linearGradient>
      </defs>
      <rect width="24" height="24" rx="7" fill="url(#nf-brand)" />
      <ellipse cx="12" cy="12" rx="7.5" ry="3.2" fill="none" stroke="#fff" strokeWidth="1.6" transform="rotate(-28 12 12)" />
      <circle cx="12" cy="12" r="2.6" fill="#fff" />
    </svg>
  );
}

More in Navbars

View all →