Header
Header Split
Sticky header with brand and links at the left and two rolling-label buttons at the right; menu inside the bar below 1024px.
Layout and motion from the Findit template — see it in context"use client";
import * as React from "react";
import Link from "next/link";
import { AnimatePresence, motion, type Transition } from "motion/react";
import { FlipButton } from "@/components/ui/flip-button";
import { Hamburger } from "@/components/ui/hamburger";
import { cn } from "@/lib/utils";
/*
* Header Split — brand and links at the left, two actions at the right, on a bar with a line under it; sticky at the
* top.
* Source: marcframe/findit → components/site/Header.tsx (+ design-system Navigation, Button, Hamburger).
*
* Layout kept from Framer: from 1024px the links start 48px after the brand (32px apart) and the two buttons sit at
* the right end (10px apart); below 1024px a menu button opens the links (stacked, 28px apart) and the two buttons side
* by side, inside the bar.
* Motion kept: a link goes from muted to foreground on hover while a 1px line grows under it from the left (spring,
* bounce 0.2, 0.4s); the current link is foreground with no line. The buttons roll their label (flip-button, spring
* bounce 0, 0.4s). The menu opens by height (spring bounce 0, 0.4s; content fades in 0.25s after 0.1s, out in 0.15s);
* choosing a link or pressing Escape closes it.
* Changed: sticky instead of fixed (no spacer needed); a line under the bar instead of Findit's soft shadow.
* Reduced motion: MotionConfig skips the line, the roll and the height; the color change is a CSS transition that
* motion-reduce turns off.
*/
const line: Transition = { type: "spring", bounce: 0.2, duration: 0.4 };
const soft: Transition = { type: "spring", bounce: 0, duration: 0.4 };
export type HeaderSplitLink = { label: string; href: string };
export type HeaderSplitProps = {
/** Brand at the left; `logo` replaces the text. */
brand?: { name: string; href: string; logo?: React.ReactNode };
links?: HeaderSplitLink[];
/** The two actions: the first filled, the second outlined. */
actions?: [HeaderSplitLink, HeaderSplitLink];
/** Path of the current page. A link is current on its page and on pages below it. */
current?: string;
className?: string;
};
const defaults = {
brand: { name: "Harbor Homes", href: "/" },
links: [
{ label: "Home", href: "/" },
{ label: "About us", href: "/about" },
{ label: "Properties", href: "/properties" },
{ label: "Agents", href: "/agents" },
{ label: "Blog", href: "/blog" },
],
actions: [
{ label: "Contact us", href: "/contact" },
{ label: "Submit property", href: "/submit" },
] as [HeaderSplitLink, HeaderSplitLink],
};
const isCurrent = (href: string, path: string) =>
href === "/" ? path === "/" : path === href || path.startsWith(`${href}/`);
export function HeaderSplit({
brand = defaults.brand,
links = defaults.links,
actions = defaults.actions,
current = "",
className,
}: HeaderSplitProps) {
const [open, setOpen] = React.useState(false);
const menuId = React.useId();
const [primary, secondary] = actions;
React.useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => event.key === "Escape" && setOpen(false);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
const buttons = (
<div className="flex items-center gap-2.5">
<FlipButton href={primary.href}>{primary.label}</FlipButton>
<FlipButton href={secondary.href} variant="outline">
{secondary.label}
</FlipButton>
</div>
);
return (
<header className={cn("sticky top-0 z-50 border-b border-border bg-background", className)}>
<div className="mx-auto flex max-w-(--container) flex-col px-(--gutter)">
<div className="flex h-16 items-center justify-between gap-12 lg:h-18">
<Link
href={brand.href}
className="shrink-0 rounded-md font-heading text-lg font-semibold tracking-tight outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{brand.logo ?? brand.name}
</Link>
<nav aria-label="Main" className="hidden flex-1 items-center gap-8 lg:flex">
{links.map((link) => (
<NavLink key={link.href} link={link} current={isCurrent(link.href, current)} />
))}
</nav>
<div className="hidden lg:block">{buttons}</div>
<Hamburger open={open} onOpenChange={setOpen} controls={menuId} className="lg:hidden" />
</div>
<AnimatePresence initial={false}>
{open && (
<motion.div
id={menuId}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1, transition: { height: soft, opacity: { duration: 0.25, delay: 0.1 } } }}
exit={{ height: 0, opacity: 0, transition: { height: soft, opacity: { duration: 0.15 } } }}
className="overflow-hidden lg:hidden"
>
<div className="flex flex-col items-start gap-12 pt-8 pb-8">
<nav aria-label="Mobile" className="flex flex-col items-start gap-7">
{links.map((link) => (
<NavLink
key={link.href}
link={link}
current={isCurrent(link.href, current)}
onNavigate={() => setOpen(false)}
/>
))}
</nav>
{buttons}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</header>
);
}
/* A menu link: muted, foreground on hover with a line growing under it; the current page is foreground, no line.
The hover and focus gestures stay on for the current link too: Motion only records the end of a hover while
whileHover is set, so switching it off mid-hover left the line drawn once the link stopped being current. */
function NavLink({ link, current, onNavigate }: { link: HeaderSplitLink; current: boolean; onNavigate?: () => void }) {
return (
<motion.span initial="rest" animate="rest" whileHover="hover" whileFocus="hover" className="inline-flex">
<Link
href={link.href}
onClick={onNavigate}
aria-current={current ? "page" : undefined}
className={cn(
"relative rounded-xs pb-0.5 text-sm font-medium outline-none transition-colors duration-400 ease-out focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none",
current ? "text-foreground" : "text-muted-foreground hover:text-foreground focus-visible:text-foreground",
)}
>
{link.label}
{!current && (
<motion.span
aria-hidden
variants={{ rest: { width: "0%" }, hover: { width: "100%" } }}
transition={line}
className="absolute bottom-0 left-0 h-px bg-current"
/>
)}
</Link>
</motion.span>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/blocks/header-split.json1. Add the Framebrick UI components it uses (they bring Motion).
npx shadcn@latest add https://framebrick.co/r/flip-button.json https://framebrick.co/r/hamburger.json2. Add the block tokens to
app/globals.cssonce (theming).npx shadcn@latest add https://framebrick.co/r/blocks/tokens.json3. Install the dependencies.
npm install motion4. Copy the block into
components/header-split.tsx."use client"; import * as React from "react"; import Link from "next/link"; import { AnimatePresence, motion, type Transition } from "motion/react"; import { FlipButton } from "@/components/ui/flip-button"; import { Hamburger } from "@/components/ui/hamburger"; import { cn } from "@/lib/utils"; /* * Header Split — brand and links at the left, two actions at the right, on a bar with a line under it; sticky at the * top. * Source: marcframe/findit → components/site/Header.tsx (+ design-system Navigation, Button, Hamburger). * * Layout kept from Framer: from 1024px the links start 48px after the brand (32px apart) and the two buttons sit at * the right end (10px apart); below 1024px a menu button opens the links (stacked, 28px apart) and the two buttons side * by side, inside the bar. * Motion kept: a link goes from muted to foreground on hover while a 1px line grows under it from the left (spring, * bounce 0.2, 0.4s); the current link is foreground with no line. The buttons roll their label (flip-button, spring * bounce 0, 0.4s). The menu opens by height (spring bounce 0, 0.4s; content fades in 0.25s after 0.1s, out in 0.15s); * choosing a link or pressing Escape closes it. * Changed: sticky instead of fixed (no spacer needed); a line under the bar instead of Findit's soft shadow. * Reduced motion: MotionConfig skips the line, the roll and the height; the color change is a CSS transition that * motion-reduce turns off. */ const line: Transition = { type: "spring", bounce: 0.2, duration: 0.4 }; const soft: Transition = { type: "spring", bounce: 0, duration: 0.4 }; export type HeaderSplitLink = { label: string; href: string }; export type HeaderSplitProps = { /** Brand at the left; `logo` replaces the text. */ brand?: { name: string; href: string; logo?: React.ReactNode }; links?: HeaderSplitLink[]; /** The two actions: the first filled, the second outlined. */ actions?: [HeaderSplitLink, HeaderSplitLink]; /** Path of the current page. A link is current on its page and on pages below it. */ current?: string; className?: string; }; const defaults = { brand: { name: "Harbor Homes", href: "/" }, links: [ { label: "Home", href: "/" }, { label: "About us", href: "/about" }, { label: "Properties", href: "/properties" }, { label: "Agents", href: "/agents" }, { label: "Blog", href: "/blog" }, ], actions: [ { label: "Contact us", href: "/contact" }, { label: "Submit property", href: "/submit" }, ] as [HeaderSplitLink, HeaderSplitLink], }; const isCurrent = (href: string, path: string) => href === "/" ? path === "/" : path === href || path.startsWith(`${href}/`); export function HeaderSplit({ brand = defaults.brand, links = defaults.links, actions = defaults.actions, current = "", className, }: HeaderSplitProps) { const [open, setOpen] = React.useState(false); const menuId = React.useId(); const [primary, secondary] = actions; React.useEffect(() => { if (!open) return; const onKey = (event: KeyboardEvent) => event.key === "Escape" && setOpen(false); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open]); const buttons = ( <div className="flex items-center gap-2.5"> <FlipButton href={primary.href}>{primary.label}</FlipButton> <FlipButton href={secondary.href} variant="outline"> {secondary.label} </FlipButton> </div> ); return ( <header className={cn("sticky top-0 z-50 border-b border-border bg-background", className)}> <div className="mx-auto flex max-w-(--container) flex-col px-(--gutter)"> <div className="flex h-16 items-center justify-between gap-12 lg:h-18"> <Link href={brand.href} className="shrink-0 rounded-md font-heading text-lg font-semibold tracking-tight outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > {brand.logo ?? brand.name} </Link> <nav aria-label="Main" className="hidden flex-1 items-center gap-8 lg:flex"> {links.map((link) => ( <NavLink key={link.href} link={link} current={isCurrent(link.href, current)} /> ))} </nav> <div className="hidden lg:block">{buttons}</div> <Hamburger open={open} onOpenChange={setOpen} controls={menuId} className="lg:hidden" /> </div> <AnimatePresence initial={false}> {open && ( <motion.div id={menuId} initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1, transition: { height: soft, opacity: { duration: 0.25, delay: 0.1 } } }} exit={{ height: 0, opacity: 0, transition: { height: soft, opacity: { duration: 0.15 } } }} className="overflow-hidden lg:hidden" > <div className="flex flex-col items-start gap-12 pt-8 pb-8"> <nav aria-label="Mobile" className="flex flex-col items-start gap-7"> {links.map((link) => ( <NavLink key={link.href} link={link} current={isCurrent(link.href, current)} onNavigate={() => setOpen(false)} /> ))} </nav> {buttons} </div> </motion.div> )} </AnimatePresence> </div> </header> ); } /* A menu link: muted, foreground on hover with a line growing under it; the current page is foreground, no line. The hover and focus gestures stay on for the current link too: Motion only records the end of a hover while whileHover is set, so switching it off mid-hover left the line drawn once the link stopped being current. */ function NavLink({ link, current, onNavigate }: { link: HeaderSplitLink; current: boolean; onNavigate?: () => void }) { return ( <motion.span initial="rest" animate="rest" whileHover="hover" whileFocus="hover" className="inline-flex"> <Link href={link.href} onClick={onNavigate} aria-current={current ? "page" : undefined} className={cn( "relative rounded-xs pb-0.5 text-sm font-medium outline-none transition-colors duration-400 ease-out focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none", current ? "text-foreground" : "text-muted-foreground hover:text-foreground focus-visible:text-foreground", )} > {link.label} {!current && ( <motion.span aria-hidden variants={{ rest: { width: "0%" }, hover: { width: "100%" } }} transition={line} className="absolute bottom-0 left-0 h-px bg-current" /> )} </Link> </motion.span> ); }
Usage
import { HeaderSplit } from "@/components/header-split";
import { usePathname } from "next/navigation";
// in a client component
<HeaderSplit
current={usePathname()}
brand={{ name: "Harbor Homes", href: "/" }}
links={[{ label: "Home", href: "/" }, { label: "Properties", href: "/properties" }]}
actions={[{ label: "Contact us", href: "/contact" }, { label: "Submit property", href: "/submit" }]}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| brand | { name, href, logo? } | Harbor Homes | At the left; logo (any node) replaces the name. |
| links | { label, href }[] | 5 links | Beside the brand from 1024px, in the menu below. |
| actions | [{ label, href }, { label, href }] | — | Filled and outlined buttons at the right from 1024px, in the menu below. |
| current | string | "" | Path of the current page (pass usePathname()). A link is current on its page and below it. |
| className | string | — | Classes for the <header>. |
- Sticky, with a line under the bar. Links go from muted to foreground on hover while a 1px line grows under them (spring bounce 0.2, 0.4s); the current link has no line.
- Buttons roll their label on hover and focus (flip-button). Below 1024px the menu opens inside the bar by height; choosing a link or Escape closes it.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this block in your own project.
Create a React + Tailwind CSS + Motion block called HeaderSplit (Next.js). - A sticky <header> (top-0, z-50, bg-background, border-b) with a container mx-auto max-w-(--container) px-(--gutter); a row h-16 (lg:h-18) justify-between gap-12: the brand (font-heading text-lg semibold), from 1024px a <nav aria-label="Main"> filling the row (links gap-8) and two buttons (filled + outline, gap-2.5); below 1024px a "Menu / Close" button (aria-expanded, aria-controls). - Links: text-sm font-medium muted, foreground on hover; a 1px line under the link grows from 0 to 100% width with a spring (bounce 0.2, 0.4s); the current link (aria-current="page") is foreground with no line. - Buttons: shadcn-style; the label sits in a 20px window and rolls up 28px while a copy rises from below (spring bounce 0, 0.4s). - Menu below 1024px: inside the bar, height 0 → auto (spring bounce 0, 0.4s; fade in 0.25s after 0.1s, out 0.15s): links stacked gap-7, then the two buttons; close on link click and Escape. - Only shadcn variables and block tokens.











