# Handoff Nav: Sticky Navbar on Scroll for React

> A navbar that tightens into a floating pill once the page scrolls, and catches your hero's call to action: as the hero button rides up behind the pill it flies into a slot at the end, and when you scroll back it flies home. On narrow screens the pill grows down into a menu.

Sticky Navbar on Scroll for React: the bar shrinks to a pill and catches your CTA. Built with Tailwind CSS and Motion; install it with the shadcn CLI. It's a KeyframeUI component: the file lands in your project as source, yours to edit.

- Docs and live preview: https://keyframeui.com/components/handoff-nav
- Tags: Navigation, Scroll, Interaction
- npm dependencies: motion, clsx, tailwind-merge

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/handoff-nav.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/handoff-nav
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/handoff-nav.json
```

## Usage

The main demo shows typical usage.

### AI Product

An AI inbox's landing page. Scroll the page: the bar docks into a pill, and "Get Morrow" flies up into it as the hero passes under. Scroll back up and it flies home.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/handoff-nav-demo
```

```tsx title="components/handoff-nav-demo.tsx"
"use client";

import * as React from "react";

import { HandoffNav, HandoffNavCta, HandoffNavProvider } from "@/components/ui/handoff-nav";

const links = [
  { label: "Product", href: "#product" },
  { label: "Pricing", href: "#pricing" },
  { label: "Changelog", href: "#changelog" },
  { label: "Docs", href: "#docs" },
];

const threads = [
  {
    from: "Priya Raman",
    subject: "Q3 board deck: one more pass?",
    preview: "Happy to tighten the retention slide before Thursday. I moved the cohort chart up and…",
    time: "6:12",
  },
  {
    from: "Lisbon offsite",
    subject: "Flights are booked, hotel needs a headcount",
    preview: "The venue holds rooms until Friday noon. Can you confirm 14 or should we keep 16?",
    time: "5:48",
  },
  {
    from: "Tomás at Harbor",
    subject: "Renewal terms for 2027",
    preview: "Attaching the redline. The only open item is the uptime credit in section 4.2.",
    time: "Yesterday",
  },
  {
    from: "Mei Chen",
    subject: "Coffee next week?",
    preview: "I'm in town Tuesday to Thursday. Would love to hear how the launch went.",
    time: "Yesterday",
  },
];

const steps = [
  {
    title: "It reads overnight",
    body: "Every new thread is read once, with the history behind it: what you promised, who's waiting, what changed.",
  },
  {
    title: "It drafts in your voice",
    body: "Replies sound like the last hundred you sent. Short where you're short, warm where you're warm.",
  },
  {
    title: "You send before coffee",
    body: "Open a thread, change a word if you want, press send. Most mornings take under ten minutes.",
  },
];

function Mark() {
  return (
    <svg viewBox="0 0 24 24" aria-hidden className="size-6">
      <circle cx="12" cy="12" r="10" className="fill-zinc-950 dark:fill-white" />
      <path d="M7 13.5a5 5 0 0 0 10 0" className="fill-none stroke-white stroke-2 dark:stroke-zinc-950" />
      <circle cx="12" cy="8.5" r="1.6" className="fill-violet-400" />
    </svg>
  );
}

export function HandoffNavDemo() {
  const scrollRef = React.useRef<HTMLDivElement>(null);

  return (
    <div className="relative w-full overflow-hidden rounded-xl border border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950">
      <div ref={scrollRef} className="@container relative h-[var(--demo-height,36rem)] overflow-y-auto overscroll-contain">
        <HandoffNavProvider>
          <HandoffNav
            containerRef={scrollRef}
            className="[--handoff-nav-width:64rem]"
            logo={
              <a
                href="#"
                className="flex items-center gap-2 rounded-md text-[15px] font-semibold tracking-tight text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-zinc-950 dark:text-white dark:focus-visible:outline-white"
              >
                <Mark />
                Morrow
              </a>
            }
            links={links}
            action={{ label: "Log in", href: "#login" }}
          />

          <main className="mx-auto max-w-5xl px-4 pb-16 @xl:px-6">
            <section className="flex flex-col items-center pt-10 text-center @xl:pt-14">
              <a
                href="#changelog"
                className="inline-flex items-center gap-2 rounded-full border border-zinc-950/10 bg-white py-1 pr-3 pl-1 text-[13px] text-zinc-700 shadow-sm dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-300"
              >
                <span className="rounded-full bg-violet-600 px-2 py-0.5 text-[11px] font-semibold text-white">New</span>
                Drafts now match your sign-off
                <span aria-hidden>→</span>
              </a>
              <h1 className="mt-6 max-w-2xl text-4xl/[1.05] font-semibold tracking-[-0.035em] text-balance text-zinc-950 @xl:text-6xl/[1.02] dark:text-white">
                Your inbox, answered{" "}
                <span className="text-zinc-400 dark:text-zinc-500">before you open it.</span>
              </h1>
              <p className="mt-5 max-w-md text-[15px]/6 text-pretty text-zinc-600 dark:text-zinc-400">
                Morrow reads every thread overnight and leaves a reply drafted in your voice. You read it,
                change a word, and send.
              </p>
              <div className="mt-8 flex items-center gap-5">
                <a
                  href="#product"
                  className="text-sm font-medium text-zinc-700 underline-offset-4 hover:underline dark:text-zinc-300"
                >
                  See a sample morning
                </a>
                <HandoffNavCta href="#signup">Get Morrow</HandoffNavCta>
              </div>
            </section>

            <section id="product" className="mt-14">
              <div className="overflow-hidden rounded-2xl border border-zinc-950/10 bg-white shadow-[0_24px_60px_-30px_rgb(9_9_11/0.35)] dark:border-white/10 dark:bg-zinc-900">
                <div className="flex items-center justify-between border-b border-zinc-950/[0.06] px-4 py-3 dark:border-white/[0.06]">
                  <p className="text-[13px] font-medium text-zinc-900 dark:text-zinc-100">Inbox</p>
                  <p className="text-[12px] text-zinc-500">4 drafts ready · 7:02 am</p>
                </div>
                <ul className="divide-y divide-zinc-950/[0.06] dark:divide-white/[0.06]">
                  {threads.map((thread) => (
                    <li key={thread.subject} className="flex items-start gap-3 px-4 py-3.5">
                      <span className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-full bg-zinc-100 text-[12px] font-semibold text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
                        {thread.from
                          .split(" ")
                          .map((word) => word[0])
                          .slice(0, 2)
                          .join("")}
                      </span>
                      <div className="min-w-0 flex-1 text-left">
                        <div className="flex items-baseline justify-between gap-3">
                          <p className="truncate text-[13px] font-medium text-zinc-900 dark:text-zinc-100">
                            {thread.from}
                          </p>
                          <p className="shrink-0 text-[11px] text-zinc-400 tabular-nums">{thread.time}</p>
                        </div>
                        <p className="truncate text-[13px] text-zinc-700 dark:text-zinc-300">{thread.subject}</p>
                        <p className="truncate text-[12px] text-zinc-500">{thread.preview}</p>
                      </div>
                      <span className="mt-1 hidden shrink-0 rounded-full bg-violet-50 px-2 py-0.5 text-[11px] font-medium text-violet-700 @xl:inline dark:bg-violet-500/15 dark:text-violet-300">
                        Draft ready
                      </span>
                    </li>
                  ))}
                </ul>
              </div>
            </section>

            <section className="mt-20">
              <h2 className="max-w-md text-2xl/tight font-semibold tracking-tight text-zinc-950 dark:text-white">
                How a morning goes
              </h2>
              <ol className="mt-6 grid gap-px overflow-hidden rounded-2xl border border-zinc-950/10 bg-zinc-950/10 @2xl:grid-cols-3 dark:border-white/10 dark:bg-white/10">
                {steps.map((step, index) => (
                  <li key={step.title} className="bg-white p-5 dark:bg-zinc-900">
                    <p className="text-[12px] text-zinc-400 tabular-nums">{index + 1} of 3</p>
                    <p className="mt-6 text-[15px] font-medium text-zinc-900 dark:text-zinc-100">{step.title}</p>
                    <p className="mt-1.5 text-[13px]/5 text-zinc-500 dark:text-zinc-400">{step.body}</p>
                  </li>
                ))}
              </ol>
            </section>

            <section className="mt-20 grid gap-8 @2xl:grid-cols-[1fr_1.2fr] @2xl:items-end">
              <h2 className="text-2xl/tight font-semibold tracking-tight text-zinc-950 dark:text-white">
                Nothing sends without you
              </h2>
              <p className="text-[15px]/6 text-zinc-600 dark:text-zinc-400">
                Drafts wait in the thread until you press send. Morrow never replies on its own, never
                forwards, and never reads folders you haven&apos;t shared with it.
              </p>
            </section>

            <footer className="mt-24 flex items-center justify-between border-t border-zinc-950/10 pt-6 text-[13px] text-zinc-500 dark:border-white/10">
              <span>© 2026 Morrow Labs</span>
              <span>Privacy · Terms</span>
            </footer>
          </main>
        </HandoffNavProvider>
      </div>
    </div>
  );
}
```

