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
| Prop | Type | Default | Description |
|---|---|---|---|
| crumbs | { label: string; href?: string }[] | The path to the current page. The last crumb is the page itself and gets aria-current. | |
| onSearch | () => void | Renders the search trigger and runs when it is pressed. Omit to hide search. | |
| searchPlaceholder | string | "Search" | Text inside the search trigger, and its accessible name on small screens. |
| actions | ReactNode | Right side, before the avatar. | |
| user | { name: string; image?: string } | The signed-in person. Initials are the fallback when there is no image. | |
| sticky | boolean | true | Sticks 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