Minimal sticky header

Wordmark, centered links, and a cart badge that springs on change. Hairline appears on scroll.

blockminimalanycart-countsearch
$npx shadcn@latest add @storekit/nav-minimal
nav-minimal

Live and interactive. Switch the brand in the frame header to see the same source under another token file.

Installation

$npx shadcn@latest add @storekit/nav-minimal
$pnpm dlx shadcn@latest add @storekit/nav-minimal
$yarn dlx shadcn@latest add @storekit/nav-minimal
$bunx --bun shadcn@latest add @storekit/nav-minimal

Source

components/storekit/nav/nav-minimal.tsx
133 lines
"use client"

import { useEffect, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Menu, Search, ShoppingBag, User } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Minimal sticky header: wordmark left, links centered, actions right. The
   hairline under it only appears once the page has scrolled, and the cart
   badge springs each time the count changes. Pass `scrollRoot` when the
   header lives inside a scrolling element instead of the window. Below md the
   links collapse behind a menu button that calls `onMenu` (pair it with a
   drawer). */

type NavLink = { label: string; href: string }

const ring =
  "outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper"

export function NavMinimal({
  brandName = "Morrow",
  markSrc,
  links = [
    { label: "Shop", href: "#shop" },
    { label: "Bundles", href: "#bundles" },
    { label: "Lab results", href: "#lab-results" },
    { label: "Journal", href: "#journal" },
  ],
  cartCount = 0,
  onSearch,
  onAccount,
  onCart,
  onMenu,
  scrollRoot,
  className,
}: {
  brandName?: string
  markSrc?: string
  links?: NavLink[]
  cartCount?: number
  onSearch?: () => void
  onAccount?: () => void
  onCart?: () => void
  onMenu?: () => void
  scrollRoot?: HTMLElement | null
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("nav", "nav-minimal")
  const [scrolled, setScrolled] = useState(false)

  useEffect(() => {
    const target: HTMLElement | Window = scrollRoot ?? window
    const read = () => setScrolled((scrollRoot ? scrollRoot.scrollTop : window.scrollY) > 4)
    read()
    target.addEventListener("scroll", read, { passive: true })
    return () => target.removeEventListener("scroll", read)
  }, [scrollRoot])

  const icon = cn("grid h-11 w-11 place-items-center rounded-sk-btn text-sk-ink transition-colors duration-200 hover:bg-sk-ink/[0.05] active:scale-[0.97]", ring)

  return (
    <header
      ref={sk.ref}
      {...sk.attrs}
      className={cn(
        "sticky top-0 z-30 border-b bg-sk-paper/95 transition-colors duration-300",
        scrolled ? "border-sk-line" : "border-transparent",
        className
      )}
    >
      <div className="mx-auto flex h-16 max-w-6xl items-center gap-2 px-2 sm:gap-4 sm:px-8">
        <button type="button" aria-label="Open menu" aria-haspopup="dialog" onClick={onMenu} className={cn(icon, "md:hidden")}>
          <Menu size={20} aria-hidden />
        </button>
        <a href="/" className={cn("flex min-h-11 min-w-0 items-center gap-2.5 rounded-sk-btn pr-1", ring)}>
          {markSrc ? <img src={markSrc} alt="" className="h-7 w-7 shrink-0 object-contain" /> : null}
          <span className="truncate font-sk-display text-[15px] font-semibold tracking-[-0.02em] text-sk-ink">{brandName}</span>
        </a>
        <nav aria-label="Primary" className="hidden flex-1 justify-center md:flex">
          <ul className="flex items-center gap-1">
            {links.map((l) => (
              <li key={l.href}>
                <a
                  href={l.href}
                  className={cn("inline-flex h-11 items-center rounded-sk-btn px-3 text-sm text-sk-muted transition-colors duration-200 hover:text-sk-ink", ring)}
                >
                  {l.label}
                </a>
              </li>
            ))}
          </ul>
        </nav>
        <div className="ml-auto flex shrink-0 items-center md:ml-0">
          <button type="button" aria-label="Search" onClick={onSearch} className={icon}>
            <Search size={18} aria-hidden />
          </button>
          <button type="button" aria-label="Account" onClick={onAccount} className={cn(icon, "hidden sm:grid")}>
            <User size={18} aria-hidden />
          </button>
          <button
            type="button"
            aria-label={`Cart, ${cartCount} ${cartCount === 1 ? "item" : "items"}`}
            onClick={() => {
              sk.emit("interact", "cart_open")
              onCart?.()
            }}
            className={cn(icon, "relative")}
          >
            <ShoppingBag size={18} aria-hidden />
            <AnimatePresence initial={false}>
              {cartCount > 0 ? (
                <motion.span
                  key={cartCount}
                  aria-hidden
                  initial={reduce ? { opacity: 0 } : { scale: 0.4, opacity: 0 }}
                  animate={{ scale: 1, opacity: 1 }}
                  exit={{ scale: reduce ? 1 : 0.4, opacity: 0, transition: { duration: 0.12 } }}
                  transition={reduce ? { duration: 0.15 } : { type: "spring", stiffness: 520, damping: 16 }}
                  className="absolute right-1 top-1 grid h-[18px] min-w-[18px] place-items-center rounded-full bg-sk-accent px-1 text-[10px] font-semibold tabular-nums leading-none text-sk-accent-ink"
                >
                  {cartCount > 99 ? "99+" : cartCount}
                </motion.span>
              ) : null}
            </AnimatePresence>
          </button>
        </div>
      </div>
    </header>
  )
}

Usage

Pass scrollRoot when the header sits inside a scrolling element rather than the window.

Minimal sticky header, StoreKit