Domphy

resizableNavbar

A Navigation block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call resizableNavbar() with no arguments for a working demo, or edit the code below live.

Implementation notes

Continuous scroll-offset interpolation (0..1 progress over a configurable shrinkDistancePx, default 240) drives width/margin/border-radius/padding via a JS lerp() helper formatted into template-literal style values, rather than a native CSS scroll-timeline (broader browser support, and Domphy's reactive style functions already give per-frame control). This is a continuous resize, never a hide, correctly distinguishing it from floatingNavbar. Four button variants are supported (primary/secondary/dark/gradient); gradient is built from themeColor()-composed linear-gradient() stops, never a literal hex color.

Status: ported · Reference: Aceternity UI original

// Aceternity "Resizable Navbar" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied). A
// sticky top navbar that continuously interpolates its own width/margin/
// border-radius against the raw scroll offset — shrinking from a full-width
// flat bar into a narrower, more rounded floating pill as the page scrolls,
// never fully hiding. A separate mobile header + collapsible link panel
// covers narrow viewports.
//
// Distinct from floatingNavbar: this reacts to a continuous scroll *offset*
// (0..1 progress), not scroll *direction* — floatingNavbar hides/reveals on
// down/up deltas, this one only ever resizes in place.

import type { DomphyElement, ElementNode, Listener, State, ValueOrState } from "@domphy/core";
import { toState } from "@domphy/core";
import { buttonGhost, linkButton, listItemButton, strong } from "@domphy/ui";
import { type ThemeColor, themeColor, themeDensity, themeSpacing } from "@domphy/theme";

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

export type ResizableNavButtonVariant = "primary" | "secondary" | "dark" | "gradient";

export interface ResizableNavButton {
  label: string;
  href?: string;
  onClick?: (event: MouseEvent) => void;
  /** Visual treatment. Defaults to "primary" for the first button, "secondary" style choices are left to the caller. */
  variant?: ResizableNavButtonVariant;
}

export interface ResizableNavbarProps {
  /** Nav link entries, shared by the desktop bar and the mobile panel. Defaults to a 4-item marketing-site demo set. */
  items?: ResizableNavItem[];
  /** Brand/logo text. Defaults to "Acme". */
  logoLabel?: string;
  /** Trailing action buttons. Defaults to a "Login" + "Book a call" pair. */
  buttons?: ResizableNavButton[];
  /** Renders the desktop row. Defaults to true. */
  showDesktop?: boolean;
  /** Renders the mobile header + panel. Defaults to true. */
  showMobile?: boolean;
  /** Mobile menu open state — pass your own `State` to control it externally. Defaults to false. */
  mobileOpen?: ValueOrState<boolean>;
  onMobileOpenChange?: (open: boolean) => void;
  /** Scroll distance (px) over which the bar fully shrinks into its pill shape. Defaults to 240. */
  shrinkDistancePx?: number;
}

const DEFAULT_ITEMS: ResizableNavItem[] = [
  { label: "Features", href: "#features" },
  { label: "Pricing", href: "#pricing" },
  { label: "About", href: "#about" },
  { label: "Contact", href: "#contact" },
];

const DEFAULT_BUTTONS: ResizableNavButton[] = [
  { label: "Login", href: "#login", variant: "secondary" },
  { label: "Book a call", href: "#book", variant: "primary" },
];

const MOBILE_MEDIA_QUERY = "(max-width: 61.9375em)";

const MENU_SHAPE: DomphyElement[] = [
  { line: null, x1: "4", y1: "7", x2: "20", y2: "7" },
  { line: null, x1: "4", y1: "12", x2: "20", y2: "12" },
  { line: null, x1: "4", y1: "17", x2: "20", y2: "17" },
];

const CLOSE_SHAPE: DomphyElement[] = [
  { line: null, x1: "5", y1: "5", x2: "19", y2: "19" },
  { line: null, x1: "19", y1: "5", x2: "5", y2: "19" },
];

function rawGlyph(shape: DomphyElement[]): DomphyElement<"svg"> {
  return {
    svg: shape,
    viewBox: "0 0 24 24",
    fill: "none",
    stroke: "currentColor",
    strokeWidth: "1.75",
    strokeLinecap: "round",
    strokeLinejoin: "round",
    role: "img",
    ariaHidden: "true",
    style: { width: "100%", height: "100%" },
  } as DomphyElement<"svg">;
}

/** Linear interpolation, clamping `t` to [0, 1]. */
function lerp(t: number, from: number, to: number): number {
  const clamped = Math.max(0, Math.min(1, t));
  return from + (to - from) * clamped;
}

function brandLogo(label: string): DomphyElement<"strong"> {
  return { strong: label, $: [strong()], style: { flexShrink: "0" } };
}

function resizableButton(entry: ResizableNavButton): DomphyElement<"a"> {
  const variant = entry.variant ?? "primary";
  const element: DomphyElement<"a"> = { a: entry.label, href: entry.href ?? "#", _key: entry.label };
  if (entry.onClick) element.onClick = entry.onClick;

  if (variant === "gradient") {
    element.$ = [linkButton({ color: "primary" })];
    element.style = {
      backgroundImage: (listener: Listener) =>
        `linear-gradient(135deg, ${themeColor(listener, "shift-2", "primary")}, ${themeColor(listener, "shift-2", "secondary")})`,
      color: (listener: Listener) => themeColor(listener, "shift-11", "primary"),
      outline: "none",
    };
    return element;
  }

  if (variant === "dark") {
    element.$ = [linkButton({ color: "neutral" })];
    element.dataTone = "shift-16";
    element.style = {
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
    };
    return element;
  }

  const colorByVariant: Record<"primary" | "secondary", ThemeColor> = {
    primary: "primary",
    secondary: "neutral",
  };
  element.$ = [linkButton({ color: colorByVariant[variant] })];
  return element;
}

function desktopBar(
  items: ResizableNavItem[],
  logoLabel: string,
  buttons: ResizableNavButton[],
  progress: State<number>,
): DomphyElement<"nav"> {
  return {
    nav: [
      brandLogo(logoLabel),
      {
        div: items.map((item) => ({
          a: item.label,
          href: item.href ?? "#",
          _key: item.label,
          $: [listItemButton({ color: "neutral" })],
          style: { width: "auto" },
        })),
        style: { display: "flex", alignItems: "center", gap: themeSpacing(1) },
      } as DomphyElement<"div">,
      {
        div: buttons.map((entry) => resizableButton(entry)),
        style: {
          display: "flex",
          alignItems: "center",
          gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 2),
        },
      } as DomphyElement<"div">,
    ],
    ariaLabel: "Primary",
    style: {
      display: "flex",
      alignItems: "center",
      justifyContent: "space-between",
      marginInline: "auto",
      width: (listener: Listener) => `${lerp(progress.get(listener), 100, 86)}%`,
      maxWidth: themeSpacing(240),
      marginTop: (listener: Listener) => themeSpacing(lerp(progress.get(listener), 0, 3)),
      borderRadius: (listener: Listener) => themeSpacing(lerp(progress.get(listener), 0, 999)),
      paddingBlock: (listener: Listener) =>
        themeSpacing(lerp(progress.get(listener), themeDensity(listener) * 3, themeDensity(listener) * 1.5)),
      paddingInline: (listener: Listener) =>
        themeSpacing(lerp(progress.get(listener), themeDensity(listener) * 5, themeDensity(listener) * 4)),
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      outline: (listener: Listener) =>
        `1px solid ${themeColor(listener, progress.get(listener) > 0.05 ? "shift-3" : "inherit")}`,
      outlineOffset: "-1px",
      boxShadow: (listener: Listener) =>
        progress.get(listener) > 0.05
          ? `0 ${themeSpacing(2)} ${themeSpacing(10)} ${themeColor(listener, "shift-4")}`
          : "none",
      transition:
        "width 200ms ease, margin 200ms ease, border-radius 200ms ease, padding 200ms ease, box-shadow 200ms ease",
      [`@media ${MOBILE_MEDIA_QUERY}`]: { display: "none" },
    },
  };
}