## Examples

Finished sections built with Handoff Nav. Each one installs whole, with the component included.

### Sticky Add to Bag

A product page where "Add to bag" moves into the nav, so it's one click away all the way down to the reviews. Either copy adds to the bag, and the action's count and the button's label update in both places.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/handoff-nav-store-demo
```

```tsx title="components/handoff-nav-store-demo.tsx"
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { HandoffNav, HandoffNavCta, HandoffNavProvider } from "@/components/ui/handoff-nav";

// The full shot, or a close crop of one spot on it (x and y from 0 to 1).
const photo = (id: string, w: number, h: number, spot?: [number, number]) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=crop${
    spot ? `&crop=focalpoint&fp-x=${spot[0]}&fp-y=${spot[1]}&fp-z=2.4` : ""
  }&w=${w}&h=${h}&q=80`;

const colors: { name: string; swatch: string; id: string; spots: [number, number][] }[] = [
  { name: "Tobacco", swatch: "bg-[#9a6b43]", id: "1448582649076-3981753123b5", spots: [[0.3, 0.72], [0.14, 0.6]] },
  { name: "Olive", swatch: "bg-[#6f735b]", id: "1604712941007-2627cfd759fd", spots: [[0.55, 0.55], [0.32, 0.42]] },
];

const details = [
  { term: "Material", value: "18 oz waxed cotton canvas, vegetable-tanned leather trim" },
  { term: "Size", value: "52 × 28 × 30 cm · 42 litres · fits most overhead bins" },
  { term: "Inside", value: "Zip pocket, two slip pockets, a padded sleeve for a 16-inch laptop" },
  { term: "Care", value: "Brush off dry dirt. Re-wax once a year with our bar, sold separately" },
];

const pairs = [
  { id: "1473188588951-666fce8e7c68", name: "The Satchel", note: "Bridle leather", price: 240 },
  { id: "1598532163257-ae3c6b2524b6", name: "The Tote", note: "Woven leather", price: 210 },
  { id: "1605733513597-a8f8341084e6", name: "The Commuter", note: "Waxed canvas, slate", price: 165 },
];

const reviews = [
  { name: "Hannah L.", where: "Copenhagen", text: "Two winters of trains and it looks better than the day it came. The wax took a crease and kept it." },
  { name: "Owen T.", where: "Portland", text: "Holds four days of clothes and my laptop without looking stuffed. The shoulder strap pad is the detail I didn't know I needed." },
];

export function HandoffNavStoreDemo() {
  const scrollRef = React.useRef<HTMLDivElement>(null);
  const [color, setColor] = React.useState(0);
  const [view, setView] = React.useState(0);
  const [bag, setBag] = React.useState(0);
  const [added, setAdded] = React.useState(false);

  React.useEffect(() => {
    if (!added) return;
    const timer = setTimeout(() => setAdded(false), 1600);
    return () => clearTimeout(timer);
  }, [added]);

  const add = React.useCallback(() => {
    setBag((count) => count + 1);
    setAdded(true);
  }, []);

  const views: (undefined | [number, number])[] = [undefined, ...colors[color].spots];
  const id = colors[color].id;

  return (
    <div className="relative w-full overflow-hidden rounded-xl border border-stone-200 bg-stone-50 dark:border-stone-800 dark:bg-stone-950">
      <div ref={scrollRef} className="@container relative h-[var(--demo-height,36rem)] overflow-y-auto overscroll-contain">
        <HandoffNavProvider>
          <HandoffNav
            containerRef={scrollRef}
            className="[--handoff-nav-width:64rem]"
            surfaceClassName="bg-stone-50/85 dark:bg-stone-900/85"
            logo={
              <a
                href="#"
                className="rounded-sm font-serif text-lg tracking-tight text-stone-950 italic focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-stone-950 dark:text-stone-50 dark:focus-visible:outline-white"
              >
                Field &amp; Fold
              </a>
            }
            links={[
              { label: "Shop", href: "#shop" },
              { label: "Bags", href: "#bags" },
              { label: "Journal", href: "#journal" },
              { label: "Stores", href: "#stores" },
            ]}
            action={{ label: bag ? `Bag · ${bag}` : "Bag", href: "#bag" }}
          />

          <main className="mx-auto max-w-5xl px-4 pb-16 @xl:px-6">
            <div className="grid gap-8 pt-4 @2xl:grid-cols-[1.1fr_1fr] @2xl:gap-10 @2xl:pt-6">
              <div>
                <div className="aspect-[4/3] overflow-hidden rounded-2xl bg-stone-200 dark:bg-stone-800">
                  {/* eslint-disable-next-line @next/next/no-img-element */}
                  <img
                    key={`${id}-${view}`}
                    src={photo(id, 1100, 825, views[view])}
                    alt={`The Weekender in ${colors[color].name}`}
                    className="size-full object-cover"
                  />
                </div>
                <div className="mt-3 grid grid-cols-3 gap-3">
                  {views.map((spot, index) => (
                    <button
                      key={index}
                      type="button"
                      onClick={() => setView(index)}
                      aria-label={`View photo ${index + 1}`}
                      aria-pressed={view === index}
                      className={cn(
                        "aspect-[4/3] overflow-hidden rounded-xl ring-offset-2 ring-offset-stone-50 transition-shadow duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-stone-950 dark:ring-offset-stone-950 dark:focus-visible:outline-white",
                        view === index ? "ring-2 ring-stone-900 dark:ring-stone-100" : "opacity-80 hover:opacity-100",
                      )}
                    >
                      {/* eslint-disable-next-line @next/next/no-img-element */}
                      <img src={photo(id, 320, 240, spot)} alt="" className="size-full object-cover" />
                    </button>
                  ))}
                </div>
              </div>

              <div className="@2xl:pt-2">
                <p className="text-[13px] text-stone-500">Bags / Weekenders</p>
                <h1 className="mt-2 font-serif text-3xl tracking-tight text-stone-950 @xl:text-4xl dark:text-stone-50">
                  The Weekender
                </h1>
                <p className="mt-1 text-[15px] text-stone-600 dark:text-stone-400">Waxed canvas · 42 L</p>
                <p className="mt-5 text-2xl font-medium text-stone-950 tabular-nums dark:text-stone-50">$189</p>

                <div className="mt-6">
                  <p className="text-[13px] text-stone-600 dark:text-stone-400">
                    Colour: <span className="text-stone-950 dark:text-stone-50">{colors[color].name}</span>
                  </p>
                  <div className="mt-2.5 flex gap-2.5">
                    {colors.map((option, index) => (
                      <button
                        key={option.name}
                        type="button"
                        onClick={() => {
                          setColor(index);
                          setView(0);
                        }}
                        aria-label={option.name}
                        aria-pressed={color === index}
                        className={cn(
                          "size-8 rounded-full ring-offset-2 ring-offset-stone-50 transition-shadow duration-150 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-stone-950 dark:ring-offset-stone-950 dark:focus-visible:outline-white",
                          option.swatch,
                          color === index && "ring-2 ring-stone-900 dark:ring-stone-100",
                        )}
                      />
                    ))}
                  </div>
                </div>

                <div className="mt-7 flex flex-wrap items-center gap-4">
                  <HandoffNavCta
                    onClick={add}
                    className="bg-stone-950 hover:bg-stone-800 dark:bg-stone-50 dark:text-stone-950 dark:hover:bg-stone-200"
                  >
                    {added ? (
                      <>
                        <svg viewBox="0 0 16 16" aria-hidden className="size-4">
                          <path d="m3.5 8.5 3 3 6-7" className="fill-none stroke-current stroke-[1.75]" />
                        </svg>
                        Added
                      </>
                    ) : (
                      "Add to bag — $189"
                    )}
                  </HandoffNavCta>
                  <p className="text-[13px] text-stone-500">Ships tomorrow · free returns</p>
                </div>
                <p aria-live="polite" className="sr-only">
                  {added ? "Added The Weekender to your bag" : ""}
                </p>

                <dl className="mt-8 divide-y divide-stone-950/10 border-y border-stone-950/10 dark:divide-white/10 dark:border-white/10">
                  {details.map((item) => (
                    <div key={item.term} className="grid grid-cols-[6rem_1fr] gap-4 py-3.5 text-[13px]/5">
                      <dt className="text-stone-500">{item.term}</dt>
                      <dd className="text-stone-800 dark:text-stone-200">{item.value}</dd>
                    </div>
                  ))}
                </dl>
              </div>
            </div>

            <section className="mt-20">
              <div className="flex items-baseline justify-between gap-4">
                <h2 className="font-serif text-2xl tracking-tight text-stone-950 dark:text-stone-50">What owners say</h2>
                <p className="text-[13px] text-stone-500 tabular-nums">4.8 from 312 reviews</p>
              </div>
              <div className="mt-6 grid gap-4 @2xl:grid-cols-2">
                {reviews.map((review) => (
                  <figure key={review.name} className="rounded-2xl border border-stone-950/10 bg-white p-5 dark:border-white/10 dark:bg-stone-900">
                    <blockquote className="text-[15px]/6 text-stone-800 dark:text-stone-200">“{review.text}”</blockquote>
                    <figcaption className="mt-4 text-[13px] text-stone-500">
                      {review.name} · {review.where}
                    </figcaption>
                  </figure>
                ))}
              </div>
            </section>

            <section className="mt-20">
              <h2 className="font-serif text-2xl tracking-tight text-stone-950 dark:text-stone-50">Pairs well with</h2>
              <ul className="mt-6 grid grid-cols-2 gap-4 @2xl:grid-cols-3">
                {pairs.map((item) => (
                  <li key={item.name}>
                    <a href="#shop" className="group block rounded-2xl focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-stone-950 dark:focus-visible:outline-white">
                      <div className="aspect-square overflow-hidden rounded-2xl bg-stone-200 dark:bg-stone-800">
                        {/* eslint-disable-next-line @next/next/no-img-element */}
                        <img
                          src={photo(item.id, 520, 520)}
                          alt={item.name}
                          className="size-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.03]"
                        />
                      </div>
                      <div className="mt-3 flex items-baseline justify-between gap-3 text-[13px]">
                        <span>
                          <span className="block text-[15px] text-stone-950 dark:text-stone-50">{item.name}</span>
                          <span className="text-stone-500">{item.note}</span>
                        </span>
                        <span className="text-stone-800 tabular-nums dark:text-stone-200">${item.price}</span>
                      </div>
                    </a>
                  </li>
                ))}
              </ul>
            </section>

            <footer className="mt-24 flex items-center justify-between border-t border-stone-950/10 pt-6 text-[13px] text-stone-500 dark:border-white/10">
              <span>Field &amp; Fold, Portland</span>
              <span>Shipping · Returns</span>
            </footer>
          </main>
        </HandoffNavProvider>
      </div>
    </div>
  );
}
```

