Skip to content
goodtabs UI
BrowseBrowse components

App header

An app shell header with a breadcrumb, a search trigger, actions and an avatar.

npx shadcn@latest add https://ui.goodtabs.co/r/app-header.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/app-header

PN

Three crumbs, the search trigger and an avatar with initials.

Without search

JW

Leave out onSearch and the trigger goes with it.

With actions

MC

A notifications button and a primary action before the avatar.

Usage

import { AppHeader } from "@/components/ui/app-header"

<AppHeader
  crumbs={[{ label: "Acme", href: "/" }, { label: "Projects", href: "/projects" }, { label: "Storefront" }]}
  onSearch={() => setOpen(true)}
  actions={<Button size="sm">New</Button>}
  user={{ name: "Priya Nair", image: "/priya.jpg" }}
/>

Props

PropTypeDefaultDescription
crumbs{ label: string; href?: string }[]The path to the current page. The last crumb is the page itself and gets aria-current.
onSearch() => voidRenders the search trigger and runs when it is pressed. Omit to hide search.
searchPlaceholderstring"Search"Text inside the search trigger, and its accessible name on small screens.
actionsReactNodeRight side, before the avatar.
user{ name: string; image?: string }The signed-in person. Initials are the fallback when there is no image.
stickybooleantrueSticks to the top of its scroll container.

Source

app-header/app-header.tsx
"use client";

import * as React from "react";
import { SearchIcon } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import { cn } from "@/lib/utils";

export type AppHeaderCrumb = {
  label: string;
  /** Omit on the last crumb, or on a crumb that is a group rather than a page. */
  href?: string;
};

export type AppHeaderUser = {
  name: string;
  image?: string;
};

type AppHeaderProps = Omit<React.ComponentProps<"header">, "children"> & {
  /** The path to the current page. The last crumb is the page itself. */
  crumbs: AppHeaderCrumb[];
  /** Renders the search trigger and is called when it is pressed, usually to open a command menu. */
  onSearch?: () => void;
  /** Text inside the search trigger. */
  searchPlaceholder?: string;
  /** Right side, before the avatar: buttons, notifications, a theme toggle. */
  actions?: React.ReactNode;
  /** The signed-in person. Renders a 28px avatar with their initials as the fallback. */
  user?: AppHeaderUser;
  /** Sticks to the top of its scroll container. */
  sticky?: boolean;
};

/**
 * An app shell header: breadcrumb left, a search trigger that looks like an input,
 * then actions and the avatar on the right. 56px tall with a hairline below.
 */
export function AppHeader({
  crumbs,
  onSearch,
  searchPlaceholder = "Search",
  actions,
  user,
  sticky = true,
  className,
  ...props
}: AppHeaderProps) {
  return (
    <header
      data-slot="app-header"
      className={cn(
        "z-20 flex h-14 w-full items-center gap-3 border-b bg-background px-4",
        sticky && "sticky top-0",
        className,
      )}
      {...props}
    >
      <nav aria-label="Breadcrumb" className="min-w-0 flex-1">
        <ol className="flex min-w-0 items-center text-sm">
          {crumbs.map((crumb, i) => {
            const last = i === crumbs.length - 1;
            return (
              <li key={`${crumb.label}-${i}`} className="flex min-w-0 items-center">
                {i > 0 && (
                  <span aria-hidden="true" className="mx-1.5 shrink-0 text-muted-foreground/60">
                    /
                  </span>
                )}
                {crumb.href && !last ? (
                  <a
                    href={crumb.href}
                    className="truncate rounded-sm text-muted-foreground transition-colors duration-100 ease-out hover:text-foreground"
                  >
                    {crumb.label}
                  </a>
                ) : (
                  <span
                    aria-current={last ? "page" : undefined}
                    className={cn("truncate", last ? "font-medium text-foreground" : "text-muted-foreground")}
                  >
                    {crumb.label}
                  </span>
                )}
              </li>
            );
          })}
        </ol>
      </nav>

      {onSearch && (
        <>
          <button
            type="button"
            onClick={onSearch}
            className="hidden h-8 w-56 shrink-0 items-center gap-2 rounded-md border bg-background pr-1 pl-2.5 text-sm text-muted-foreground shadow-xs transition-colors duration-100 ease-out outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 sm:flex"
          >
            <SearchIcon className="size-4 shrink-0" aria-hidden="true" />
            <span className="flex-1 truncate text-left">{searchPlaceholder}</span>
            <Kbd aria-hidden="true">⌘K</Kbd>
          </button>
          <Button
            type="button"
            variant="ghost"
            size="icon-sm"
            aria-label={searchPlaceholder}
            onClick={onSearch}
            className="rounded-full sm:hidden"
          >
            <SearchIcon aria-hidden="true" />
          </Button>
        </>
      )}

      {(actions || user) && (
        <div className="flex shrink-0 items-center gap-2">
          {actions}
          {user && (
            <Avatar role="img" aria-label={user.name} className="size-7">
              {user.image && <AvatarImage src={user.image} alt="" />}
              <AvatarFallback className="text-xs text-foreground">{initials(user.name)}</AvatarFallback>
            </Avatar>
          )}
        </div>
      )}
    </header>
  );
}

function initials(name: string) {
  return name
    .split(/\s+/)
    .filter(Boolean)
    .slice(0, 2)
    .map((part) => part[0]?.toUpperCase() ?? "")
    .join("");
}

For AI agents

Hand your agent this page, the markdown at /components/app-header/prompt.md, or the Copy AI prompt button above.

When to use

  • The top bar of a signed-in app next to a sidebar
  • When the page location should read as a path and search opens a command menu
  • Dashboards, admin panels and settings areas

When not to use

  • For a marketing site, use the navbar
  • When the search is a real text field rather than a trigger: use search-input instead
  • On pages with no hierarchy, where a plain title would do

Agent notes

  • It is a client component because the search trigger takes onSearch; a demo or page that passes it must be "use client"
  • The trigger only looks like an input. Wire onSearch to your command menu and bind Cmd K yourself
  • Crumbs are plain anchors; wrap in next/link yourself if you need prefetching
  • Under sm the search collapses to an icon button named after searchPlaceholder