Skip to content

Transparent over hero

A navbar with no background of its own, inheriting inverted ink from the hero beneath it.

Navbarsstarternavbartransparentheroinverted

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

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.