Domphy

stickyBanner

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

Implementation notes

hideOnScroll defaults to false per spec. When enabled, a one-way latch (hidden state only ever flips to true past the documented ~40px threshold and is never reset) matches the documented 'no reappear on scroll up' behavior exactly, distinguishing it from floatingNavbar's two-way direction toggle. Default accent color uses the theme's own 'primary' family rather than a hardcoded purple/violet hex, per the spec's explicit note that Aceternity's purple is just example styling, not a documented default.

Status: ported · Reference: Aceternity UI original

// Aceternity "Sticky Banner" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A slim,
// accent-colored announcement strip pinned to the very top of the page. With
// `hideOnScroll` on, it slides away for good once the scroll offset passes a
// small threshold (~40px) — a one-way, scroll-triggered dismissal, not a
// direction-based toggle (that's floatingNavbar).

import type { DomphyElement, ElementNode, Listener } from "@domphy/core";
import { toState } from "@domphy/core";
import { link, paragraph, strong } from "@domphy/ui";
import { type ThemeColor, themeColor, themeDensity, themeSpacing } from "@domphy/theme";

export interface StickyBannerProps {
  /** Announcement message text. Defaults to a generic release-note demo string. */
  message?: string;
  /** Inline call-to-action link label. Defaults to "Read announcement". */
  ctaLabel?: string;
  ctaHref?: string;
  onCtaClick?: (event: MouseEvent) => void;
  /**
   * Enables the auto-hide-on-scroll behavior: once scrolled past ~40px, the
   * banner slides up and stays hidden (it does not reappear on scrolling
   * back up). Defaults to false — the banner stays put like an ordinary
   * sticky header unless a caller opts in.
   */
  hideOnScroll?: boolean;
  /**
   * Background/accent color family. The saturated purple seen in Aceternity's
   * own demo is just example styling, not a documented default — this block
   * defaults to the theme's own "primary" family instead of a hardcoded hue.
   */
  accentColor?: ThemeColor;
}

const SCROLL_HIDE_THRESHOLD_PX = 40;

/**
 * A slim, accent-colored announcement banner pinned to the top of the page.
 * Call with no arguments for a working demo message + CTA link.
 */
function stickyBanner(props: StickyBannerProps = {}): DomphyElement<"div"> {
  const message =
    props.message ?? "New: real-time collaboration is now available on every plan.";
  const ctaLabel = props.ctaLabel ?? "Read announcement";
  const ctaHref = props.ctaHref ?? "#";
  const hideOnScroll = props.hideOnScroll ?? false;
  const accentColor = props.accentColor ?? "primary";

  const hidden = toState(false);

  const ctaLink: DomphyElement<"a"> = {
    a: [{ strong: ctaLabel, $: [strong({ color: accentColor })] } as DomphyElement<"strong">],
    href: ctaHref,
    $: [link({ color: accentColor, accentColor })],
  };
  if (props.onCtaClick) ctaLink.onClick = props.onCtaClick;

  const element: DomphyElement<"div"> = {
    div: [{ p: message, $: [paragraph({ color: accentColor })], style: { margin: "0" } }, ctaLink],
    role: "note",
    dataTone: "shift-15",
    style: {
      position: "sticky",
      top: "0",
      zIndex: 50,
      width: "100%",
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
      flexWrap: "wrap",
      textAlign: "center",
      gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 2),
      paddingBlock: (listener: Listener) => themeSpacing(themeDensity(listener) * 2),
      paddingInline: (listener: Listener) => themeSpacing(themeDensity(listener) * 4),
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit", accentColor),
      color: (listener: Listener) => themeColor(listener, "shift-9", accentColor),
      transition: "transform 200ms ease, opacity 200ms ease",
      transform: (listener: Listener) =>
        hideOnScroll && hidden.get(listener)
          ? `translateY(calc(-100% - ${themeSpacing(4)}))`
          : "translateY(0)",
      opacity: (listener: Listener) => (hideOnScroll && hidden.get(listener) ? 0 : 1),
      pointerEvents: (listener: Listener) => (hideOnScroll && hidden.get(listener) ? "none" : "auto"),
    },
    _onMount: (node: ElementNode) => {
      if (!hideOnScroll) return;

      let scheduled = false;
      const checkScrollThreshold = () => {
        scheduled = false;
        // One-way: once past the threshold it latches hidden and never
        // re-checks (matches the documented behavior — no reappear on
        // scrolling back up).
        if (window.scrollY > SCROLL_HIDE_THRESHOLD_PX) hidden.set(true);
      };
      const handleScroll = () => {
        if (scheduled || hidden.get()) return;
        scheduled = true;
        requestAnimationFrame(checkScrollThreshold);
      };

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

  return element;
}

export { stickyBanner };

← Back to Aceternity UI catalog