### Studio at Phone Width

An interiors studio's site in a phone frame. The links fold into a menu that grows out of the pill, and "Book a call" shares the pill with it once caught; the hero copy gets wider padding through `data-[placement=hero]:`.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/handoff-nav-studio-demo
```

```tsx title="components/handoff-nav-studio-demo.tsx"
"use client";

import * as React from "react";

import { HandoffNav, HandoffNavCta, HandoffNavProvider } from "@/components/ui/handoff-nav";

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=720&h=900&q=80`;

const work = [
  { id: "1638193399915-e22226710f2b", title: "Rua das Flores", kind: "Apartment, 92 m²", year: "2026" },
  { id: "1630232782797-7b48bf7125e4", title: "Casa Alfama", kind: "Kitchen and dining", year: "2025" },
  { id: "1633944095397-878622ebc01c", title: "Quinta Norte", kind: "Guest suite", year: "2025" },
  { id: "1633429083812-2f9c1ef8738d", title: "Atelier Graça", kind: "Reading room", year: "2024" },
];

const services = ["Whole homes", "Kitchens and joinery", "Lighting plans", "Furniture sourcing"];

export function HandoffNavStudioDemo() {
  const scrollRef = React.useRef<HTMLDivElement>(null);

  return (
    // The studio's site at phone width: the links fold into the menu, and the caught button
    // shares the pill with it.
    <div className="flex w-full justify-center">
      <div className="w-full max-w-[390px] overflow-hidden rounded-[2rem] border border-stone-300 bg-[#f6f3ee] shadow-[0_30px_60px_-30px_rgb(28_25_23/0.45)] dark:border-stone-700 dark:bg-stone-950">
        <div ref={scrollRef} className="relative h-[var(--demo-height,36rem)] overflow-y-auto overscroll-contain">
          <HandoffNavProvider>
            <HandoffNav
              containerRef={scrollRef}
              surfaceClassName="bg-[#f6f3ee]/85 dark:bg-stone-900/85"
              logo={
                <a
                  href="#"
                  className="rounded-sm font-serif text-[17px] tracking-tight text-stone-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-stone-900 dark:text-stone-100 dark:focus-visible:outline-white"
                >
                  Atelier Mora
                </a>
              }
              links={[
                { label: "Work", href: "#work" },
                { label: "Studio", href: "#studio" },
                { label: "Journal", href: "#journal" },
                { label: "Contact", href: "#contact" },
              ]}
              action={{ label: "hello@ateliermora.pt", href: "#contact" }}
            />

            <main className="px-5 pb-12">
              <section className="pt-8">
                <p className="text-[13px] text-stone-500">Interiors · Lisbon</p>
                <h1 className="mt-4 font-serif text-[40px]/[1.02] tracking-[-0.02em] text-stone-900 dark:text-stone-100">
                  Rooms that hold <em className="text-stone-500">still.</em>
                </h1>
                <p className="mt-5 text-[15px]/6 text-stone-600 dark:text-stone-400">
                  We design homes around the light they already have, and finish them with things made a few
                  streets away.
                </p>
                <div className="mt-7 flex items-center gap-4">
                  <HandoffNavCta
                    href="#contact"
                    className="bg-stone-900 hover:bg-stone-700 data-[placement=hero]:px-6 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-stone-300"
                  >
                    Book a call
                  </HandoffNavCta>
                  <a href="#work" className="text-sm text-stone-700 underline underline-offset-4 dark:text-stone-300">
                    See the work
                  </a>
                </div>
              </section>

              <section id="work" className="mt-12 space-y-8">
                {work.map((item) => (
                  <figure key={item.title}>
                    <div className="aspect-[4/5] overflow-hidden rounded-xl bg-stone-200 dark:bg-stone-800">
                      {/* eslint-disable-next-line @next/next/no-img-element */}
                      <img src={photo(item.id)} alt={`${item.title}, ${item.kind}`} className="size-full object-cover" />
                    </div>
                    <figcaption className="mt-3 flex items-baseline justify-between text-[13px]">
                      <span className="font-serif text-[17px] text-stone-900 dark:text-stone-100">{item.title}</span>
                      <span className="text-stone-500">
                        {item.kind} · {item.year}
                      </span>
                    </figcaption>
                  </figure>
                ))}
              </section>

              <section id="studio" className="mt-14 border-t border-stone-900/10 pt-8 dark:border-white/10">
                <h2 className="font-serif text-2xl tracking-tight text-stone-900 dark:text-stone-100">What we take on</h2>
                <ul className="mt-4 divide-y divide-stone-900/10 dark:divide-white/10">
                  {services.map((service) => (
                    <li key={service} className="py-3 text-[15px] text-stone-700 dark:text-stone-300">
                      {service}
                    </li>
                  ))}
                </ul>
              </section>

              <footer id="contact" className="mt-14 text-[13px]/6 text-stone-500">
                Rua do Benformoso 41, Lisbon
                <br />
                Two new homes a season. Next opening: March.
              </footer>
            </main>
          </HandoffNavProvider>
        </div>
      </div>
    </div>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `logo` (required) | `React.ReactNode` |  | Your logo or wordmark, at the start of the bar. Wrap it in a link to your home page. |
| `links` | `HandoffNavLink[]` | `[]` | The page links, each `{ label, href }`. They sit at the end of the bar and fold into a menu on narrow screens. |
| `action` | `HandoffNavLink` |  | One quieter action after the links, like "Log in", drawn as an outlined pill. |
| `dockAt` | `number` | `24` | Pixels of scroll before the bar docks into the pill. |
| `dockDuration` | `number` | `0.4` | Seconds the bar takes to tighten into the pill once the page scrolls. Spreading back out at the top stays a little quicker. |
| `catchDuration` | `number` | `0.45` | Seconds the hero's button takes to fly up into the pill. The flight home scales with it. |
| `catchBounce` | `number` | `0.08` | How much the caught button overshoots its slot before it settles, from `0` (none) up. |
| `containerRef` | `React.RefObject<HTMLElement \| null>` |  | The element that scrolls, when it isn't the window. The nav then sticks to the top of that box: put it first inside it. Without it, the nav is fixed to the viewport and keeps its 64px in the page. |
| `surfaceClassName` | `string` |  | Classes for the docked pill's surface: its fill, border and shadow. |
| `className` | `string` |  | Extra classes for the `<header>`. Set `[--handoff-nav-width:64rem]` to match the bar's resting width to your page's container (72rem by default). |
| `cta.children` (required) | `React.ReactNode` |  | `HandoffNavCta`: the button's label. The same content is drawn in the nav once it's caught, at the same size: only the button around it changes. |
| `cta.href` | `string` |  | `HandoffNavCta`: where the button goes. Without it, it's a `<button>`. |
| `cta.onClick` | `React.MouseEventHandler<HTMLElement>` |  | `HandoffNavCta`: called when either copy is clicked, in the hero or in the nav. |
| `cta.className` | `string` |  | `HandoffNavCta`: classes for both copies. Target one with `data-[placement=hero]:` or `data-[placement=nav]:`. |
| `provider.children` (required) | `React.ReactNode` |  | `HandoffNavProvider`: wrap the nav and the hero in it, so the hero's `HandoffNavCta` can find the nav that catches it. One per page, or one per demo. |

## What to keep

To adapt it, change its props first. When you do edit `components/ui/handoff-nav.tsx`, keep these, which make it feel and behave right:

- The caught button is a second copy in the pill's slot, flown by `transform`, width and height on one spring's progress; the hero keeps an empty space its size, so nothing under it moves.
- A flight runs in two legs so the button never crosses the links: up, it slides along under the pill and rises into the slot last; home, it drops out of the pill first and then slides.
- The label only travels, it never scales: it sits in one span that keeps its own width, which is also what the pill measures to open the slot.
- The hand between copies is invisible: the copy is placed over its start before the original hides, coming home both show for a frame or two (never neither), and focus moves with it.
- Reduced motion: nothing travels. The bar swaps to the pill at once, the button crossfades between hero and pill, and the menu opens without growing.

Its timing (durations, delays, springs and easings) is all in the `TIMING` block at the top of the file; the timing props default to it.

## Source

```tsx title="components/ui/handoff-nav.tsx"
"use client";

