Morph Navbar

A header with two states. At the top of the page it spans the full width: the logo on the left, the links centred, and the actions on the right, with no surface behind it. Scroll a little and it closes in to a small frosted bar that holds only what it needs. The logo becomes a square mark and a divider appears before the actions.

Scroll inside the preview to see the change, then scroll back up. Switch to Mobile to see the links move into a menu, or use Full screen to see it at page width.

One bar, two widths

Both states are the same bar. Nothing is swapped out, so the links never jump. Only the bar's width, surface, and logo change.

  • At the top the bar is 100% wide and transparent. The logo and actions slots are flex-1, which keeps the links centred on the page.
  • Scrolled, the bar gets an exact pixel width: its padding, the 30px mark, the links, the actions, and the 8px gaps between them. The flex-1 slots then shrink to their contents, so the bar fits snugly.
  • The width transitions in CSS, from 100% to that pixel value over 500ms on ease-snappy. It is a real width change, not a scale, so the text stays sharp the whole way.
  • The sizes come from a ResizeObserver. It reports sizes without transforms, so the width is still correct inside a scaled preview. When the links are hidden on a narrow screen they measure 0 and their gap is dropped.

The logo hands over

The full logo sits out of the flow, pinned to the left of its slot. That lets the bar close past it while it fades out with a slight scale and blur. The mark tile grows from 0 to 30px wide in the flow, so it pushes the links over as it arrives. The divider always takes up its 1px and only fades in, so the measured width never changes between states.

Surface

Scrolled, the bar takes the usual raised shadow, a 70% background, and backdrop-blur-xl, so the page shows through faintly as it passes underneath. The outer radius is 14px: the 6px of the buttons inside plus the 8px of padding. The header pads 12px down from the top edge so the bar floats instead of touching it.

Behaviour

  • It follows the nearest scroller. The navbar walks up to the first ancestor whose overflow-y scrolls, and uses the window if there isn't one. It works on a real page, in a scrolling panel, or in this preview without any setup.
  • threshold sets how far the page scrolls before it floats (24px by default). Pass scrolled to control the state yourself.
  • It is sticky, so put it first in the scrolling container.
  • With reduced motion it changes state instantly, and the logo only fades.

Colour

The main action is the dark primary button by default. Pass any CSS colour as accent (the preview uses a soft periwinkle, #cfd8ff) and accentForeground for its text. Its ring is mixed from the accent, so any colour gets a matching edge.

Widths

Below @2xl the links and Sign in move into a menu button beside the main action. The menu opens as a card under the bar, full width in both states.

Usage

The whole header is a client component, since it listens to scroll.

components/blocks/navigation/morph-navbar.tsx
"use client";

import { ListIcon, XIcon } from "@phosphor-icons/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type ReactNode,
} from "react";

import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

type Link = { label: string; href: string };
type Brand = Link & {
  /** The square mark shown in the floating bar. */
  mark?: ReactNode;
  /** The full logo shown at the top of the page. Defaults to the mark and label. */
  wordmark?: ReactNode;
};

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

// Fixed sizes the compact width is built from: bar padding, gap, and mark tile.
const PAD = 8;
const GAP = 8;
const MARK = 30;

const EASE = [0.23, 1, 0.32, 1] as const;

function DefaultMark({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" className={className}>
      <path
        fillRule="evenodd"
        d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0Zm0 2.75a5.25 5.25 0 1 0 0 10.5 5.25 5.25 0 0 0 0-10.5Z"
      />
      <circle cx="8" cy="8" r="2.25" />
    </svg>
  );
}

/** The nearest ancestor that scrolls vertically, or the window. */
function getScrollParent(el: HTMLElement): HTMLElement | Window {
  for (let node = el.parentElement; node; node = node.parentElement) {
    if (/auto|scroll|overlay/.test(getComputedStyle(node).overflowY)) return node;
  }
  return window;
}

/**
 * A header that spans the page at the top and, once the page scrolls, draws
 * in to a floating, frosted bar just wide enough for its contents. The full
 * logo hands over to a square mark as it closes. Place it at the top of the
 * page (or of any scrolling container); it sticks and watches that scroll.
 */
