Navigation

Floating Navbar

A header that hides on scroll-down and reappears on scroll-up, giving long reading surfaces the vertical space back without losing the nav.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export interface FloatingNavbarProps extends React.HTMLAttributes<HTMLElement> {
  /** The consumer's own nav content — logo, links, actions. This component is a shell only. */
  children: React.ReactNode
  /** Scrollable container to track; omit to track the window. */
  target?: React.RefObject<HTMLElement | null>
  /** Top-of-page exemption band in px — while scroll position is within it, the bar always stays visible. */
  threshold?: number
}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/floating-navbar.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "FloatingNavbar" component (no animation
library — one CSS transform plus a transition).

Contract
- Export a forwardRef<HTMLElement, FloatingNavbarProps> rendering a <header>.
- children: ReactNode — the consumer's own nav content (logo, links, actions).
  This component is a shell only; it renders nothing of its own besides children.
- target?: RefObject<HTMLElement | null> — scrollable container to track;
  omit to track the window.
- threshold?: number (default 80) — top-of-page exemption band in px. While
  the tracked scroll position is within this band, the bar always stays
  visible, regardless of direction.
- Standard className passthrough, merged via cn(), plus the rest of
  React.HTMLAttributes<HTMLElement> spread onto the root.

Behavior
- Track direction with a ref-held "last scrollTop", not React state — the
  scroll handler runs at high frequency and must not re-render on every tick.
- Attach one passive scroll listener, throttled to one direction check per
  animation frame via requestAnimationFrame (guard re-entrancy with a raf id,
  cancel it on unmount).
- On every tick: read the current scrollTop (container or window.scrollY). If
  it's <= threshold, force the bar visible and reset the reference point —
  no direction math inside the exemption band. Otherwise compute
  delta = current - lastScrollTop. If |delta| < 8px, ignore it entirely (do
  not update the reference point either) — this is the jitter guard against
  trackpad/momentum micro-scrolls. Otherwise set hidden = delta > 0 (scrolling
  down) and update the reference point to the current value.
- Listen on both the container (if a target is given) and window — a target
  element may not be scrollable yet at mount time, so both stay wired for the
  component's lifetime, same lesson as this library's Scroll Progress /
  Scroll to Top.
- Visibility is a single low-frequency boolean state, flipped only inside the
  rAF callback, never synchronously inside the effect body.
- Focus safety: a keyboard user tabbing into the nav must never have it slide
  out from under their focus. Add a `focus-within:translate-y-0` class that
  always wins over the hidden transform, so any focused descendant forces the
  bar back on screen regardless of scroll state.
- Clean up: remove every scroll listener and cancel any pending
  requestAnimationFrame on unmount or when target/threshold change.

Rendering & styling
- Semantic tokens only: bg-background/80 + backdrop-blur for the glass
  surface, border-b for the seam, no hardcoded colors.
- Root is fixed inset-x-0 top-0 z-40 by default; consumers embedding it inside
  a scroll-tracked container (rather than the window) override the position
  utility via className (e.g. "absolute inset-x-0 top-0") so it stays pinned
  to that container instead of escaping to the viewport — cn()'s class-merge
  lets a later className win over the default position class.
- Hide/show is transition-transform duration-300 plus -translate-y-full when
  hidden; motion-reduce:transition-none removes the animation without
  removing the functionality (it still snaps hidden/visible).
- No internal padding, height, or layout opinions — those live entirely in
  the consumer's children, this component only owns positioning + the
  hide/reveal transform + the glass surface.

Customization levers
- Top exemption band: threshold controls how much top-of-page scroll is
  exempt from hiding — raise it for pages with a tall hero, drop it to 0 for
  an immediately reactive bar.
- Jitter tolerance: the 8px delta guard is the debounce threshold — tune it
  up for touch devices with jumpier momentum scroll, down for a snappier feel.
- Reveal style: swap the full -translate-y-full hide for a "peek" mode (e.g.
  translate-y-[-70%] so a sliver stays visible) by changing that one class.
- Surface: bg-background/80 + backdrop-blur can drop to a flat bg-background
  for a non-glass look, or pick up a shadow on the visible state.
- Pairs with a use-scroll-direction hook when other elements on the page need
  the same up/down signal — this component keeps its own inline copy of the
  direction logic since it doesn't need to share it.

Concepts

  • Top-of-page exemption bandthreshold carves out a zone near the top where the bar never hides, so the very first scroll tick (often noisy on mobile) can't flicker it away before the reader has even left the hero.
  • Ref-based direction tracking — the "last scrollTop" lives in a ref, not state, so the high-frequency scroll math costs zero re-renders; only the low-frequency hidden/visible boolean ever triggers one.
  • Direction jitter guard — sub-8px deltas are ignored and don't even reset the reference point, so trackpad momentum and micro-adjustments can't flip the bar back and forth on their own.
  • Focus-safe hidingfocus-within:translate-y-0 overrides the hidden transform unconditionally, so a keyboard user tabbing into the nav can never have it slide away mid-interaction.
  • Shell, not a layout — the component owns positioning and the hide/reveal transform only; height, padding, and every visual choice inside the bar belong entirely to the children it wraps.
  • Position override via class-merge — the default fixed positioning is meant to be swapped for absolute when the bar needs to stay pinned inside a scroll-tracked container instead of the viewport (see the sensitive demo variant), which is exactly what cn()'s merge behavior is for.

On This Page