import * as React from "react";
import { animate, motion, useReducedMotion, type AnimationPlaybackControls } from "motion/react";

import { cn } from "@/lib/utils";

/**
 * Keep:
 * - The caught button is a second copy in the pill's slot, flown by `transform`, width and height
 *   on one spring's progress; the hero keeps an empty space its size, so nothing under it moves.
 * - A flight runs in two legs so the button never crosses the links: up, it slides along under the
 *   pill and rises into the slot last; home, it drops out of the pill first and then slides.
 * - The label only travels, it never scales: it sits in one span that keeps its own width, which is
 *   also what the pill measures to open the slot.
 * - The hand between copies is invisible: the copy is placed over its start before the original
 *   hides, coming home both show for a frame or two (never neither), and focus moves with it.
 * - Reduced motion: nothing travels. The bar swaps to the pill at once, the button crossfades
 *   between hero and pill, and the menu opens without growing.
 */
const TIMING = {
  /** The bar tightening into the pill: a spring, in seconds (`visualDuration`: `dockDuration`). */
  dock: { type: "spring", visualDuration: 0.4, bounce: 0 },
  /** Spreading out again at the top, quicker: a spring, in seconds, scaled by `dockDuration`. */
  undock: { type: "spring", visualDuration: 0.3, bounce: 0 },
  /** The slot opening, ahead of the button: a spring, in seconds. */
  make: { type: "spring", visualDuration: 0.3, bounce: 0 },
  /** The slot closing, behind it: a spring, in seconds. */
  shut: { type: "spring", visualDuration: 0.34, bounce: 0, delay: 0.06 },
  /**
   * The button flying up into the pill: a spring, in seconds (`visualDuration` is the
   * `catchDuration` prop, `bounce` the `catchBounce` prop).
   */
  catch: { type: "spring", visualDuration: 0.45, bounce: 0.08 },
  /** And back down into the hero: a spring, in seconds, scaled by `catchDuration`. */
  release: { type: "spring", visualDuration: 0.38, bounce: 0 },
  /** The menu panel growing: a spring, in seconds. */
  open: { type: "spring", visualDuration: 0.4, bounce: 0.06 },
  /** The menu panel closing: a spring, in seconds. */
  close: { type: "spring", visualDuration: 0.3, bounce: 0 },
  /** The link highlight sliding: a spring, in seconds. */
  hover: { type: "spring", visualDuration: 0.3, bounce: 0 },
  /** The pill's surface fading in or out, in seconds. */
  fill: { duration: 0.2, ease: [0.22, 1, 0.36, 1] },
  /** Fades: the link highlight, reduced motion's swaps, a far-off button arriving, in seconds. */
  fade: { duration: 0.15, ease: "easeOut" },
  /** A far-off button waiting for its slot to open before it fades in, in seconds. */
  slotWait: 0.2,
  /** Between menu links as they arrive, in seconds. */
  stagger: 0.03,
  /** Between menu links as they leave, last first, in seconds. */
  tuck: 0.02,
  /** The links' and buttons' hover color, as a Tailwind class. */
  color: "duration-150",
  /** The hero button's hover fill and press, as Tailwind classes. */
  press: "duration-150 ease-out",
} as const;

