# Navbar with Centered Links, CTA Button and Mobile Menu

> Navbar over a photo hero: logo left, links in the true centre, a booking button and a glass mobile menu with a photo per page. Free React and HTML to copy.

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/navbar-with-centered-links-and-mobile-menu).

- Page, with a live preview: https://shadcn.exchange/r/weinc/navbar-with-centered-links-and-mobile-menu
- Screenshot: https://shadcn.exchange/shot/weinc/navbar-with-centered-links-and-mobile-menu/light.png
- Registry item: https://we.inc/r/navbar-with-centered-links-and-mobile-menu.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/navbar-with-centered-links-and-mobile-menu.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `framer-motion`

## Files

### `components/we-inc/navbar-with-centered-links-and-mobile-menu.tsx`

```tsx
import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";

/** One entry per page link. Each link brings its own photograph, headline and facts to the hero. */
const links = [
  {
    label: "Day trips",
    img: "https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=1600&q=80",
    thumb: "https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=200&q=80",
    alt: "A wooden rowing boat crossing a turquoise mountain lake below pale limestone cliffs",
    headline: ["Row out before", "the wind does."],
    copy: "Larch rowing boats by the hour or the day, from the old jetty on the north shore. No engines on this water since 1971.",
    facts: [["First boat", "06:30"], ["Water", "11 °C"], ["Per hour", "$28"]],
  },
  {
    label: "Boathouse",
    img: "https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=1600&q=80",
    thumb: "https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=200&q=80",
    alt: "A timber boathouse on stilts over a green lake with cloud on the mountains behind",
    headline: ["On stilts", "since 1934."],
    copy: "Nineteen boats sleep under one shingle roof. Come by after four and watch Ilse oil the oars for the morning.",
    facts: [["Boats", "19"], ["Open", "Apr to Oct"], ["Varnished", "Each March"]],
  },
  {
    label: "Routes",
    img: "https://images.unsplash.com/photo-1465056836041-7f43ac27dcb5?auto=format&fit=crop&w=1600&q=80",
    thumb: "https://images.unsplash.com/photo-1465056836041-7f43ac27dcb5?auto=format&fit=crop&w=200&q=80",
    alt: "A long still lake at sunset with a snow covered mountain range on the far shore",
    headline: ["Eleven kilometres", "of quiet water."],
    copy: "Three marked routes, from a forty minute loop of the reed beds to the full length of the lake with lunch at the far hut.",
    facts: [["Short loop", "40 min"], ["Far hut", "3 h 10"], ["Buoys", "Each 500 m"]],
  },
  {
    label: "Journal",
    img: "https://images.unsplash.com/photo-1493246507139-91e8fad9978e?auto=format&fit=crop&w=1600&q=80",
    thumb: "https://images.unsplash.com/photo-1493246507139-91e8fad9978e?auto=format&fit=crop&w=200&q=80",
    alt: "Mountain peaks lit orange by sunrise and mirrored in a calm blue lake",
    headline: ["Notes from", "the far shore."],
    copy: "What the lake did this week, written at the bench by the slipway. Ice out dates, first swifts, the morning the fog stayed.",
    facts: [["Latest", "First frost"], ["Written by", "Ilse Brandt"], ["Entries", "212"]],
  },
];

const brand = "Tarnside Boats";
const CYCLE_MS = 3600; // how long the preview rests on each link until someone touches the menu
const SPRING = { type: "spring", stiffness: 380, damping: 34 } as const;
const ring = "focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-black/40";

function BoatMark() {
  return (
    <svg viewBox="0 0 28 28" className="h-7 w-7" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M3 17c3 4 7 6 11 6s8-2 11-6Z" />
      <path d="M14 17V4" />
      <path d="M14 5c3 1 5 4 6 8h-6" />
    </svg>
  );
}

export default function NavbarWithCenteredLinksAndMobileMenu() {
  const reduce = useReducedMotion();
  const root = useRef<HTMLElement>(null);
  const menuButton = useRef<HTMLButtonElement>(null);
  const inView = useInView(root, { amount: 0.3 });
  // The page being viewed, the link being previewed (hover or focus) and whether the preview still plays by itself.
  const [current, setCurrent] = useState(0);
  const [peek, setPeek] = useState<number | null>(null);
  const [demo, setDemo] = useState(true);
  // Starts open so a phone preview shows the menu. Set to false on a real site.
  const [open, setOpen] = useState(true);

  const shown = peek ?? current;
  const page = links[shown];
  const playing = demo && !reduce && inView;

  useEffect(() => {
    if (!playing) return;
    const id = window.setInterval(() => setCurrent((c) => (c + 1) % links.length), CYCLE_MS);
    return () => window.clearInterval(id);
  }, [playing]);

  const choose = (i: number) => {
    setDemo(false);
    setCurrent(i);
    setPeek(null);
  };
  const preview = (i: number | null) => {
    if (i !== null) setDemo(false);
    setPeek(i);
  };
  const onMenuKey = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.key !== "Escape") return;
    setOpen(false);
    menuButton.current?.focus();
  };

  return (
    <section ref={root} className="relative isolate overflow-hidden bg-[#0b1d24] text-white">
      <style>{`
        @keyframes navbar-with-centered-links-and-mobile-menu-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
        @keyframes navbar-with-centered-links-and-mobile-menu-drift { from { transform: scale(1.04) translate3d(0, 0, 0); } to { transform: scale(1.12) translate3d(-1%, -1%, 0); } }
        .navbar-with-centered-links-and-mobile-menu-fill { transform-origin: left; animation: navbar-with-centered-links-and-mobile-menu-fill ${CYCLE_MS}ms linear both; }
        .navbar-with-centered-links-and-mobile-menu-drift { animation: navbar-with-centered-links-and-mobile-menu-drift 14s ease-out both; }
        @media (prefers-reduced-motion: reduce) {
          .navbar-with-centered-links-and-mobile-menu-fill, .navbar-with-centered-links-and-mobile-menu-drift { animation: none; }
        }
      `}</style>

      {/* Every link's photograph is stacked here; only the shown one is opaque. */}
      <div className="absolute inset-0 -z-10">
        {links.map((l, i) => (
          <motion.div key={l.label} className="absolute inset-0" initial={false} animate={{ opacity: i === shown ? 1 : 0 }} transition={reduce ? { duration: 0 } : { duration: 0.6, ease: [0.22, 1, 0.36, 1] }}>
            <img
              key={i === shown ? "on" : "off"}
              src={l.img}
              alt={i === shown ? l.alt : ""}
              width={1600}
              height={1067}
              loading="lazy"
              className={`h-full w-full object-cover ${i === shown ? "navbar-with-centered-links-and-mobile-menu-drift" : "scale-105"}`}
            />
          </motion.div>
        ))}
        <div className="absolute inset-x-0 top-0 h-44 bg-gradient-to-b from-[#06141a]/80 via-[#06141a]/35 to-transparent" aria-hidden="true" />
        <div className="absolute inset-x-0 bottom-0 h-[70%] bg-gradient-to-t from-[#06141a]/90 via-[#06141a]/45 to-transparent" aria-hidden="true" />
      </div>

      <div className="relative mx-auto flex min-h-[680px] max-w-7xl flex-col px-5 sm:px-8 lg:min-h-[700px]">
        {/* The bar: three columns, so the links sit in the true centre whatever the two sides weigh. */}
        <header className="relative z-20 grid h-20 grid-cols-[1fr_auto] items-center border-b border-white/20 md:grid-cols-[1fr_auto_1fr]">
          <a href="#" className={`flex w-fit items-center gap-2.5 rounded-full text-[15px] font-semibold tracking-tight ${ring}`}>
            <BoatMark />
            {brand}
          </a>

          <nav aria-label="Main" className="hidden md:block" onPointerLeave={() => setPeek(null)}>
            <ul className="flex items-center">
              {links.map((l, i) => (
                <li key={l.label}>
                  <a
                    href="#"
                    aria-current={i === current ? "page" : undefined}
                    onClick={(e) => {
                      e.preventDefault();
                      choose(i);
                    }}
                    onPointerEnter={(e) => e.pointerType === "mouse" && preview(i)}
                    onFocus={() => preview(i)}
                    onBlur={() => setPeek(null)}
                    className={`relative flex h-20 items-center px-5 text-sm font-medium transition-colors duration-300 ${ring} ${i === shown ? "text-white" : "text-white/80 hover:text-white"}`}
                  >
                    {l.label}
                    {i === shown && (
                      <motion.span layoutId="navbar-with-centered-links-and-mobile-menu-marker" transition={reduce ? { duration: 0 } : SPRING} className="absolute inset-x-5 -bottom-px h-[3px] overflow-hidden rounded-full bg-white/40" aria-hidden="true">
                        {/* While the preview plays, the marker doubles as the timer for the next link. */}
                        <span key={shown} className={`block h-full w-full rounded-full bg-[#ffd9a0] ${playing ? "navbar-with-centered-links-and-mobile-menu-fill" : ""}`} />
                      </motion.span>
                    )}
                  </a>
                </li>
              ))}
            </ul>
          </nav>

          <div className="flex items-center justify-end gap-1">
            <a href="#" className={`hidden h-10 items-center rounded-full px-4 text-sm font-medium text-white transition-opacity hover:opacity-80 md:inline-flex ${ring}`}>
              Sign in
            </a>
            <a href="#" className={`hidden h-10 items-center gap-2 rounded-full bg-[#fff4e2] pl-4 pr-1.5 text-sm font-semibold text-[#0b1d24] shadow-lg shadow-black/25 transition-transform hover:-translate-y-0.5 md:inline-flex ${ring}`}>
              Book a boat
              <span className="flex h-7 w-7 items-center justify-center rounded-full bg-[#0b1d24] text-[#fff4e2]">
                <svg viewBox="0 0 20 20" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M4 10h12M11 5l5 5-5 5" /></svg>
              </span>
            </a>
            <button
              ref={menuButton}
              type="button"
              aria-expanded={open}
              aria-controls="navbar-with-centered-links-and-mobile-menu-panel"
              aria-label={open ? "Close menu" : "Open menu"}
              onClick={() => setOpen(!open)}
              className={`relative flex h-11 w-11 items-center justify-center rounded-full border border-white/30 bg-white/10 backdrop-blur-md md:hidden ${ring}`}
            >
              <span aria-hidden="true" className={`absolute h-0.5 w-5 rounded-full bg-white transition-transform duration-300 motion-reduce:transition-none ${open ? "rotate-45" : "-translate-y-[5px]"}`} />
              <span aria-hidden="true" className={`absolute h-0.5 w-5 rounded-full bg-white transition-transform duration-300 motion-reduce:transition-none ${open ? "-rotate-45" : "translate-y-[5px]"}`} />
            </button>
          </div>
        </header>

        {/* Phone menu: a sheet of dark glass that drops from the bar, one photo row per link. */}
        <AnimatePresence initial={false}>
          {open && (
            <motion.div
              id="navbar-with-centered-links-and-mobile-menu-panel"
              onKeyDown={onMenuKey}
              initial={reduce ? { opacity: 0 } : { opacity: 0, y: -14, scale: 0.98 }}
              animate={{ opacity: 1, y: 0, scale: 1 }}
              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -10, scale: 0.98, transition: { duration: 0.16 } }}
              transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 320, damping: 30 }}
              className="absolute inset-x-3 top-[88px] z-30 origin-top rounded-[28px] border border-white/20 bg-[#06141a]/75 p-2 shadow-[0_30px_60px_-20px_rgba(0,0,0,0.7),inset_0_1px_0_rgba(255,255,255,0.18)] backdrop-blur-2xl md:hidden"
            >
              <nav aria-label="Mobile">
                <ul>
                  {links.map((l, i) => (
                    <li key={l.label}>
                      <a
                        href="#"
                        aria-current={i === current ? "page" : undefined}
                        onClick={(e) => {
                          e.preventDefault();
                          choose(i);
                        }}
                        className={`flex items-center gap-3 rounded-[20px] p-2 pr-4 transition-colors ${ring} ${i === current ? "bg-white/15" : "hover:bg-white/10"}`}
                      >
                        <img src={l.thumb} alt="" width={56} height={56} loading="lazy" className="h-14 w-14 flex-none rounded-2xl object-cover" />
                        <span className="min-w-0 flex-1">
                          <span className="block text-lg font-medium tracking-tight">{l.label}</span>
                          <span className="block truncate text-[13px] text-white/80">{l.headline.join(" ")}</span>
                        </span>
                        <span className={`h-2 w-2 flex-none rounded-full ${i === current ? "bg-[#ffd9a0]" : "bg-white/25"}`} aria-hidden="true" />
                      </a>
                    </li>
                  ))}
                </ul>
              </nav>
              <div className="mt-2 grid grid-cols-2 gap-2 border-t border-white/15 p-2 pt-3">
                <a href="#" className={`flex h-12 items-center justify-center rounded-full border border-white/30 text-sm font-semibold ${ring}`}>Sign in</a>
                <a href="#" className={`flex h-12 items-center justify-center rounded-full bg-[#fff4e2] text-sm font-semibold text-[#0b1d24] ${ring}`}>Book a boat</a>
              </div>
            </motion.div>
          )}
        </AnimatePresence>

        {/* The slice of page under the bar. It follows whichever link is shown. */}
        <div className="mt-auto grid items-end gap-8 pb-8 pt-40 sm:pb-12 lg:grid-cols-[1fr_auto] lg:gap-16">
          <div aria-live="polite">
            <AnimatePresence mode="wait" initial={false}>
              <motion.div
                key={shown}
                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 18, filter: "blur(6px)" }}
                animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                exit={reduce ? { opacity: 0 } : { opacity: 0, y: -10, transition: { duration: 0.12 } }}
                transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 260, damping: 28 }}
              >
                <p className="flex items-center gap-3 text-[11px] font-semibold uppercase tracking-[0.3em] text-[#ffd9a0]">
                  <span className="tabular-nums">0{shown + 1} / 0{links.length}</span>
                  <span className="h-px w-10 bg-[#ffd9a0]/70" aria-hidden="true" />
                  {page.label}
                </p>
                <h1 className="mt-5 text-[clamp(2.6rem,7.4vw,6.4rem)] font-light leading-[0.94] tracking-[-0.045em]">
                  {page.headline.map((line) => (
                    <span key={line} className="block">{line}</span>
                  ))}
                </h1>
                <p className="mt-6 max-w-md text-[15px] leading-relaxed text-white/90 sm:text-base">{page.copy}</p>
              </motion.div>
            </AnimatePresence>
          </div>

          <dl className="grid grid-cols-3 overflow-hidden rounded-3xl border border-white/25 bg-gradient-to-br from-white/20 to-white/5 shadow-[0_24px_60px_-24px_rgba(0,0,0,0.7),inset_0_1px_0_rgba(255,255,255,0.3)] backdrop-blur-xl lg:w-[420px]">
            {page.facts.map(([label, value], i) => (
              <div key={`${shown}-${label}`} className={`min-w-0 px-4 py-4 sm:px-5 ${i > 0 ? "border-l border-white/15" : ""}`}>
                <dt className="truncate text-[11px] font-medium uppercase tracking-[0.16em] text-white/80">{label}</dt>
                <dd className="mt-2 truncate text-base font-medium tabular-nums tracking-tight sm:text-lg">{value}</dd>
              </div>
            ))}
          </dl>
        </div>
      </div>
    </section>
  );
}
```