function mobilePanel(
  items: ResizableNavItem[],
  logoLabel: string,
  mobileOpenState: State<boolean>,
  onMobileOpenChange?: (open: boolean) => void,
): DomphyElement<"div"> {
  const toggle = () => {
    const next = !mobileOpenState.get();
    mobileOpenState.set(next);
    onMobileOpenChange?.(next);
  };

  return {
    div: [
      {
        div: [
          brandLogo(logoLabel),
          {
            button: [{ span: [rawGlyph(MENU_SHAPE)], style: { display: "inline-flex", width: themeSpacing(5), height: themeSpacing(5) } }],
            ariaLabel: "Toggle menu",
            ariaExpanded: (listener: Listener) => mobileOpenState.get(listener),
            onClick: toggle,
            $: [buttonGhost()],
          } as DomphyElement<"button">,
        ],
        style: { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" },
      } as DomphyElement<"div">,
      {
        ul: items.map((item) => ({
          li: [{ a: item.label, href: item.href ?? "#", $: [listItemButton({ color: "neutral" })] }],
          _key: item.label,
        })),
        role: "menu",
        style: {
          listStyle: "none",
          margin: "0",
          padding: "0",
          display: "flex",
          flexDirection: "column",
          overflow: "hidden",
          maxHeight: (listener: Listener) => (mobileOpenState.get(listener) ? themeSpacing(120) : "0em"),
          opacity: (listener: Listener) => (mobileOpenState.get(listener) ? 1 : 0),
          transition: "max-height 250ms ease, opacity 200ms ease",
        },
      } as DomphyElement<"ul">,
    ],
    dataTone: "shift-1",
    style: {
      display: "none",
      flexDirection: "column",
      width: "100%",
      paddingBlock: (listener: Listener) => themeSpacing(themeDensity(listener) * 2),
      paddingInline: (listener: Listener) => themeSpacing(themeDensity(listener) * 4),
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      borderBottom: (listener: Listener) => `1px solid ${themeColor(listener, "shift-3")}`,
      [`@media ${MOBILE_MEDIA_QUERY}`]: { display: "flex" },
    },
  };
}

/**
 * A sticky top navbar that continuously narrows into a rounded floating
 * pill as the page scrolls, plus a mobile header with a collapsible link
 * panel. Call with no arguments for a working 4-link demo.
 */
function resizableNavbar(props: ResizableNavbarProps = {}): DomphyElement<"header"> {
  const items = props.items ?? DEFAULT_ITEMS;
  const logoLabel = props.logoLabel ?? "Acme";
  const buttons = props.buttons ?? DEFAULT_BUTTONS;
  const showDesktop = props.showDesktop ?? true;
  const showMobile = props.showMobile ?? true;
  const shrinkDistancePx = props.shrinkDistancePx ?? 240;
  const mobileOpenState = toState(props.mobileOpen ?? false);
  const progress = toState(0);

  const sections: DomphyElement[] = [];
  if (showDesktop) sections.push(desktopBar(items, logoLabel, buttons, progress));
  if (showMobile) sections.push(mobilePanel(items, logoLabel, mobileOpenState, props.onMobileOpenChange));

  return {
    header: sections,
    style: {
      position: "sticky",
      top: "0",
      zIndex: 40,
      width: "100%",
      display: "flex",
      flexDirection: "column",
    },
    _onMount: (node: ElementNode) => {
      let scheduled = false;
      const applyScrollProgress = () => {
        scheduled = false;
        const clamped = Math.max(0, Math.min(1, window.scrollY / shrinkDistancePx));
        progress.set(clamped);
      };
      const handleScroll = () => {
        if (scheduled) return;
        scheduled = true;
        requestAnimationFrame(applyScrollProgress);
      };

      applyScrollProgress();
      window.addEventListener("scroll", handleScroll, { passive: true });
      node.addHook("Remove", () => window.removeEventListener("scroll", handleScroll));
    },
  };
}

export { resizableNavbar };

← Back to Aceternity UI catalog