HextorUI
block

App Header

Application header: brand, nav with active state, a slot for auth or account controls, and a theme toggle.

npx shadcn@latest add @hextor/app-header

Source

"use client";

import type { ReactNode } from "react";
import Link from "next/link";
import { useTheme } from "next-themes";
import { Moon, Sun } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/registry/hextor/lib/utils";
import {
  resolveLabels,
  type AppHeaderLabels,
} from "@/registry/hextor/components/hextor/app-header/labels";
import type {
  AppHeaderBrand,
  AppHeaderNavItem,
} from "@/registry/hextor/components/hextor/app-header/types";

/**
 * A reusable application header: brand mark, a nav row, and a slot for
 * whatever identity affordance the host app wants (auth buttons, an avatar
 * menu, nothing at all). The upstream this is styled after hardcoded all
 * three — HextorCo's brand string, HextorCo's nav destinations, and a
 * three-way Clerk/dev-session/demo-mode branch for the trailing slot — none
 * of which survives contact with a second consumer, so all three arrive as
 * props instead.
 */
export function AppHeader({
  brand,
  nav = [],
  activeHref,
  actions,
  themeToggle = true,
  sticky = true,
  className,
  labels,
}: {
  brand: AppHeaderBrand;
  nav?: AppHeaderNavItem[];
  /**
   * Which nav item reads as current. A prop rather than a `usePathname()`
   * call: this block ships into whatever router (or router-less shell) hosts
   * it, and a hook call here would only work inside the one router it was
   * authored against. The caller already knows the active route.
   */
  activeHref?: string;
  /** Auth buttons, an avatar menu, anything — the header only reserves the slot. */
  actions?: ReactNode;
  themeToggle?: boolean;
  /**
   * Pins the header and blurs what scrolls under it. Default on because that is
   * what an app header is usually for, but a prop rather than a fixed class
   * because it is not free: a sticky header inside an already-scrolling panel
   * pins to the wrong ancestor, and `backdrop-blur` forces a compositing layer
   * that is wasted on a header nothing scrolls beneath.
   */
  sticky?: boolean;
  className?: string;
  labels?: Partial<AppHeaderLabels>;
}) {
  const { resolvedTheme, setTheme } = useTheme();
  const copy = resolveLabels(labels);

  return (
    <header
      className={cn(
        "flex h-12 shrink-0 items-center gap-3 border-b bg-background px-3",
        sticky &&
          "sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60",
        className
      )}
    >
      <Link href={brand.href} className="flex items-center gap-2">
        {brand.icon ? <brand.icon className="size-4" /> : null}
        <span className="text-sm font-semibold tracking-tight">{brand.label}</span>
      </Link>

      {nav.length > 0 && (
        <nav className="flex items-center gap-1">
          {nav.map((item) => {
            const isActive = activeHref === item.href;
            const content = (
              <>
                {item.icon ? <item.icon className="size-3.5" /> : null}
                <span className="hidden sm:inline">{item.label}</span>
                {item.badge ? (
                  <Badge variant="outline" className="h-4 px-1 text-[9px] font-normal">
                    {item.badge}
                  </Badge>
                ) : null}
              </>
            );
            return (
              <Button
                key={item.href}
                variant="ghost"
                size="sm"
                aria-current={isActive ? "page" : undefined}
                className={cn("text-muted-foreground", isActive && "text-foreground")}
                // Base UI has no `asChild`; `render` takes the replacement element
                // directly and that element's own children win over the trigger's.
                render={
                  item.external ? (
                    <a href={item.href} target="_blank" rel="noreferrer">
                      {content}
                    </a>
                  ) : (
                    <Link href={item.href}>{content}</Link>
                  )
                }
              />
            );
          })}
        </nav>
      )}

      <div className="ml-auto flex items-center gap-1">
        {actions}

        {themeToggle && (
          <Tooltip>
            <TooltipTrigger
              render={
                <Button
                  variant="ghost"
                  size="icon"
                  className="size-8"
                  onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
                >
                  {/*
                    CSS-driven swap, not a mounted-state gate: both icons and both
                    sr-only names render identically on server and client, and only
                    the `.dark` class — already on <html> pre-hydration via
                    next-themes' injected script — decides which half is visible.
                    `resolvedTheme` is only read inside the click handler, which
                    never runs during SSR, so there is no prop to mismatch.
                  */}
                  <Sun className="size-4 dark:hidden" aria-hidden />
                  <Moon className="hidden size-4 dark:block" aria-hidden />
                  <span className="sr-only dark:hidden">{copy.themeToggle.switchToDark}</span>
                  <span className="sr-only hidden dark:inline">
                    {copy.themeToggle.switchToLight}
                  </span>
                </Button>
              }
            />
            <TooltipContent>
              <span className="dark:hidden">{copy.themeToggle.switchToDark}</span>
              <span className="hidden dark:inline">{copy.themeToggle.switchToLight}</span>
            </TooltipContent>
          </Tooltip>
        )}
      </div>
    </header>
  );
}

Docs

Everything app-specific arrives as props. activeHref is a prop rather than a usePathname() call on purpose: a block that reaches for a router hook only works inside the router it was written against, and the caller already knows the current route. Auth controls go in actions — the header reserves the slot and has no opinion about what fills it.

The theme toggle swaps icons with Tailwind's dark: variant rather than by reading resolvedTheme during render. next-themes sets the class before hydration, so a JS-driven swap would flash the wrong icon and warn about a hydration mismatch; the accessible name switches the same CSS-only way.

sticky defaults on but is a prop, not a fixed class: a sticky header inside an already-scrolling panel pins to the wrong ancestor, and backdrop-blur forces a compositing layer that is wasted when nothing scrolls beneath.

Renders a <Tooltip>, so a <TooltipProvider> must be mounted somewhere above it — one per app, not one per component.

Dependencies

lucide-reactnext-themesbuttonbadgetooltip