Navbar
A sticky navbar with inline links on desktop that folds into a drop-down panel on mobile.
FromFumadocs
"use client"
import * as React from "react"
import { GalleryVerticalEndIcon } from "lucide-react"
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { Navbar, type NavbarLink } from "./components/navbar"
const links: NavbarLink[] = [
{ title: "Product", url: "#product" },
{ title: "Docs", url: "#docs" },
{ title: "Pricing", url: "#pricing" },
{ title: "Blog", url: "#blog" },
]
export default function Page() {
const [searchOpen, setSearchOpen] = React.useState(false)
const openSearch = React.useCallback(() => setSearchOpen(true), [])
return (
<div className="flex min-h-svh flex-col">
<Navbar
logo={
<>
<div className="flex size-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
<GalleryVerticalEndIcon className="size-4" />
</div>
Acme Inc.
</>
}
links={links}
iconLinks={[
{ title: "GitHub", url: "https://github.com", icon: <GitHubIcon /> },
]}
onSearch={openSearch}
/>
<main className="mx-auto flex w-full max-w-[1400px] flex-1 flex-col gap-4 px-4 py-16">
<div className="h-12 w-2/3 max-w-md rounded-lg bg-muted" />
<div className="h-4 w-full max-w-xl rounded bg-muted/60" />
<div className="h-4 w-5/6 max-w-lg rounded bg-muted/60" />
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className="aspect-video rounded-xl bg-muted/50" />
))}
</div>
</main>
<CommandDialog open={searchOpen} onOpenChange={setSearchOpen}>
<CommandInput placeholder="Search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Pages">
{links.map((link) => (
<CommandItem key={link.url} onSelect={() => setSearchOpen(false)}>
{link.title}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
</div>
)
}
function GitHubIcon() {
return (
<svg role="img" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
)
}"use client"
import * as React from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { ChevronDownIcon, SearchIcon } from "lucide-react"
import { cn } from "cn"
import { buttonVariants } from "@/components/ui/button"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { Kbd } from "@/components/ui/kbd"
import { ThemeToggle } from "./theme-toggle"
export interface NavbarLink {
title: string
url: string
}
export interface NavbarIconLink extends NavbarLink {
icon: React.ReactNode
}
// A sticky header with inline links on desktop. Below lg the links fold into
// a panel that drops out of the bar, which rounds off and lifts while open.
export function Navbar({
logo,
links,
iconLinks = [],
onSearch,
className,
}: {
logo: React.ReactNode
links: NavbarLink[]
iconLinks?: NavbarIconLink[]
onSearch?: () => void
className?: string
}) {
const [open, setOpen] = React.useState(false)
const headerRef = React.useRef<HTMLElement>(null)
const pathname = usePathname()
React.useEffect(() => {
if (!open) return
const onClick = (event: MouseEvent) => {
if (!headerRef.current?.contains(event.target as Node)) setOpen(false)
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false)
}
window.addEventListener("click", onClick)
window.addEventListener("keydown", onKeyDown)
return () => {
window.removeEventListener("click", onClick)
window.removeEventListener("keydown", onKeyDown)
}
}, [open])
React.useEffect(() => {
if (!onSearch) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
onSearch()
}
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [onSearch])
const isActive = (url: string) =>
url.startsWith("/") &&
(pathname === url || pathname.startsWith(`${url.replace(/\/$/, "")}/`))
return (
<Collapsible
open={open}
onOpenChange={setOpen}
render={
<header
ref={headerRef}
className={cn("sticky top-0 z-40 h-14", className)}
/>
}
>
<div
className={cn(
"border-b bg-background/80 backdrop-blur-lg transition-[box-shadow,border-radius]",
open && "max-lg:rounded-b-2xl max-lg:shadow-lg"
)}
>
<div className="mx-auto flex h-14 w-full max-w-[1400px] items-center px-4">
<Link href="/" className="inline-flex items-center gap-2.5 font-semibold">
{logo}
</Link>
<ul className="flex items-center gap-2 px-6 max-sm:hidden">
{links.map((link) => (
<li key={link.url}>
<Link
href={link.url}
data-active={isActive(link.url)}
className="inline-flex items-center gap-1 p-2 text-sm text-muted-foreground transition-colors hover:text-accent-foreground data-[active=true]:text-primary"
>
{link.title}
</Link>
</li>
))}
</ul>
<div className="flex flex-1 items-center justify-end gap-1.5 max-lg:hidden">
{onSearch && (
<button
type="button"
onClick={onSearch}
className="inline-flex w-full max-w-60 items-center gap-2 rounded-full border bg-secondary/50 p-1.5 ps-2.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<SearchIcon className="size-4" />
Search
<Kbd className="ms-auto rounded-full">⌘K</Kbd>
</button>
)}
<ThemeToggle />
<IconLinks links={iconLinks} />
</div>
<div className="ms-auto -me-1.5 flex items-center lg:hidden">
{onSearch && (
<button
type="button"
aria-label="Open search"
onClick={onSearch}
className={buttonVariants({ variant: "ghost", size: "icon" })}
>
<SearchIcon />
</button>
)}
<CollapsibleTrigger
aria-label="Toggle menu"
className={cn(
buttonVariants({ variant: "ghost", size: "icon" }),
"aria-expanded:bg-transparent"
)}
>
<ChevronDownIcon
className={cn("transition-transform", open && "rotate-180")}
/>
</CollapsibleTrigger>
</div>
</div>
<CollapsibleContent className="mx-auto h-(--collapsible-panel-height) max-w-[1400px] overflow-hidden transition-[height,opacity] data-ending-style:h-0 data-ending-style:opacity-0 data-starting-style:h-0 data-starting-style:opacity-0 lg:hidden">
<div className="flex flex-col p-4 pt-2 sm:flex-row sm:items-center sm:justify-end">
{links.map((link) => (
<Link
key={link.url}
href={link.url}
data-active={isActive(link.url)}
onClick={() => setOpen(false)}
className="inline-flex items-center gap-2 py-1.5 transition-colors hover:text-foreground/50 data-[active=true]:font-medium data-[active=true]:text-primary sm:hidden"
>
{link.title}
</Link>
))}
<div className="-ms-1.5 flex flex-1 items-center gap-2 max-sm:mt-2 sm:flex-none">
<IconLinks links={iconLinks} />
<div role="separator" className="flex-1" />
<ThemeToggle />
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
)
}
function IconLinks({ links }: { links: NavbarIconLink[] }) {
if (links.length === 0) return null
return (
<ul className="flex items-center">
{links.map((link) => (
<li key={link.url}>
<a
href={link.url}
aria-label={link.title}
target="_blank"
rel="noreferrer"
className={buttonVariants({ variant: "ghost", size: "icon" })}
>
{link.icon}
</a>
</li>
))}
</ul>
)
}"use client"
import * as React from "react"
import { flushSync } from "react-dom"
import { MoonIcon, SunIcon } from "lucide-react"
import { useTheme } from "next-themes"
import { cn } from "cn"
const subscribe = () => () => {}
// A pill holding both icons; the active theme's icon sits in a filled circle.
export function ThemeToggle({ className }: { className?: string }) {
const { resolvedTheme, setTheme } = useTheme()
const mounted = React.useSyncExternalStore(subscribe, () => true, () => false)
const theme = mounted ? resolvedTheme : null
const toggle = () => {
const next = theme === "light" ? "dark" : "light"
if (document.startViewTransition) {
document.startViewTransition(() => flushSync(() => setTheme(next)))
} else {
setTheme(next)
}
}
return (
<button
type="button"
aria-label="Toggle theme"
onClick={toggle}
className={cn(
"inline-flex items-center overflow-hidden rounded-full border p-1",
className
)}
>
{(
[
["light", SunIcon],
["dark", MoonIcon],
] as const
).map(([key, Icon]) => (
<Icon
key={key}
fill="currentColor"
className={cn(
"size-6.5 rounded-full p-1.5 text-muted-foreground",
theme === key && "bg-accent text-accent-foreground"
)}
/>
))}
</button>
)
}Installation
Copy the files above into your app, then add the components they use:
npx shadcn@latest add button collapsible kbd commandThe theme toggle needs next-themes. Search is optional: pass onSearch to show the search button and bind ⌘ K.