Transparent over hero
A navbar with no background of its own, inheriting inverted ink from the hero beneath it.
Live preview
Source
This exact file renders the preview above.
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { Menu, X } from 'lucide-react'
import { BrandMark } from '@/components/library/brand'
import { ButtonLink } from '@/components/ui/button'
import { productLinks } from '@/content/nav-demo'
/**
* Transparent-over-hero navbar
*
* Sits on top of a dark hero with no background of its own. Two details make
* it work rather than merely look right: the nav is inside the hero's stacking
* context so it inherits the inverted ink, and every control keeps a solid
* focus ring that is visible against imagery.
*/
export default function TransparentHeroNavbar() {
const [open, setOpen] = useState(false)
return (
<div className="relative w-full overflow-hidden bg-surface-inverse text-ink-inverse">
<div className="grid-paper absolute inset-0 opacity-40" aria-hidden="true" />
<div className="relative">
<div className="mx-auto flex h-16 w-full max-w-6xl items-center gap-6 px-4 sm:px-6">
<Link href="/" className="flex items-center gap-2">
<BrandMark className="size-5" />
<span className="display-type text-md font-semibold">Foundry</span>
</Link>
<nav aria-label="Transparent example" className="hidden md:block">
<ul className="flex items-center gap-1">
{productLinks.slice(0, 3).map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="flex h-8 items-center rounded-md px-3 text-sm font-medium opacity-80 transition-opacity hover:opacity-100"
>
{link.label}
</Link>
</li>
))}
</ul>
</nav>
<div className="ml-auto hidden items-center gap-2 md:flex">
<ButtonLink href="/docs/getting-started" size="sm" variant="secondary">
Documentation
</ButtonLink>
</div>
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-controls="transparent-nav-panel"
className="ml-auto flex size-9 items-center justify-center rounded-md opacity-80 hover:opacity-100 md:hidden"
>
{open ? (
<X className="size-5" aria-hidden="true" />
) : (
<Menu className="size-5" aria-hidden="true" />
)}
<span className="sr-only">{open ? 'Close navigation' : 'Open navigation'}</span>
</button>
</div>
<div
id="transparent-nav-panel"
hidden={!open}
className="border-t border-white/15 md:hidden"
>
<nav aria-label="Transparent example, mobile" className="flex flex-col gap-2 px-4 py-4">
<ul className="flex flex-col gap-0.5">
{productLinks.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="flex min-h-11 items-center rounded-md px-3 text-sm hover:bg-white/10"
>
{link.label}
</Link>
</li>
))}
</ul>
<ButtonLink href="/docs/getting-started" size="sm" variant="secondary" block>
Documentation
</ButtonLink>
</nav>
</div>
<div className="mx-auto w-full max-w-6xl px-4 pt-16 pb-24 sm:px-6">
<p className="label-caps opacity-70">Transparent over hero</p>
<h2 className="display-type mt-3 max-w-2xl text-4xl leading-tight font-semibold sm:text-5xl">
The navigation is part of the hero, not a bar above it.
</h2>
<p className="mt-4 max-w-xl text-md opacity-80">
Inverted ink is inherited from the hero, so the same markup works on any dark treatment.
</p>
</div>
</div>
</div>
)
}
components/blocks/navigation/transparent-hero.tsx
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { Menu, X } from 'lucide-react'
import { BrandMark } from '@/components/library/brand'
import { ButtonLink } from '@/components/ui/button'
import { productLinks } from '@/content/nav-demo'
/**
* Transparent-over-hero navbar
*
* Sits on top of a dark hero with no background of its own. Two details make
* it work rather than merely look right: the nav is inside the hero's stacking
* context so it inherits the inverted ink, and every control keeps a solid
* focus ring that is visible against imagery.
*/
export default function TransparentHeroNavbar() {
const [open, setOpen] = useState(false)
return (
<div className="relative w-full overflow-hidden bg-surface-inverse text-ink-inverse">
<div className="grid-paper absolute inset-0 opacity-40" aria-hidden="true" />
<div className="relative">
<div className="mx-auto flex h-16 w-full max-w-6xl items-center gap-6 px-4 sm:px-6">
<Link href="/" className="flex items-center gap-2">
<BrandMark className="size-5" />
<span className="display-type text-md font-semibold">Foundry</span>
</Link>
<nav aria-label="Transparent example" className="hidden md:block">
<ul className="flex items-center gap-1">
{productLinks.slice(0, 3).map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="flex h-8 items-center rounded-md px-3 text-sm font-medium opacity-80 transition-opacity hover:opacity-100"
>
{link.label}
</Link>
</li>
))}
</ul>
</nav>
<div className="ml-auto hidden items-center gap-2 md:flex">
<ButtonLink href="/docs/getting-started" size="sm" variant="secondary">
Documentation
</ButtonLink>
</div>
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-controls="transparent-nav-panel"
className="ml-auto flex size-9 items-center justify-center rounded-md opacity-80 hover:opacity-100 md:hidden"
>
{open ? (
<X className="size-5" aria-hidden="true" />
) : (
<Menu className="size-5" aria-hidden="true" />
)}
<span className="sr-only">{open ? 'Close navigation' : 'Open navigation'}</span>
</button>
</div>
<div
id="transparent-nav-panel"
hidden={!open}
className="border-t border-white/15 md:hidden"
>
<nav aria-label="Transparent example, mobile" className="flex flex-col gap-2 px-4 py-4">
<ul className="flex flex-col gap-0.5">
{productLinks.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="flex min-h-11 items-center rounded-md px-3 text-sm hover:bg-white/10"
>
{link.label}
</Link>
</li>
))}
</ul>
<ButtonLink href="/docs/getting-started" size="sm" variant="secondary" block>
Documentation
</ButtonLink>
</nav>
</div>
<div className="mx-auto w-full max-w-6xl px-4 pt-16 pb-24 sm:px-6">
<p className="label-caps opacity-70">Transparent over hero</p>
<h2 className="display-type mt-3 max-w-2xl text-4xl leading-tight font-semibold sm:text-5xl">
The navigation is part of the hero, not a bar above it.
</h2>
<p className="mt-4 max-w-xl text-md opacity-80">
Inverted ink is inherited from the hero, so the same markup works on any dark treatment.
</p>
</div>
</div>
</div>
)
}
components/ui/button.tsx
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import Link from 'next/link'
import { cn } from '@/lib/cn'
import { variants } from '@/lib/variants'
import { Spinner } from './spinner'
/**
* Button
*
* The whole action surface of Foundry in one component. It is intentionally a
* *shared* component (no `'use client'`): rendered from a Server Component it
* ships zero JavaScript, and it upgrades to a client island automatically when
* a Client Component imports it.
*
* Height, padding and radius resolve from density and radius tokens, so a
* button restyles itself when the density or palette axis changes. Focus is
* handled once, globally, by the `:focus-visible` rule in `globals.css`.
*/
const buttonVariants = variants(
cn(
'relative inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium',
'transition-colors duration-150 ease-standard select-none',
'disabled:pointer-events-none disabled:opacity-50',
'aria-disabled:pointer-events-none aria-disabled:opacity-50',
),
{
variants: {
variant: {
primary: 'bg-accent text-accent-ink hover:bg-accent-hover active:bg-accent-active',
secondary: 'bg-surface-inverse text-ink-inverse hover:opacity-90 active:opacity-80',
outline: 'border border-line-strong bg-surface text-ink hover:bg-surface-sunken',
ghost: 'text-ink hover:bg-surface-sunken active:bg-surface-sunken',
destructive: 'bg-danger text-white hover:opacity-90 active:opacity-80',
success: 'bg-success text-white hover:opacity-90 active:opacity-80',
warning: 'bg-warning text-white hover:opacity-90 active:opacity-80',
link: 'text-accent underline underline-offset-4 hover:text-accent-hover',
soft: 'bg-accent-soft text-accent-soft-ink border border-accent-line hover:brightness-[0.97]',
},
size: {
sm: 'h-control-sm px-3 text-xs rounded-md',
md: 'h-control px-[var(--density-control-padding-x)] text-sm rounded-md',
lg: 'h-control-lg px-5 text-base rounded-md',
icon: 'h-control w-control p-0 rounded-md',
'icon-sm': 'h-control-sm w-control-sm p-0 rounded-sm',
},
block: { true: 'w-full', false: '' },
},
defaultVariants: { variant: 'primary', size: 'md', block: false },
compound: [
{ variant: 'link', size: 'sm', class: 'h-auto px-0' },
{ variant: 'link', size: 'md', class: 'h-auto px-0' },
{ variant: 'link', size: 'lg', class: 'h-auto px-0' },
],
},
)
export type ButtonVariant =
| 'primary'
| 'secondary'
| 'outline'
| 'ghost'
| 'destructive'
| 'success'
| 'warning'
| 'link'
| 'soft'
export type ButtonSize = 'sm' | 'md' | 'lg' | 'icon' | 'icon-sm'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant
size?: ButtonSize
block?: boolean
/** Swaps content for a spinner while preserving the button's measured width. */
loading?: boolean
/** Announced by assistive tech while `loading` is true. */
loadingLabel?: string
leadingIcon?: ReactNode
trailingIcon?: ReactNode
}
export function Button({
variant = 'primary',
size = 'md',
block = false,
loading = false,
loadingLabel = 'Working',
leadingIcon,
trailingIcon,
className,
children,
disabled,
type = 'button',
...props
}: ButtonProps) {
return (
<button
type={type}
className={buttonVariants({ variant, size, block, className })}
disabled={disabled ?? loading}
aria-busy={loading || undefined}
{...props}
>
{loading ? (
<>
{/* Label stays in the DOM but hidden so the control never collapses
to spinner width halfway through an interaction. */}
<span className="invisible flex items-center gap-2" aria-hidden="true">
{leadingIcon}
{children}
{trailingIcon}
</span>
<span className="absolute inset-0 flex items-center justify-center">
<Spinner size="sm" />
<span className="sr-only">{loadingLabel}</span>
</span>
</>
) : (
<>
{leadingIcon}
{children}
{trailingIcon}
</>
)}
</button>
)
}
export interface ButtonLinkProps {
href: string
variant?: ButtonVariant
size?: ButtonSize
block?: boolean
className?: string
children?: ReactNode
leadingIcon?: ReactNode
trailingIcon?: ReactNode
'aria-label'?: string
'aria-current'?: 'page' | 'step' | 'true' | undefined
target?: string
rel?: string
prefetch?: boolean
}
/** Anchor styled as a button, for when the action is really navigation. */
export function ButtonLink({
href,
variant = 'primary',
size = 'md',
block = false,
className,
children,
leadingIcon,
trailingIcon,
...props
}: ButtonLinkProps) {
return (
<Link href={href} className={buttonVariants({ variant, size, block, className })} {...props}>
{leadingIcon}
{children}
{trailingIcon}
</Link>
)
}
export { buttonVariants }
Demo source — adapt to your project. Foundry is not published as a package.
Usage
For hero-led marketing pages. Because the nav sits inside the hero’s stacking context it inherits the inverted foreground, so the same markup works over any dark treatment without a second colour scheme.
- Keep focus rings solid rather than translucent; a translucent ring disappears over imagery.
- Test the mobile panel over the darkest part of the hero, not the average.
Variants and states
Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.
- Inverted ink
- Grid-paper hero
- Mobile panel on a dark surface
Accessibility
- Contrast
- Inverted ink is a token, so contrast is maintained when the palette changes.
- Focus visibility
- The global focus ring uses the accent colour, which stays visible on the inverted surface.
Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.
Related
All navigationMinimal navbar
Wordmark, four links, one action — with an in-flow mobile disclosure rather than a drawer.
starterFeatured3 variantsSplit hero with live panel
Copy on the left, a real component composition on the right — not a screenshot.
intermediate3 variantsProduct launch page
A single moment, with what shipped before what is next and an interactive preview in the middle.
intermediateFeatured4 variants