# Handoff Nav: 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.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/handoff-nav-store-demo).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/handoff-nav-store-demo
- Screenshot: https://shadcn.exchange/shot/keyframeui/handoff-nav-store-demo/light.png
- Registry item: https://keyframeui.com/r/handoff-nav-store-demo.json
- Categories: navigation, scroll, interaction
- Listed: 2026-11-02, source last changed 2026-11-02

## Install

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

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://keyframeui.com/r/handoff-nav.json`

## Notes from the registry

Live preview, examples and props: https://keyframeui.com/components/handoff-nav#handoff-nav-store-demo

## Files

### `registry/demos/handoff-nav-store-demo.tsx`

```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>
  );
}
```
