Site Shell
Every Monark-branded product site uses the same header, so the family reads as one:
[mark] Product Link Link Link Link (• Demo) (EN|FR) (☾) [Primary action]
It is split into five registry items. Install the header and the other four come with it:
npx shadcn@latest add @monark/site-header
| Item | What it is |
|---|---|
site-header | The shell: sticky 64px bar, translucent background with a blur, bottom border. Brand and links on the left, actions pushed right with 10px gaps. Below lg, the brand and a menu button that opens a full-height sheet with the links and the actions. |
site-brand | The product brand: the colour Monark butterfly (28px, inline SVG), a 10px gap, and the product name in Nunito Sans 800 at 18px. Links home. No "by Monark" here; that credit belongs in the footer. |
demo-chip | "Demo" pill that marks the site as a simulated demo. Drop it once the product is live. Adds the --primary-ink token. |
locale-switch | EN/FR pill; the active locale is inverted. Keeps the current page. |
theme-toggle | 36px ghost icon button: moon in light, sun in dark. Uses next-themes and ships a ThemeProvider. |
The items take plain props (href, LinkComponent, pathname, hrefFor, translated labels). They don't import next/link or an i18n library, so they work in any React app; the wiring below is all Next.js needs.
Next.js App Router
1. Theme provider. Wrap the root layout with the exported provider (class strategy, system default), and add suppressHydrationWarning to <html>:
// app/[locale]/layout.tsx
import { ThemeProvider } from "@/components/ui/theme-toggle"
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ locale: string }>
}) {
const { locale } = await params
return (
<html lang={locale} suppressHydrationWarning>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}
2. A small client wrapper. SiteHeader is a client component, and link components and functions can't cross from a server component into it, so put the wiring in one "use client" file. Pass it plain translated strings from the server.
// components/site/header.tsx
"use client"
import Link from "next/link"
import { usePathname, useSearchParams } from "next/navigation"
import { Button } from "@/components/ui/button"
import { DemoChip } from "@/components/ui/demo-chip"
import { LocaleSwitch } from "@/components/ui/locale-switch"
import { SiteBrand } from "@/components/ui/site-brand"
import { SiteHeader } from "@/components/ui/site-header"
import { ThemeToggle } from "@/components/ui/theme-toggle"
const locales = ["en", "fr"] as const
type Locale = (typeof locales)[number]
export function Header({ locale, t }: { locale: Locale; t: HeaderMessages }) {
const pathname = usePathname() ?? `/${locale}`
const query = useSearchParams()?.toString()
// Same page, other locale: swap the first path segment and keep the query,
// so a filtered list or an open tab survives the switch.
const hrefFor = (next: Locale) =>
pathname.replace(/^\/[a-z]{2}(?=\/|$)/, `/${next}`) + (query ? `?${query}` : "")
const localeSwitch = (
<LocaleSwitch
locales={locales}
current={locale}
hrefFor={hrefFor}
labels={{ label: t.language, names: { en: "English", fr: "Français" } }}
LinkComponent={Link}
linkProps={{ prefetch: false }}
/>
)
const action = (
<Button asChild>
<Link href={`/${locale}/app`}>{t.openApp}</Link>
</Button>
)
return (
<SiteHeader
pathname={pathname}
LinkComponent={Link}
brand={<SiteBrand name="Splitflow" href={`/${locale}`} aria-label={t.homeLabel} LinkComponent={Link} />}
links={[
{ href: `/${locale}`, label: t.overview, exact: true },
{ href: `/${locale}/how-it-works`, label: t.howItWorks },
{ href: `/${locale}/faq`, label: t.faq },
]}
labels={{ nav: t.nav, openMenu: t.openMenu, closeMenu: t.closeMenu, menu: t.menu }}
actions={
<>
<DemoChip label={t.demo} />
{localeSwitch}
<ThemeToggle label={t.theme} />
</>
}
primaryAction={action}
/>
)
}
The #hash never reaches the server, so hrefFor can't see it on the first render. If a page relies on it (an anchored FAQ entry, say), add window.location.hash in the link's onClick. useSearchParams also needs a <Suspense> boundary above the header in a statically rendered route.
With next-intl, pass its Link as LinkComponent and build hrefFor with its getPathname; any link component that renders an <a> from href works.
Rules the shell follows
- Brand: mark 28px, 10px gap, product name in Nunito Sans 800 18px with
-0.02emtracking, inforeground. The accessible name defaults to ", by Monark: home"; pass a translated one on other locales. - Links: at most four, 28px after the brand, Nunito Sans 600 14px in
muted-foreground, the current page inforeground. Setexacton the home link. - Actions, in order: Demo chip, EN/FR switch, theme toggle, then
primaryAction: one primary action ("Open the app", orconnect-walletinside the app). - Mobile (below
lg): only the brand and the menu button. The sheet holds the links, then a row withmobileActions(oractionswhen you don't pass it), the Demo chip on the left and the rest on the right, thenprimaryActionat full width and 48px tall. Following a link inside the sheet closes it. - No backdrop blur support: the header falls back to a solid
background. - Primary action:
header-actionpicks it for you: a "Launch demo" link outside the app,ConnectWalletinside it (worked out frompathname), with a placeholder while the wallet state loads. - Footer:
site-footeris the matching three-band footer: product, Monark, then a darker legal band with the demo notice. - The Demo chip's text uses
--primary-ink, the primary darkened enough for text on light backgrounds. Installingdemo-chipadds it;theme-2026defines it too.