export function MorphNavbar({
  brand = { label: "Halo", href: "#" },
  links = DEFAULT_LINKS,
  secondaryAction = { label: "Sign in", href: "#" },
  primaryAction = { label: "Get started", href: "#" },
  accent,
  accentForeground = "#161a33",
  threshold = 24,
  scrolled: scrolledProp,
  className,
}: {
  brand?: Brand;
  links?: Link[];
  secondaryAction?: Link | null;
  primaryAction?: Link | null;
  /** Any CSS colour for the main action. Neutral when unset. */
  accent?: string;
  accentForeground?: string;
  /** Pixels of scroll before the bar floats. */
  threshold?: number;
  /** Force a state instead of following the scroll. */
  scrolled?: boolean;
  className?: string;
}) {
  const headerRef = useRef<HTMLElement>(null);
  const linksRef = useRef<HTMLElement>(null);
  const actionsRef = useRef<HTMLDivElement>(null);
  const [scrolledState, setScrolled] = useState(false);
  const [compact, setCompact] = useState(0);
  const [menuOpen, setMenuOpen] = useState(false);
  const closeMenu = useCallback(() => setMenuOpen(false), []);
  const menuId = useId();
  const scrolled = scrolledProp ?? scrolledState;

  useEffect(() => {
    if (scrolledProp !== undefined || !headerRef.current) return;
    const target = getScrollParent(headerRef.current);
    const read = () =>
      setScrolled(
        (target instanceof Window ? target.scrollY : target.scrollTop) > threshold,
      );
    read();
    target.addEventListener("scroll", read, { passive: true });
    return () => target.removeEventListener("scroll", read);
  }, [scrolledProp, threshold]);

  // The floating width is the links and actions plus the fixed pieces. Sizes
  // come from ResizeObserver, which ignores transforms, so a scaled preview
  // still measures true. Hidden links measure 0 and drop their gap.
  useLayoutEffect(() => {
    const nav = linksRef.current;
    const actions = actionsRef.current;
    if (!nav || !actions) return;
    const widths = new Map<Element, number>();
    const observer = new ResizeObserver((entries) => {
      for (const entry of entries) {
        widths.set(entry.target, entry.borderBoxSize[0]?.inlineSize ?? 0);
      }
      const linksWidth = widths.get(nav) ?? 0;
      const actionsWidth = widths.get(actions) ?? 0;
      setCompact(
        Math.ceil(PAD * 2 + MARK + GAP + actionsWidth + (linksWidth ? linksWidth + GAP : 0)),
      );
    });
    observer.observe(nav);
    observer.observe(actions);
    return () => observer.disconnect();
  }, []);

  const accentStyle = accent
    ? ({ "--navbar-accent": accent, "--navbar-accent-fg": accentForeground } as CSSProperties)
    : undefined;

  return (
    <header
      ref={headerRef}
      data-scrolled={scrolled ? "" : undefined}
      className={cn(
        "@container sticky top-0 z-40 w-full px-3 transition-[padding] duration-500 ease-snappy data-scrolled:pt-3 motion-reduce:transition-none",
        className,
      )}
      style={accentStyle}
    >
      <div className="relative mx-auto max-w-6xl">
        <div
          className={cn(
            "relative mx-auto flex h-11.5 items-center gap-2 rounded-[14px] p-2 transition-[width,background-color,box-shadow] duration-500 ease-snappy motion-reduce:transition-none",
            scrolled
              ? "bg-background/70 shadow-(--custom-shadow) backdrop-blur-xl dark:bg-card/70"
              : "bg-transparent shadow-none",
          )}
          style={{ width: scrolled && compact ? compact : "100%" }}
        >
          <div className="flex flex-1 items-center">
            <a
              href={brand.href}
              aria-label={brand.label}
              className="relative flex h-7.5 items-center rounded-md outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring/60"
            >
              {/* Out of flow, so the bar can close past it while it fades. */}
              <span
                className={cn(
                  "absolute top-1/2 left-1 flex -translate-y-1/2 items-center gap-2 text-base font-medium tracking-tight whitespace-nowrap transition-[opacity,scale,filter] duration-300 ease-snappy motion-reduce:transition-opacity",
                  scrolled && "pointer-events-none scale-96 opacity-0 blur-[2px]",
                )}
              >
                {brand.wordmark ?? (
                  <>
                    <span className="size-4">{brand.mark ?? <DefaultMark className="size-4" />}</span>
                    {brand.label}
                  </>
                )}
              </span>
              <span
                aria-hidden="true"
                className={cn(
                  "flex h-7.5 items-center justify-center overflow-clip rounded-md bg-card text-foreground shadow-(--custom-shadow) transition-[width,opacity,scale] duration-500 ease-snappy motion-reduce:transition-opacity [&_svg]:size-4 [&_svg]:shrink-0",
                  scrolled ? "w-7.5 opacity-100" : "w-0 scale-80 opacity-0",
                )}
              >
                {brand.mark ?? <DefaultMark />}
              </span>
            </a>
          </div>

          <nav ref={linksRef} aria-label="Main" className="hidden shrink-0 @2xl:block">
            <ul className="flex items-center gap-2">
              {links.map((link) => (
                <li key={link.label}>
                  <a href={link.href} className={LINK_CLASS}>
                    {link.label}
                  </a>
                </li>
              ))}
            </ul>
          </nav>

          <div className="flex flex-1 justify-end">
            <div ref={actionsRef} className="flex shrink-0 items-center gap-2">
              {/* Always holds its place so the floating width never jumps. */}
              <span
                aria-hidden="true"
                className={cn(
                  "hidden h-5 w-px bg-border transition-opacity duration-300 @2xl:block",
                  !scrolled && "opacity-0",
                )}
              />
              {secondaryAction && (
                <a href={secondaryAction.href} className={cn(LINK_CLASS, "hidden @2xl:flex")}>
                  {secondaryAction.label}
                </a>
              )}
              {primaryAction && (
                <Button
                  size="sm"
                  className={cn(
                    "rounded-md px-2 font-normal",
                    accent &&
                      "bg-(--navbar-accent) text-(--navbar-accent-fg) shadow-[0_0_0_1px_color-mix(in_oklab,var(--navbar-accent)_85%,black),0_1px_2px_rgb(0_0_0/0.2),inset_0_0.75px_0_rgb(255_255_255/0.2)] hover:bg-(--navbar-accent) hover:brightness-97 dark:bg-(--navbar-accent) dark:hover:bg-(--navbar-accent)",
                  )}
                  render={<a href={primaryAction.href} />}
                >
                  {primaryAction.label}
                </Button>
              )}
              <MenuButton
                menuId={menuId}
                open={menuOpen}
                onOpenChange={setMenuOpen}
                className="@2xl:hidden"
              />
            </div>
          </div>
        </div>

        <MobileMenu
          id={menuId}
          open={menuOpen}
          onClose={closeMenu}
          links={secondaryAction ? [...links, secondaryAction] : links}
        />
      </div>
    </header>
  );
}