/* ─────────────────────────────────────────────────────────
 * HANDOFF NAV STORYBOARD
 *
 *  rest     a full-width bar on the page itself: logo at the start,
 *           links and a quiet outlined action at the end, 64px tall,
 *           no fill and no border
 *  dock     past 24px of scroll the bar tightens into a pill that
 *           floats 12px below the top. It narrows to fit what's in it
 *           and drops to 48px while the frosted fill, the hairline and
 *           a soft shadow fade in: one 0.4s spring, no bounce. Back at
 *           the top it spreads out again, quicker (0.3s)
 *  catch    the hero's button rides up the page, and the moment its
 *           top edge meets the pill's underside it lifts off. The pill
 *           widens to open a slot at its end (0.3s, the links slide aside)
 *           and the button flies into it, going from 44px to 32px tall
 *           on a 0.45s spring with the smallest settle. Its label only
 *           travels, it never scales. It slides along under the pill
 *           and rises into the slot last, so it never crosses the
 *           links. The hero keeps an empty space the button's size, so
 *           nothing under it moves
 *  release  scroll back until that space is 8px clear of the pill and
 *           the button drops out of the pill and flies home (0.38s, no
 *           bounce), following the space as the page moves; the slot
 *           closes behind it
 *  again    reversing mid-flight turns the button round from wherever
 *           it is. A button that's far off screen (a jump to the
 *           bottom) fades into the slot instead of crossing the page
 *  hover    a soft highlight slides from link to link under a mouse
 *  menu     on narrow screens the links fold into a menu button. The
 *           pill keeps its width, so the button stays under the finger,
 *           and grows down into a panel (0.4s, a slight settle); its
 *           links rise 8px into place 30ms apart and tuck back in,
 *           last first, when it closes (0.3s)
 *  keys     Tab runs logo → links → action → caught button → menu. Esc
 *           closes the menu and puts focus back on its button. A
 *           focused hero button hands its focus to the caught copy
 *  reduced  nothing travels: the bar swaps to the pill at once, the
 *           button fades out of the hero while its copy fades into the
 *           pill, and the menu opens without growing
 * ───────────────────────────────────────────────────────── */

const NAV = {
  rest: 64, // px; the bar's height at the top of the page
  docked: 48, // px; the pill's height
  top: 12, // px; the pill's distance from the top once docked
  padStart: 16, // px inside the pill before the logo
  padEnd: 8, // px inside the pill after the last item
  gap: 32, // px; the least room between the logo and the links in the pill
  slotGap: 8, // px between the links and the caught button
  clear: 8, // px the hero's space must clear below the pill before the button flies home
  rise: 8, // px a menu link rises into place from
} as const;

type Why = "dock" | "undock" | "catch" | "release" | "open" | "close";
type Mode = { docked: boolean; caught: boolean; open: boolean; why: Why };
type Rect = { x: number; y: number; w: number; h: number };
type Sizes = { rest: number; logo: number; group: number; menu: number; cta: number };

const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
// A flight in two legs, so the button never crosses the links. Going up it slides along under the
// pill first and rises into its slot last; going home it drops out of the pill first and then
// slides to its place. Each leg eases out over its share of the spring's progress, and past 1
// (the spring's settle) both run straight, so the overshoot stays small.
const leg = (t: number, start: number, end: number) => {
  if (t >= 1) return t;
  const u = Math.min(1, Math.max(0, (t - start) / (end - start)));
  return 1 - (1 - u) * (1 - u);
};
const flightRect = (a: Rect, b: Rect, t: number, to: "nav" | "home"): Rect => ({
  x: lerp(a.x, b.x, to === "nav" ? leg(t, 0, 0.7) : leg(t, 0.3, 1)),
  y: lerp(a.y, b.y, to === "nav" ? leg(t, 0.55, 1) : leg(t, 0, 0.45)),
  w: lerp(a.w, b.w, t),
  h: lerp(a.h, b.h, t),
});
const toRect = (r: DOMRect): Rect => ({ x: r.left, y: r.top, w: r.width, h: r.height });

/* ── Handoff store ──────────────────────────────────────── */

// What the hero's button tells the nav: how to draw its copy, and where it lives. One store per
// provider, so two navs on one page (or one docs page) never catch each other's buttons.
type Cta = {
  label: React.ReactNode;
  href?: string;
  onClick?: React.MouseEventHandler<HTMLElement>;
  className?: string;
};
type HandoffState = { cta: Cta | null; away: boolean };

const EMPTY: HandoffState = { cta: null, away: false };

function createHandoff() {
  let state = EMPTY;
  let home: HTMLElement | null = null;
  const listeners = new Set<() => void>();
  return {
    get: () => state,
    set(patch: Partial<HandoffState>) {
      state = { ...state, ...patch };
      listeners.forEach((listener) => listener());
    },
    subscribe(listener: () => void) {
      listeners.add(listener);
      return () => {
        listeners.delete(listener);
      };
    },
    home: () => home,
    setHome(element: HTMLElement | null) {
      home = element;
    },
  };
}

type Handoff = ReturnType<typeof createHandoff>;

const HandoffContext = React.createContext<Handoff | null>(null);
const noSubscribe = () => () => {};

function useHandoffState(store: Handoff | null) {
  return React.useSyncExternalStore(
    store?.subscribe ?? noSubscribe,
    () => store?.get() ?? EMPTY,
    () => EMPTY,
  );
}

/* ── Types ──────────────────────────────────────────────── */

export interface HandoffNavLink {
  label: string;
  href: string;
}

export interface HandoffNavProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
  /** Your logo or wordmark, at the start of the bar. Wrap it in a link to your home page. */
  logo: React.ReactNode;
  /** The page links. They sit at the end of the bar and fold into a menu on narrow screens. */
  links?: HandoffNavLink[];
  /** One quieter action after the links, like "Log in", drawn as an outlined pill. */
  action?: HandoffNavLink;
  /** Pixels of scroll before the bar docks into the pill. */
  dockAt?: number;
  /** Seconds the bar takes to tighten into the pill. Spreading out again at the top stays a little quicker. */
  dockDuration?: number;
  /** Seconds the hero's button takes to fly into the pill. The flight home scales with it. */
  catchDuration?: number;
  /** How much the caught button overshoots its slot and settles, from 0 (none) up. */
  catchBounce?: number;
  /** The element that scrolls, when it isn't the window. The nav then sticks to the top of that box: put it first inside it. */
  containerRef?: React.RefObject<HTMLElement | null>;
  /** Classes for the docked pill's surface: its fill, border and shadow. */
  surfaceClassName?: string;
}

export interface HandoffNavCtaProps {
  /** The button's label. The same content is drawn in the nav once it's caught. */
  children: React.ReactNode;
  /** Where the button goes. Without it, it's a `<button>`. */
  href?: string;
  /** Called when either copy is clicked, in the hero or in the nav. */
  onClick?: React.MouseEventHandler<HTMLElement>;
  /** Classes for both copies. Target one with `data-[placement=hero]:` or `data-[placement=nav]:`. */
  className?: string;
}

