Domphy

floatingNavbar

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

Implementation notes

Direction-based (not distance-threshold-based) hide/reveal via a rAF-throttled window scroll listener comparing consecutive scrollY reads, matching the spec's documented distinguishing behavior versus stickyBanner. A small sensitivity floor (default 4px) filters sub-pixel jitter, and downward hides only past scrollY>80 to avoid flicker right at the top of the page (no numeric threshold is documented upstream, so this is a reasonable low-confidence default). Nav links reuse the listItemButton() ui patch for the hover highlight; CTA reuses linkButton().

Status: ported · Reference: Aceternity UI original

// Aceternity "Floating Navbar" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A slim,
// pill-shaped top navbar that floats above the page and slides out of view
// on downward scroll, then slides back in on the very next upward scroll —
// direction-based hide/reveal, not a one-way dismissal (that's stickyBanner).

import type { DomphyElement, ElementNode, Listener } from "@domphy/core";
import { toState } from "@domphy/core";
import { linkButton, listItemButton } from "@domphy/ui";
import { themeColor, themeDensity, themeSize, themeSpacing } from "@domphy/theme";

export type FloatingNavbarIconName = "home" | "info" | "grid" | "mail";

export interface FloatingNavbarItem {
  label: string;
  href?: string;
  icon?: FloatingNavbarIconName;
  onClick?: (event: MouseEvent) => void;
}

export interface FloatingNavbarProps {
  /** Nav link entries. Defaults to a 4-item marketing-site demo set. */
  items?: FloatingNavbarItem[];
  /** Trailing call-to-action button label. Defaults to "Login". */
  ctaLabel?: string;
  /** Call-to-action button href. Defaults to "#". */
  ctaHref?: string;
  onCtaClick?: (event: MouseEvent) => void;
  /**
   * Minimum absolute scroll delta (px) between two frames before a
   * direction change is honored — filters out sub-pixel scroll jitter.
   * Defaults to 4.
   */
  scrollSensitivityPx?: number;
}

const DEFAULT_ITEMS: FloatingNavbarItem[] = [
  { label: "Home", href: "#", icon: "home" },
  { label: "About", href: "#about", icon: "info" },
  { label: "Products", href: "#products", icon: "grid" },
  { label: "Contact", href: "#contact", icon: "mail" },
];

// ---------------------------------------------------------------------------
// Hand-authored generic line icons (24x24, stroke=currentColor) — simple
// geometric silhouettes, not sourced from or tracing any icon library.
// ---------------------------------------------------------------------------

const ICON_SHAPES: Record<FloatingNavbarIconName, DomphyElement[]> = {
  home: [
    { polyline: null, points: "4,12 12,5 20,12" },
    { rect: null, x: "6", y: "12", width: "12", height: "8" },
  ],
  info: [
    { circle: null, cx: "12", cy: "12", r: "9" },
    { line: null, x1: "12", y1: "11", x2: "12", y2: "16" },
    { circle: null, cx: "12", cy: "8", r: "0.8", fill: "currentColor" },
  ],
  grid: [
    { rect: null, x: "3", y: "3", width: "8", height: "8", rx: "1" },
    { rect: null, x: "13", y: "3", width: "8", height: "8", rx: "1" },
    { rect: null, x: "3", y: "13", width: "8", height: "8", rx: "1" },
    { rect: null, x: "13", y: "13", width: "8", height: "8", rx: "1" },
  ],
  mail: [
    { rect: null, x: "3", y: "5", width: "18", height: "14", rx: "2" },
    { polyline: null, points: "3,7 12,13 21,7" },
  ],
};