const LINK_CLASS =
  "relative flex h-7 items-center rounded-md px-2 text-sm whitespace-nowrap text-foreground outline-none transition-colors duration-150 after:absolute after:inset-x-0 after:-inset-y-1.5 hover:bg-foreground/5 focus-visible:ring-[1.5px] focus-visible:ring-ring/60 dark:hover:bg-foreground/10";

function MenuButton({
  menuId,
  open,
  onOpenChange,
  className,
}: {
  menuId: string;
  open: boolean;
  onOpenChange: (open: boolean) => void;
  className?: string;
}) {
  const iconClass =
    "absolute size-4 transition-[opacity,scale] duration-200 ease-snappy motion-reduce:transition-none";
  return (
    <button
      type="button"
      aria-label={open ? "Close menu" : "Open menu"}
      aria-expanded={open}
      aria-controls={menuId}
      data-morph-menu-toggle=""
      onClick={() => onOpenChange(!open)}
      className={cn(
        LINK_CLASS,
        "w-7 cursor-pointer justify-center after:-inset-x-1.5",
        className,
      )}
    >
      <ListIcon className={cn(iconClass, open ? "scale-75 opacity-0" : "opacity-100")} />
      <XIcon className={cn(iconClass, open ? "opacity-100" : "scale-75 opacity-0")} />
    </button>
  );
}

function MobileMenu({
  id,
  open,
  onClose,
  links,
}: {
  id: string;
  open: boolean;
  onClose: () => void;
  links: Link[];
}) {
  const reduceMotion = useReducedMotion();
  const ref = useRef<HTMLElement>(null);

  useEffect(() => {
    if (!open) return;
    const onPointerDown = (e: PointerEvent) => {
      const target = e.target as Element;
      if (!ref.current?.contains(target) && !target.closest("[data-morph-menu-toggle]")) onClose();
    };
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") onClose();
    };
    document.addEventListener("pointerdown", onPointerDown);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("pointerdown", onPointerDown);
      document.removeEventListener("keydown", onKey);
    };
  }, [open, onClose]);

  return (
    <AnimatePresence>
      {open && (
        <motion.nav
          ref={ref}
          id={id}
          aria-label="Main"
          className="absolute inset-x-0 top-full mt-2 origin-top rounded-[14px] bg-card p-1.5 text-card-foreground shadow-(--custom-shadow) @2xl:hidden"
          initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: -4 }}
          animate={{ opacity: 1, scale: 1, y: 0 }}
          exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.98, y: -4 }}
          transition={{ duration: 0.2, ease: EASE }}
        >
          <ul className="flex flex-col">
            {links.map((link) => (
              <li key={link.label}>
                <a
                  href={link.href}
                  onClick={onClose}
                  className="flex h-10 items-center rounded-lg px-3 text-sm outline-none transition-colors duration-150 hover:bg-muted focus-visible:bg-muted focus-visible:ring-[1.5px] focus-visible:ring-ring/60"
                >
                  {link.label}
                </a>
              </li>
            ))}
          </ul>
        </motion.nav>
      )}
    </AnimatePresence>
  );
}

Search concepts

Search for a command to run...