/* ── CtaButton ──────────────────────────────────────────── */

// The button itself, drawn in the hero and again in the nav. Its contents sit in one span, so the
// label keeps its size and only the button around it changes during a flight.
function CtaButton({
  cta,
  placement,
  className,
  ref,
  labelRef,
}: {
  cta: Cta;
  placement: "hero" | "nav";
  className?: string;
  ref?: React.Ref<HTMLElement>;
  labelRef?: React.Ref<HTMLSpanElement>;
}) {
  const classes = cn(
    "inline-flex shrink-0 items-center justify-center rounded-full text-sm font-medium whitespace-nowrap",
    `bg-zinc-950 text-white transition-[background-color,scale] ${TIMING.press} hover:bg-zinc-800 active:scale-[0.97]`,
    "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950",
    "dark:bg-white dark:text-zinc-950 dark:hover:bg-zinc-200 dark:focus-visible:outline-white",
    placement === "hero" ? "h-11 px-5" : "h-8 px-3.5",
    cta.className,
    className,
  );
  const label = (
    <span ref={labelRef} className="inline-flex items-center gap-1.5">
      {cta.label}
    </span>
  );
  if (cta.href) {
    return (
      <a
        ref={ref as React.Ref<HTMLAnchorElement>}
        href={cta.href}
        onClick={cta.onClick}
        data-placement={placement}
        className={classes}
      >
        {label}
      </a>
    );
  }
  return (
    <button
      ref={ref as React.Ref<HTMLButtonElement>}
      type="button"
      onClick={cta.onClick}
      data-placement={placement}
      className={classes}
    >
      {label}
    </button>
  );
}

/* ── HandoffNavProvider ─────────────────────────────────── */

/** Wraps the nav and the hero, so the hero's button can find the nav that catches it. */
export function HandoffNavProvider({ children }: { children: React.ReactNode }) {
  const [store] = React.useState(createHandoff);
  return <HandoffContext.Provider value={store}>{children}</HandoffContext.Provider>;
}

/* ── HandoffNavCta ──────────────────────────────────────── */

/**
 * The hero's call to action. It renders the button where you put it, and when it rides up behind
 * the docked nav, the nav catches it. Its place in the hero stays reserved while it's away.
 */
export function HandoffNavCta({ children, href, onClick, className }: HandoffNavCtaProps) {
  const store = React.useContext(HandoffContext);
  const { away } = useHandoffState(store);
  const reduced = useReducedMotion() ?? false;
  const homeRef = React.useRef<HTMLSpanElement>(null);

  React.useEffect(() => {
    if (!store) return;
    store.setHome(homeRef.current);
    return () => {
      store.setHome(null);
      store.set({ cta: null, away: false });
    };
  }, [store]);

  React.useEffect(() => {
    store?.set({ cta: { label: children, href, onClick, className } });
  }, [store, children, href, onClick, className]);

  return (
    <span ref={homeRef} className="inline-flex">
      {/* Out of view while the nav holds the button; the span keeps its place in the hero. */}
      <motion.span
        className="inline-flex"
        initial={false}
        animate={away ? { opacity: 0, transitionEnd: { visibility: "hidden" } } : { opacity: 1, visibility: "visible" }}
        transition={reduced ? TIMING.fade : { duration: 0 }}
      >
        <CtaButton cta={{ label: children, href, onClick, className }} placement="hero" />
      </motion.span>
    </span>
  );
}

/* ── HandoffNav ─────────────────────────────────────────── */

/**
 * A navbar that docks into a floating pill once the page scrolls, and catches the hero's
 * `HandoffNavCta` as it slides up behind it.
 */
