Client store header with a centered mega menu of link-only columns, search and bag count, collapsing to a left Sheet with accordion categories when narrow. No announcement bar.
"use client"
import {
BasketIcon,
CircleIcon,
ListIcon,
MagnifyingGlassIcon,
} from "@phosphor-icons/react"
import { cn } from "cn"
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion"
import { Button, buttonVariants } from "@/components/ui/button"
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
// The copy: change text here, not in the markup.
const COPY = {
brand: "Orbcore",
home: "Orbcore home",
navigation: "Store",
mobileNavigation: "Store menu",
menu: "Browse the shop",
openMenu: "Open menu",
pages: "Pages",
search: "Search",
cart: "items in bag, view bag",
moreEditions: "More editions",
shopAll: "Shop all",
} as const
const NAVIGATION = {
categories: [
{
id: "prints",
name: "Prints",
href: "#",
editions: {
id: "editions",
name: "Editions",
href: "#",
columns: [
[
{ name: "Color fields", href: "#" },
{ name: "Line work", href: "#" },
{ name: "Mezzotints", href: "#" },
{ name: "Monotypes", href: "#" },
{ name: "Posters", href: "#" },
{ name: "Small formats", href: "#" },
],
[
{ name: "Signed editions", href: "#" },
{ name: "Diptychs", href: "#" },
{ name: "Large formats", href: "#" },
{ name: "Sample packs", href: "#" },
{ name: "Browse all", href: "#" },
],
],
},
groups: [
{
id: "framing",
name: "Framing",
href: "#",
links: [
{ name: "Frames", href: "#" },
{ name: "Mounts", href: "#" },
{ name: "Glass", href: "#" },
{ name: "Hanging kits", href: "#" },
{ name: "Portfolios", href: "#" },
{ name: "Browse all", href: "#" },
],
},
{
id: "collections",
name: "Collections",
href: "#",
links: [
{ name: "New arrivals", href: "#" },
{ name: "Sale", href: "#" },
{ name: "Core", href: "#" },
{ name: "Signed editions", href: "#" },
],
},
],
},
{
id: "originals",
name: "Originals",
href: "#",
editions: {
id: "works",
name: "Works",
href: "#",
columns: [
[
{ name: "Drawings", href: "#" },
{ name: "Paintings", href: "#" },
{ name: "Studies", href: "#" },
{ name: "Diptychs", href: "#" },
{ name: "Sketchbooks", href: "#" },
{ name: "Collages", href: "#" },
],
[
{ name: "Commissions", href: "#" },
{ name: "Archive", href: "#" },
{ name: "Large formats", href: "#" },
{ name: "Browse all", href: "#" },
],
],
},
groups: [
{
id: "framing",
name: "Framing",
href: "#",
links: [
{ name: "Frames", href: "#" },
{ name: "Mounts", href: "#" },
{ name: "Glass", href: "#" },
{ name: "Hanging kits", href: "#" },
{ name: "Browse all", href: "#" },
],
},
{
id: "collections",
name: "Collections",
href: "#",
links: [
{ name: "Just added", href: "#" },
{ name: "Clearance", href: "#" },
{ name: "Studio picks", href: "#" },
],
},
],
},
],
pages: [
{ name: "Studio", href: "#" },
{ name: "Stockists", href: "#" },
],
} as const
type StoreCategory = (typeof NAVIGATION.categories)[number]
// The phone drawer lists each group's links in one column.
function groupsOf(category: StoreCategory) {
return [
{
id: category.editions.id,
name: category.editions.name,
href: category.editions.href,
links: category.editions.columns.flat(),
},
...category.groups,
]
}
const LINK =
"inline-flex min-h-6 items-center rounded-sm text-sm underline-offset-4 outline-none hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
const MUTED = cn(LINK, "text-muted-foreground hover:text-foreground")
const ROW = cn(LINK, "min-h-11 w-full text-base")
type Link = { name: string; href: string }
function LinkList({
links,
label,
}: {
links: readonly Link[]
label?: string
}) {
return (
<ul role="list" aria-label={label} className="grid content-start gap-2">
{links.map((link) => (
<li key={link.name}>
<a href={link.href} className={MUTED}>
{link.name}
</a>
</li>
))}
</ul>
)
}
/**
* A one-row shop header with the logo, the store navigation and links for
* search and the bag. On wide screens the navigation sits in the middle,
* and each category opens a menu of text links: a two-column list under
* one heading and two smaller groups beside it. When the header is
* narrower than 64rem, a menu button opens a sheet from the left where
* each category folds open in an accordion.
*
* The words live in `COPY` and the categories and links in `NAVIGATION`,
* at the top of the file. The links point to `#`.
*/
export default function StoreHeader02({
cartCount = 0,
}: {
/** The number shown beside the bag icon. Defaults to `0`. */
cartCount?: number
}) {
return (
<header className="@container/shop-nav relative w-full border-b border-border bg-background text-foreground">
<div className="mx-auto flex min-h-16 w-full max-w-7xl flex-wrap items-center gap-x-4 px-6 lg:px-10">
<div className="-ml-2 flex flex-1 items-center gap-x-1 @5xl/shop-nav:hidden">
<Sheet>
<SheetTrigger
render={
<Button
variant="ghost"
size="icon"
aria-label={COPY.openMenu}
/>
}
>
<ListIcon weight="light" aria-hidden="true" className="size-5" />
</SheetTrigger>
<SheetContent side="left" className="overflow-y-auto">
<SheetHeader>
<SheetTitle>{COPY.menu}</SheetTitle>
</SheetHeader>
<nav
aria-label={COPY.mobileNavigation}
className="grid gap-4 pb-6"
>
<Accordion className="mx-6 w-auto">
{NAVIGATION.categories.map((category) => (
<AccordionItem key={category.id} value={category.id}>
<AccordionTrigger className="focus-visible:ring-3 focus-visible:ring-ring/30 focus-visible:ring-inset">
{category.name}
</AccordionTrigger>
<AccordionContent className="grid gap-6">
<a href={category.href} className={ROW}>
{COPY.shopAll} {category.name}
</a>
{groupsOf(category).map((group) => (
<div
key={group.id}
className="grid min-w-0 content-start gap-4"
>
<a
href={group.href}
className={cn(LINK, "font-medium")}
>
{group.name}
</a>
<LinkList links={group.links} />
</div>
))}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
<ul
role="list"
aria-label={COPY.pages}
className="grid border-t border-border px-6 pt-4"
>
{NAVIGATION.pages.map((page) => (
<li key={page.name}>
<a href={page.href} className={ROW}>
{page.name}
</a>
</li>
))}
</ul>
</nav>
</SheetContent>
</Sheet>
</div>
<a
href="#"
aria-label={COPY.home}
className="inline-flex w-fit min-w-0 items-center gap-2 rounded-sm text-xl font-semibold focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring @5xl/shop-nav:flex-1"
>
<CircleIcon
weight="light"
aria-hidden="true"
className="size-5 shrink-0"
/>
<span className="hidden @md/shop-nav:inline">{COPY.brand}</span>
</a>
<NavigationMenu
aria-label={COPY.navigation}
delay={250}
closeDelay={150}
className="hidden @5xl/shop-nav:flex"
>
<NavigationMenuList>
{NAVIGATION.categories.map((category) => (
<NavigationMenuItem key={category.id}>
<NavigationMenuTrigger>{category.name}</NavigationMenuTrigger>
<NavigationMenuContent>
<div className="grid w-4xl grid-cols-4 gap-x-8 gap-y-10 p-4">
<div className="col-span-2 grid min-w-0 content-start gap-4">
<a
href={category.editions.href}
className={cn(LINK, "font-medium")}
>
{category.editions.name}
</a>
<div className="grid grid-cols-2 gap-x-4">
<LinkList links={category.editions.columns[0]} />
<LinkList
links={category.editions.columns[1]}
label={COPY.moreEditions}
/>
</div>
</div>
{category.groups.map((group) => (
<div
key={group.id}
className="grid min-w-0 content-start gap-4"
>
<a
href={group.href}
className={cn(LINK, "font-medium")}
>
{group.name}
</a>
<LinkList links={group.links} />
</div>
))}
</div>
</NavigationMenuContent>
</NavigationMenuItem>
))}
{NAVIGATION.pages.map((page) => (
<NavigationMenuItem key={page.name}>
<NavigationMenuLink
href={page.href}
className={navigationMenuTriggerStyle()}
>
{page.name}
</NavigationMenuLink>
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
<div className="flex flex-1 items-center justify-end gap-x-1">
<a
href="#"
aria-label={COPY.search}
className={buttonVariants({ variant: "ghost", size: "icon" })}
>
<MagnifyingGlassIcon
weight="light"
aria-hidden="true"
className="size-5"
/>
</a>
<a
href="#"
className={cn(buttonVariants({ variant: "ghost" }), "gap-1.5 px-2")}
>
<BasketIcon weight="light" aria-hidden="true" className="size-5" />
<span className="text-sm tabular-nums">{cartCount}</span>
<span className="sr-only">{COPY.cart}</span>
</a>
</div>
</div>
</header>
)
}
pnpm dlx shadcn add @01orbital/store-header-02npx shadcn add @01orbital/store-header-02yarn shadcn add @01orbital/store-header-02bunx --bun shadcn add @01orbital/store-header-02Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.
It lands at components/blocks/store/store-header-02/index.tsx, as source you own and edit.
import StoreHeader02 from "@/components/blocks/store/store-header-02"
export default function Example() {
return <StoreHeader02 />
}Prop
Type
npm: @phosphor-icons/react, cn. The CLI installs them for you.
Registry pieces it installs with it:
accordionbuttonnavigation-menusheetMini Cart Popover
Compact cart Popover opened from a bag-count button, listing lines with options and quantity, a subtotal, Checkout and View bag links, and an empty state. Imports Base UI Popover.
Product Grid with Load More
Client grid of art print tiles with star ratings and review counts that opens with eight items and a Load more button, moving focus to the first new tile.