function navGlyph(name: FloatingNavbarIconName): DomphyElement<"svg"> {
  return {
    svg: ICON_SHAPES[name],
    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">;
}

function navLinkItem(item: FloatingNavbarItem): DomphyElement<"a"> {
  const children: DomphyElement[] = [];
  if (item.icon) {
    children.push({
      span: [navGlyph(item.icon)],
      style: { display: "inline-flex", width: themeSpacing(3.5), height: themeSpacing(3.5) },
    } as DomphyElement<"span">);
  }
  children.push({ span: item.label } as DomphyElement<"span">);

  const anchor: DomphyElement<"a"> = {
    a: children,
    href: item.href ?? "#",
    _key: item.label,
    $: [listItemButton({ color: "neutral" })],
    style: {
      width: "auto",
      fontSize: (listener: Listener) => themeSize(listener, "decrease-1"),
      // `listItemButton()` already supplies `color`, but doctor's
      // `missing-color` check only inspects THIS element's own authored
      // `style` (it doesn't resolve `$` patches first) — restate it so a
      // native-style-only reader can still confirm the surface contract.
      color: (listener: Listener) => themeColor(listener, "shift-9"),
    },
  };
  if (item.onClick) anchor.onClick = item.onClick;
  return anchor;
}

/**
 * A slim floating top navbar that hides on downward scroll and reveals on
 * the very next upward scroll (direction-based, not a one-way dismissal).
 * Call with no arguments for a working 4-link demo with a "Login" CTA.
 */
function floatingNavbar(props: FloatingNavbarProps = {}): DomphyElement<"header"> {
  const items = props.items ?? DEFAULT_ITEMS;
  const ctaLabel = props.ctaLabel ?? "Login";
  const ctaHref = props.ctaHref ?? "#";
  const sensitivity = props.scrollSensitivityPx ?? 4;

  const hidden = toState(false);

  const ctaButton: DomphyElement<"a"> = {
    a: ctaLabel,
    href: ctaHref,
    $: [linkButton({ color: "primary" })],
  };
  if (props.onCtaClick) ctaButton.onClick = props.onCtaClick;

  const element: DomphyElement<"header"> = {
    header: [
      {
        nav: items.map((item) => navLinkItem(item)),
        style: {
          display: "flex",
          alignItems: "center",
          gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 1),
        },
      } as DomphyElement<"nav">,
      ctaButton,
    ],
    ariaLabel: "Primary",
    dataTone: "shift-14",
    style: {
      position: "fixed",
      top: themeSpacing(4),
      insetInlineStart: "0",
      insetInlineEnd: "0",
      marginInline: "auto",
      width: "fit-content",
      zIndex: 40,
      display: "flex",
      alignItems: "center",
      borderRadius: themeSpacing(999),
      gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 4),
      paddingBlock: (listener: Listener) => themeSpacing(themeDensity(listener) * 1.5),
      paddingInline: (listener: Listener) => themeSpacing(themeDensity(listener) * 4),
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      outline: (listener: Listener) => `1px solid ${themeColor(listener, "shift-3")}`,
      outlineOffset: "-1px",
      boxShadow: (listener: Listener) =>
        `0 ${themeSpacing(2)} ${themeSpacing(10)} ${themeColor(listener, "shift-4")}`,
      transition: "transform 220ms cubic-bezier(0.22, 1, 0.36, 1), opacity 220ms ease",
      transform: (listener: Listener) =>
        hidden.get(listener) ? `translateY(calc(-100% - ${themeSpacing(8)}))` : "translateY(0)",
      opacity: (listener: Listener) => (hidden.get(listener) ? 0 : 1),
      pointerEvents: (listener: Listener) => (hidden.get(listener) ? "none" : "auto"),
    },
    _onMount: (node: ElementNode) => {
      let lastScrollY = window.scrollY;
      let scheduled = false;

      const applyScrollDirection = () => {
        scheduled = false;
        const currentScrollY = window.scrollY;
        const delta = currentScrollY - lastScrollY;
        if (Math.abs(delta) < sensitivity) return;
        if (delta > 0 && currentScrollY > 80) hidden.set(true);
        else if (delta < 0) hidden.set(false);
        lastScrollY = currentScrollY;
      };
      const handleScroll = () => {
        if (scheduled) return;
        scheduled = true;
        requestAnimationFrame(applyScrollDirection);
      };

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

  return element;
}

export { floatingNavbar };

← Back to Aceternity UI catalog