export function HandoffNav({
  logo,
  links = [],
  action,
  dockAt = 24,
  dockDuration = TIMING.dock.visualDuration,
  catchDuration = TIMING.catch.visualDuration,
  catchBounce = TIMING.catch.bounce,
  containerRef,
  surfaceClassName,
  className,
  ...props
}: HandoffNavProps) {
  const store = React.useContext(HandoffContext);
  const { cta } = useHandoffState(store);
  const hasCta = cta !== null;
  const reduced = useReducedMotion() ?? false;
  const panelId = React.useId();

  const [mode, setMode] = React.useState<Mode>({ docked: false, caught: false, open: false, why: "dock" });
  const [sizes, setSizes] = React.useState<Sizes | null>(null);
  const [hover, setHover] = React.useState({ x: 0, w: 0, on: false, jump: true });

  const modeRef = React.useRef(mode);
  const sizesRef = React.useRef<Sizes | null>(null);
  const trackRef = React.useRef<HTMLDivElement>(null);
  const pillRef = React.useRef<HTMLDivElement>(null);
  const logoRef = React.useRef<HTMLDivElement>(null);
  const groupRef = React.useRef<HTMLDivElement>(null);
  const menuRef = React.useRef<HTMLSpanElement>(null);
  const menuButtonRef = React.useRef<HTMLButtonElement>(null);
  const panelRef = React.useRef<HTMLDivElement>(null);
  const slotRef = React.useRef<HTMLDivElement>(null);
  const flyerRef = React.useRef<HTMLElement>(null);
  const labelRef = React.useRef<HTMLSpanElement>(null);
  const flight = React.useRef<{ controls: AnimationPlaybackControls | null; rect: Rect | null }>({
    controls: null,
    rect: null,
  });
  const reducedRef = React.useRef(reduced);

  React.useEffect(() => {
    reducedRef.current = reduced;
  }, [reduced]);

  const commit = React.useCallback((next: Mode) => {
    modeRef.current = next;
    setMode(next);
  }, []);

  const setOpen = React.useCallback(
    (open: boolean) => {
      if (modeRef.current.open === open) return;
      commit({ ...modeRef.current, open, why: open ? "open" : "close" });
    },
    [commit],
  );

  // Measure what the pill has to hold. The links and the action keep their own widths whatever
  // the pill is doing, so the docked width is a sum and never a guess.
  React.useEffect(() => {
    const track = trackRef.current;
    if (!track) return;
    const measure = () => {
      const flyer = flyerRef.current;
      const label = labelRef.current;
      // The label keeps its own width during a flight, so this holds even mid-air.
      let cta = 0;
      if (flyer && label) {
        const style = getComputedStyle(flyer);
        cta = label.offsetWidth + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
      }
      const next: Sizes = {
        rest: track.clientWidth,
        logo: logoRef.current?.offsetWidth ?? 0,
        group: groupRef.current?.offsetWidth ?? 0,
        menu: menuRef.current?.offsetWidth ?? 0,
        cta,
      };
      // The menu button is gone once the links fit: a menu left open would have nothing to hold.
      if (!next.menu && modeRef.current.open) setOpen(false);
      const prev = sizesRef.current;
      if (
        prev &&
        prev.rest === next.rest &&
        prev.logo === next.logo &&
        prev.group === next.group &&
        prev.menu === next.menu &&
        prev.cta === next.cta
      )
        return;
      sizesRef.current = next;
      setSizes(next);
    };
    // The observer reports each element once on observe, which is the first measure.
    let live = true;
    const observer = new ResizeObserver(measure);
    for (const element of [track, logoRef.current, groupRef.current, menuRef.current, labelRef.current]) {
      if (element) observer.observe(element);
    }
    document.fonts?.ready.then(() => {
      if (live) measure();
    });
    return () => {
      live = false;
      observer.disconnect();
    };
  }, [hasCta, setOpen]);

  // Watch the scroll: dock past `dockAt`, catch the hero's button when it meets the pill's
  // underside, and send it home once its space is clear again.
  React.useEffect(() => {
    const scroller = containerRef?.current ?? null;
    const source: HTMLElement | Window = scroller ?? window;

    const fly = (to: "nav" | "home") => {
      const flyer = flyerRef.current;
      const slot = slotRef.current;
      const home = store?.home();
      if (!flyer || !slot || !home || !store) return;
      const sizes = sizesRef.current;
      const navWidth = sizes?.cta || flyer.offsetWidth;
      const navHeight = parseFloat(getComputedStyle(flyer).minHeight) || 32;
      const hadFocus = (to === "nav" ? home : flyer).contains(document.activeElement);
      const homeRect = () => toRect(home.getBoundingClientRect());
      // The slot's live rect: the pill may still be docking or widening while the button flies.
      const navRect = (): Rect => {
        const s = slot.getBoundingClientRect();
        return { x: s.right - navWidth, y: s.top + (s.height - navHeight) / 2, w: navWidth, h: navHeight };
      };
      const place = (r: Rect) => {
        flight.current.rect = r;
        const s = slot.getBoundingClientRect();
        flyer.style.transform = `translate(${r.x - (s.right - r.w)}px, ${r.y - s.top}px)`;
        flyer.style.width = `${r.w}px`;
        flyer.style.height = `${r.h}px`;
      };
      const settle = () => {
        flyer.style.transform = "";
        flyer.style.width = "";
        flyer.style.height = "";
      };

      flight.current.controls?.stop();
      flight.current.controls = null;

      const viewTop = scroller ? scroller.getBoundingClientRect().top : 0;
      const viewBottom = scroller ? scroller.getBoundingClientRect().bottom : window.innerHeight;
      const from = flight.current.rect ?? (to === "nav" ? homeRect() : navRect());
      const offscreen = from.y + from.h < viewTop || from.y > viewBottom;

      if (reducedRef.current || (offscreen && !flight.current.rect)) {
        // Nothing crosses the page: the copy fades in (or out) in its slot.
        flight.current.rect = null;
        settle();
        if (to === "nav") {
          flyer.style.opacity = "0";
          flyer.style.visibility = "visible";
          store.set({ away: true });
          // Without reduced motion the slot is still opening: wait until it has room.
          const fading = animate(flyer, { opacity: [0, 1] }, { ...TIMING.fade, delay: reducedRef.current ? 0 : TIMING.slotWait });
          flight.current.controls = fading;
          fading.then(() => {
            if (flight.current.controls === fading) flight.current.controls = null;
          });
          if (hadFocus) flyer.focus({ preventScroll: true });
        } else {
          store.set({ away: false });
          const fading = animate(flyer, { opacity: [1, 0] }, TIMING.fade);
          flight.current.controls = fading;
          fading.then(() => {
            if (flight.current.controls !== fading) return;
            flight.current.controls = null;
            if (!modeRef.current.caught) flyer.style.visibility = "";
          });
          if (hadFocus) home.querySelector<HTMLElement>("a,button")?.focus({ preventScroll: true });
        }
        return;
      }

      // Put the copy exactly over its starting point first, then hide the original, so the hand
      // between the two is invisible.
      flyer.style.opacity = "1";
      flyer.style.visibility = "visible";
      place(from);
      if (to === "nav") {
        store.set({ away: true });
        if (hadFocus) flyer.focus({ preventScroll: true });
      }
      const target = to === "nav" ? navRect : homeRect;
      const controls = animate(0, 1, {
        ...(to === "nav"
          ? { ...TIMING.catch, visualDuration: catchDuration, bounce: catchBounce }
          : { ...TIMING.release, visualDuration: TIMING.release.visualDuration * (catchDuration / TIMING.catch.visualDuration) }),
        onUpdate: (t) => place(flightRect(from, target(), t, to)),
        onComplete: () => {
          if (flight.current.controls !== controls) return;
          flight.current.controls = null;
          flight.current.rect = null;
          if (to === "nav") {
            settle();
            return;
          }
          store.set({ away: false });
          // A frame or two with both drawn in the same spot, never a frame with neither.
          requestAnimationFrame(() =>
            requestAnimationFrame(() => {
              if (modeRef.current.caught || flight.current.controls) return;
              settle();
              flyer.style.visibility = "";
              if (hadFocus) home.querySelector<HTMLElement>("a,button")?.focus({ preventScroll: true });
            }),
          );
        },
      });
      flight.current.controls = controls;
    };

    let frame = 0;
    const check = () => {
      frame = 0;
      const top = scroller ? scroller.scrollTop : window.scrollY;
      const docked = top > dockAt;
      const prev = modeRef.current;
      let caught = prev.caught;
      const home = store?.home();
      if (!store || !store.get().cta || !home) {
        caught = false;
      } else {
        const r = home.getBoundingClientRect();
        const line = (scroller ? scroller.getBoundingClientRect().top : 0) + NAV.top + NAV.docked;
        if (!docked || (r.width === 0 && r.height === 0)) caught = false;
        else if (!caught && r.top < line) caught = true;
        else if (caught && r.top > line + NAV.clear) caught = false;
      }
      if (docked === prev.docked && caught === prev.caught) return;
      const why: Why = caught !== prev.caught ? (caught ? "catch" : "release") : docked ? "dock" : "undock";
      commit({ ...prev, docked, caught, why });
      if (caught !== prev.caught) fly(caught ? "nav" : "home");
    };
    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(check);
    };

    source.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    // A page that loads already scrolled (a restored position) docks and catches straight away.
    onScroll();
    return () => {
      source.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
      cancelAnimationFrame(frame);
    };
  }, [containerRef, dockAt, store, hasCta, commit, catchDuration, catchBounce]);

  React.useEffect(() => () => flight.current.controls?.stop(), []);

  // The open menu: Esc and a press outside close it, and focus moves into the panel.
  React.useEffect(() => {
    if (!mode.open) return;
    const onKey = (event: KeyboardEvent) => {
      if (event.key !== "Escape") return;
      setOpen(false);
      menuButtonRef.current?.focus();
    };
    const onDown = (event: PointerEvent) => {
      if (!pillRef.current?.contains(event.target as Node)) setOpen(false);
    };
    document.addEventListener("keydown", onKey);
    document.addEventListener("pointerdown", onDown);
    const frame = requestAnimationFrame(() =>
      panelRef.current?.querySelector<HTMLElement>("a")?.focus({ preventScroll: true }),
    );
    return () => {
      document.removeEventListener("keydown", onKey);
      document.removeEventListener("pointerdown", onDown);
      cancelAnimationFrame(frame);
    };
  }, [mode.open, setOpen]);

  const surface = mode.docked || mode.open;
  const caught = mode.caught && hasCta;
  const slotWidth = caught && sizes ? NAV.slotGap + sizes.cta : 0;
  const dockedWidth = sizes
    ? Math.min(
        sizes.rest,
        NAV.padStart + sizes.logo + NAV.gap + sizes.group + slotWidth + sizes.menu + NAV.padEnd,
      )
    : 0;
  // An open menu keeps the pill's width, so the close button stays under the finger that opened it.
  const width = !sizes ? "100%" : mode.docked ? dockedWidth : sizes.rest;
  // The slot opens ahead of a caught button and closes just behind a released one.
  const move = reduced
    ? { duration: 0 }
    : mode.why === "catch"
      ? TIMING.make
      : mode.why === "release"
        ? TIMING.shut
        : mode.why === "dock"
          ? { ...TIMING.dock, visualDuration: dockDuration }
          : mode.why === "undock"
            ? { ...TIMING.undock, visualDuration: TIMING.undock.visualDuration * (dockDuration / TIMING.dock.visualDuration) }
            : TIMING[mode.why];
  const panelMove = reduced ? { duration: 0 } : mode.open ? TIMING.open : TIMING.close;
  // A menu row: it rises into place, or with reduced motion only fades (its rise jumps).
  const rowMove = (delay: number) =>
    reduced
      ? { default: { duration: 0 }, opacity: { ...TIMING.fade, delay } }
      : { ...(mode.open ? TIMING.open : TIMING.close), delay };

  return (
    <header
      {...props}
      className={cn(
        "pointer-events-none z-50 block h-16",
        containerRef ? "sticky top-0" : "relative",
        className,
      )}
    >
      <div className={cn("@container/handoff-nav inset-x-0 top-0", containerRef ? "absolute" : "fixed z-50")}>
        <div className="mx-auto w-full max-w-[var(--handoff-nav-width,72rem)] px-4 @xl/handoff-nav:px-6">
          <div ref={trackRef} className="w-full">
            <motion.div
              ref={pillRef}
              className="pointer-events-auto relative mx-auto"
              initial={false}
              animate={{ width, y: surface ? NAV.top : 0 }}
              transition={move}
            >
              <motion.div
                aria-hidden
                className={cn(
                  "absolute inset-0 rounded-[24px] border border-zinc-950/[0.08] bg-white/80 shadow-[0_10px_30px_-14px_rgb(9_9_11/0.3)] backdrop-blur-xl",
                  "dark:border-white/10 dark:bg-zinc-900/80 dark:shadow-[0_10px_30px_-12px_rgb(0_0_0/0.7)]",
                  surfaceClassName,
                )}
                initial={false}
                animate={{ opacity: surface ? 1 : 0 }}
                transition={TIMING.fill}
              />

              <motion.nav
                aria-label="Main"
                className="relative flex items-center"
                initial={false}
                animate={{
                  height: surface ? NAV.docked : NAV.rest,
                  paddingLeft: surface ? NAV.padStart : 0,
                  paddingRight: surface ? NAV.padEnd : 0,
                }}
                transition={move}
              >
                <div ref={logoRef} className="flex shrink-0 items-center">
                  {logo}
                </div>

                <div className="ml-auto flex shrink-0 items-center">
                  <div
                    ref={groupRef}
                    className="hidden shrink-0 items-center gap-1 @2xl/handoff-nav:flex"
                    onPointerLeave={() => setHover((h) => ({ ...h, on: false }))}
                  >
                    {links.length > 0 && (
                      <div className="relative flex items-center">
                        <motion.span
                          aria-hidden
                          className="pointer-events-none absolute top-0 left-0 h-8 rounded-full bg-zinc-950/[0.05] dark:bg-white/[0.08]"
                          initial={false}
                          animate={{ x: hover.x, width: hover.w, opacity: hover.on ? 1 : 0 }}
                          transition={
                            hover.jump || reduced
                              ? { x: { duration: 0 }, width: { duration: 0 }, opacity: TIMING.fade }
                              : { x: TIMING.hover, width: TIMING.hover, opacity: TIMING.fade }
                          }
                        />
                        {links.map((link) => (
                          <a
                            key={link.href + link.label}
                            href={link.href}
                            onPointerEnter={(event) => {
                              if (event.pointerType !== "mouse") return;
                              const el = event.currentTarget;
                              setHover((h) => ({ x: el.offsetLeft, w: el.offsetWidth, on: true, jump: !h.on }));
                            }}
                            className={`relative flex h-8 items-center rounded-full px-3 text-sm text-zinc-600 transition-colors ${TIMING.color} hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950 dark:text-zinc-400 dark:hover:text-white dark:focus-visible:outline-white`}
                          >
                            {link.label}
                          </a>
                        ))}
                      </div>
                    )}
                    {action && (
                      <a
                        href={action.href}
                        className={`ml-1 flex h-8 items-center rounded-full border border-zinc-950/10 px-3.5 text-sm font-medium text-zinc-900 transition-colors ${TIMING.color} hover:bg-zinc-950/[0.04] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950 dark:border-white/15 dark:text-zinc-100 dark:hover:bg-white/[0.06] dark:focus-visible:outline-white`}
                      >
                        {action.label}
                      </a>
                    )}
                  </div>

                  {cta && (
                    <motion.div
                      ref={slotRef}
                      className="relative h-8 shrink-0"
                      initial={false}
                      animate={{ width: slotWidth }}
                      transition={move}
                    >
                      <CtaButton
                        ref={flyerRef}
                        labelRef={labelRef}
                        cta={cta}
                        placement="nav"
                        className="invisible absolute top-0 right-0 min-h-8"
                      />
                    </motion.div>
                  )}

                  <span
                    ref={menuRef}
                    className={cn("flex shrink-0 pl-1 @2xl/handoff-nav:hidden", !links.length && !action && "hidden")}
                  >
                    <button
                      ref={menuButtonRef}
                      type="button"
                      aria-expanded={mode.open}
                      aria-controls={panelId}
                      aria-label={mode.open ? "Close menu" : "Open menu"}
                      onClick={() => setOpen(!modeRef.current.open)}
                      className={`relative grid size-10 place-items-center rounded-full text-zinc-900 transition-colors ${TIMING.color} hover:bg-zinc-950/[0.05] focus-visible:outline-2 focus-visible:outline-zinc-950 dark:text-zinc-100 dark:hover:bg-white/[0.08] dark:focus-visible:outline-white`}
                    >
                      {[-1, 1].map((side) => (
                        <motion.span
                          key={side}
                          aria-hidden
                          className="absolute h-[1.5px] w-4 rounded-full bg-current"
                          initial={false}
                          animate={mode.open ? { y: 0, rotate: side * 45 } : { y: side * 3.5, rotate: 0 }}
                          transition={reduced ? { duration: 0 } : panelMove}
                        />
                      ))}
                    </button>
                  </span>
                </div>
              </motion.nav>

              <motion.div
                id={panelId}
                ref={panelRef}
                className="relative overflow-hidden"
                initial={false}
                animate={{ height: mode.open ? "auto" : 0 }}
                transition={panelMove}
                inert={!mode.open}
              >
                <ul className="flex flex-col px-2 pt-1 pb-2">
                  {links.map((link, index) => (
                    <motion.li
                      key={link.href + link.label}
                      initial={false}
                      animate={mode.open ? { opacity: 1, y: 0 } : { opacity: 0, y: -NAV.rise }}
                      transition={rowMove(mode.open ? TIMING.stagger * (index + 1) : TIMING.tuck * (links.length - 1 - index))}
                    >
                      <a
                        href={link.href}
                        onClick={() => setOpen(false)}
                        className={`flex h-11 items-center rounded-2xl px-3 text-[15px] text-zinc-800 transition-colors ${TIMING.color} hover:bg-zinc-950/[0.04] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-zinc-950 dark:text-zinc-200 dark:hover:bg-white/[0.06] dark:focus-visible:outline-white`}
                      >
                        {link.label}
                      </a>
                    </motion.li>
                  ))}
                  {action && (
                    <motion.li
                      className="mt-1 px-1"
                      initial={false}
                      animate={mode.open ? { opacity: 1, y: 0 } : { opacity: 0, y: -NAV.rise }}
                      transition={rowMove(mode.open ? TIMING.stagger * (links.length + 1) : 0)}
                    >
                      <a
                        href={action.href}
                        onClick={() => setOpen(false)}
                        className={`flex h-11 items-center justify-center rounded-full border border-zinc-950/10 text-[15px] font-medium text-zinc-900 transition-colors ${TIMING.color} hover:bg-zinc-950/[0.04] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950 dark:border-white/15 dark:text-zinc-100 dark:hover:bg-white/[0.06] dark:focus-visible:outline-white`}
                      >
                        {action.label}
                      </a>
                    </motion.li>
                  )}
                </ul>
              </motion.div>
            </motion.div>
          </div>
        </div>
      </div>
    </header>
  );
}
```
