COMPONENT / terminal-navbar
Terminal Navbar_
Primary nav bar with a display wordmark, mono links split by rules, a [ BRACKETED ] current link and an actions slot for a ⌘K hint or a solid --signal CTA. Folds into a MENU toggle panel when narrow. Requires @cybr/tokens.
Install
1. Map the
@cybrnamespace, once per project. Add this inside yourcomponents.json, or into its existing"registries"object. Nocomponents.jsonyet? Runnpx shadcn@latest initfirst to create one."registries": { "@cybr": "https://cybr-ui.com/r/{name}.json" }2. Add components:
$ npx shadcn@latest add @cybr/terminal-navbar
If shadcn reports Unknown registry "@cybr", step 1 is missing from that project's components.json.
Preview
"use client";
import { useEffect, useId, useRef, useState, type ComponentProps, type ReactNode } from "react";
import { cn } from "@/lib/utils";
export type TerminalNavbarLink = { label: string; href: string; current?: boolean };
const focus =
"focus-visible:outline-(length:--focus-width) focus-visible:outline-offset-[-3px] focus-visible:outline-solid focus-visible:outline-signal";
// Primary nav bar: display wordmark, mono links split by rules, and an actions
// slot on the right. The current link shows as [ LABEL ] in --signal; the
// brackets are aria-hidden so its name stays LABEL. `children` renders after
// the links, for extra triggers such as a mega menu.
//
// The nav is a container: below @3xl (48rem) of its own width, the links,
// children and actions fold into a panel under the bar, opened by a MENU
// disclosure button. They render once and reflow, so nothing is duplicated.
export function TerminalNavbar({
brand,
brandHref = "/",
links,
actions,
children,
"aria-label": ariaLabel = "Primary",
className,
onKeyDown,
onPointerDown,
...props
}: ComponentProps<"nav"> & {
brand?: ReactNode;
brandHref?: string;
links: TerminalNavbarLink[];
actions?: ReactNode;
}) {
const [open, setOpen] = useState(false);
const panelId = useId();
const navRef = useRef<HTMLElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
// Set by React's pointerdown, which also bubbles out of portaled children
// such as a mega menu popup, so presses there do not count as outside.
const pressedInside = useRef(false);
useEffect(() => {
if (!open) return;
const onDocumentPointerDown = () => {
if (!pressedInside.current) setOpen(false);
pressedInside.current = false;
};
document.addEventListener("pointerdown", onDocumentPointerDown);
return () => document.removeEventListener("pointerdown", onDocumentPointerDown);
}, [open]);
return (
<nav
ref={navRef}
aria-label={ariaLabel}
className={cn("@container relative flex h-15.5 w-full items-stretch bg-ground-deep font-mono", className)}
onPointerDown={(event) => {
pressedInside.current = true;
onPointerDown?.(event);
}}
onKeyDown={(event) => {
onKeyDown?.(event);
// Only for keys pressed in the nav's own DOM, not in a portaled popup,
// which handles its own Escape.
if (open && event.key === "Escape" && navRef.current?.contains(event.target as Node)) {
setOpen(false);
buttonRef.current?.focus();
}
}}
{...props}
>
<a
href={brandHref}
className={cn(
"flex shrink-0 items-center px-4 font-display text-[1.375rem] tracking-[0.02em] whitespace-nowrap text-ink @xs:px-6 @3xl:px-10",
focus,
)}
>
{brand ?? (
<>
CYBR<span className="text-signal">{"//"}</span>UI
</>
)}
</a>
<button
ref={buttonRef}
type="button"
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen((value) => !value)}
className={cn(
"ml-auto flex cursor-pointer items-center gap-3 border-0 border-l-(length:--border-base) border-line bg-transparent px-4 text-[0.8125rem] font-bold tracking-label text-ink hover:bg-bg aria-expanded:bg-bg @3xl:hidden",
focus,
)}
>
<span aria-hidden="true" className="relative block h-3 w-4.5">
<span className={cn("absolute inset-x-0 top-0 h-0.5 bg-current", open && "top-1.25 rotate-45")} />
<span className={cn("absolute inset-x-0 top-1.25 h-0.5 bg-current", open && "hidden")} />
<span className={cn("absolute inset-x-0 bottom-0 h-0.5 bg-current", open && "bottom-1.25 -rotate-45")} />
</span>
{/* The word hides under @xs (20rem) to fit; it stays the button's name. */}
<span className="sr-only @xs:not-sr-only">MENU</span>
</button>
<div
id={panelId}
onClick={(event) => {
if ((event.target as Element).closest("a")) setOpen(false);
}}
className={cn(
"absolute inset-x-0 top-full z-40 hidden flex-col border-y-(length:--border-base) border-line bg-ground-deep",
"@3xl:static @3xl:z-auto @3xl:flex @3xl:min-w-0 @3xl:flex-1 @3xl:flex-row @3xl:border-0",
open && "flex",
)}
>
<div className="flex flex-col @3xl:min-w-0 @3xl:flex-1 @3xl:flex-row @3xl:overflow-x-auto @3xl:[scrollbar-width:none]">
<ul className="m-0 flex list-none flex-col p-0 @3xl:flex-row">
{links.map((link) => (
<li key={link.href} className="flex">
<a
href={link.href}
aria-current={link.current ? "page" : undefined}
className={cn(
"flex min-h-12 w-full items-center border-b-(length:--border-hair) border-line px-6 text-[0.8125rem] font-bold tracking-label whitespace-nowrap hover:bg-bg hover:text-ink",
"@3xl:min-h-0 @3xl:border-b-0 @3xl:border-l-(length:--border-base) @3xl:px-5",
link.current ? "text-signal" : "text-label",
focus,
)}
>
{link.current && <span aria-hidden="true">[ </span>}
{link.label}
{link.current && <span aria-hidden="true"> ]</span>}
</a>
</li>
))}
</ul>
{children}
</div>
{actions && <div className="flex min-h-12 items-stretch @3xl:shrink-0">{actions}</div>}
</div>
</nav>
);
}
// Solid --signal call to action for the actions slot. Hover and focus turn it
// --ink so the --signal focus outline stays visible.
export function TerminalNavbarCta({ className, ...props }: ComponentProps<"a">) {
return (
<a
className={cn(
"flex grow items-center border-l-(length:--border-base) border-ground-deep bg-signal px-6 text-[0.8125rem] font-bold tracking-label whitespace-nowrap text-ground-deep hover:bg-ink focus-visible:bg-ink @3xl:grow-0",
focus,
className,
)}
{...props}
/>
);
}
Repo source. The shadcn CLI rewrites import aliases to match your project.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| links | { label: string; href: string; current?: boolean }[] | — | Links in order. The one with current: true gets aria-current="page" and shows as [ LABEL ] in --signal. |
| brand | ReactNode | CYBR//UI | Wordmark content, set in the display font. |
| brandHref | string | "/" | Where the wordmark links to. |
| actions | ReactNode | — | Right-hand slot that never scrolls, for a ⌘K hint or a TerminalNavbarCta. Moves to the bottom of the MENU panel when the navbar is narrow. |
| children | ReactNode | — | Rendered after the links, for extra triggers such as a Brutal Mega Menu. Moves into the MENU panel with the links when the navbar is narrow. |
| aria-label | string | "Primary" | Name of the navigation landmark. Give each <nav> on a page a different name. |
| className | string | — | Extra classes, merged after the defaults with cn(). |
| ...props | ComponentProps<"nav"> | — | Every other native <nav> attribute is forwarded. Your onKeyDown and onPointerDown run before the menu's own handling. |
| TerminalNavbarCta | ComponentProps<"a"> | — | Solid --signal link styled for the actions slot. Pass href and children. |
Usage
Needs the stylesheet from @cybr/tokens. After the install, import it in app/globals.css:
@import "tailwindcss";
@import "../styles/cybr-tokens.css";
import { TerminalNavbar, TerminalNavbarCta } from "@/components/ui/terminal-navbar";
export function SiteNav() {
return (
<TerminalNavbar
links={[
{ label: "INDEX", href: "/" },
{ label: "DOCS", href: "/docs", current: true },
]}
actions={<TerminalNavbarCta href="/install">INSTALL →</TerminalNavbarCta>}
/>
);
}
Links are plain <a> elements. To use client-side routing, edit the installed source and swap them for your framework's link component.
States
- Idle link:
--labelmono text between 2px--linerules. - Hover: the link fills with
--bgand the text turns--ink. - Current:
[ LABEL ]in--signal. - Focus-visible: a 3px solid
--signaloutline inset inside the link, wordmark or CTA. The CTA turns--inkon hover and focus so the outline shows against it. - Narrow (under 48rem of navbar width): the bar shows only the wordmark and a
MENUbutton with a 3-bar icon. Under 20rem the word MENU is visually hidden and only the icon shows; it is still the button's name. The breakpoint is a container query on the navbar's own width, not the viewport, so it behaves the same in a sidebar layout or a full-width header. - Menu open: the button fills with
--bgand its icon becomes an ×. A panel drops over the page below the bar on--ground-deep, with the links as full-width 48px rows, then anychildren, then the actions. Opening and closing are instant. - Menu closes when you press the button again, press Escape, follow a link in the panel, or press anywhere outside the navbar.
- Wide but crowded: if the links still do not fit, the link row scrolls sideways with its scrollbar hidden; focusing a link scrolls it into view.
Accessibility
- A
<nav>landmark named "Primary" by default, with the links in a list. - The current link has
aria-current="page". Its brackets arearia-hidden, so it reads as "COMPONENTS, current page". - Tab order runs wordmark, links, any children, then the actions. When narrow it runs wordmark,
MENU, then the panel's links, children and actions. MENUis a disclosure button:aria-expandedreports the state andaria-controlspoints at the panel. Its name stays "MENU" in both states, and the icon isaria-hidden.- The closed panel is
display: none, so its links leave the tab order and the accessibility tree. Escape closes it and puts focus back onMENU. There is no focus trap, because the panel is part of the page, not a modal. - The demo's ⌘K hint is decorative and
aria-hidden. If you wire up a real search, make it a button with a name. --labelon--ground-deepis 8.51:1,--signalon--ground-deepis 5.31:1 and hover text is--inkon--bgat 14.63:1. CTA text is--ground-deepon--signalat 5.31:1.- Nothing animates, including the icon change, so there is no reduced-motion variant.