ShelfNav_Loveable_8_15.tsx
Anonymous
tsx
08/14/2026 1:43 PM
20.8 KB
1
Indexable
// ═══════════════════════════════════════════════════════════════
// ShelfNav — The Celiac Shelf
// Production component. React + Tailwind-free (own styles), no deps.
//
// Six real gluten-free pantry staples, each tied to its page.
// Deliberately CATEGORY-recognisable, not BRAND-recognisable:
// a celiac shopper reads these by silhouette, but no specific
// product's trade dress is copied. This is what the copy deck asks
// for ("without imitating commercial brands").
//
// Fidelity notes — these are what stop it reading as a picture book:
// seams, rims, fold lines, cellophane windows, twist ties, and a
// soft top-light overlay on every container. Line weight is 1.2–1.4,
// not the heavy uniform outline of sticker art.
// ═══════════════════════════════════════════════════════════════
import { useEffect, useState } from "react"
export type ShelfItem = { label: string; shape: Shape; href: string }
type Shape = "oats" | "pasta" | "can" | "bag" | "jar" | "loaf"
export const SHELF_ITEMS: ShelfItem[] = [
{ label: "Home", shape: "oats", href: "/" },
{ label: "About", shape: "pasta", href: "/about" },
{ label: "Our Impact", shape: "can", href: "/impact" },
{ label: "Get Involved", shape: "bag", href: "/get-involved" },
{ label: "Donate", shape: "jar", href: "/donate" },
{ label: "News & Updates", shape: "loaf", href: "/news" },
]
/* Shared geometry — every silhouette reserves the same label band
(x12 y46 w40 h26 in a 64×84 viewBox) so the HTML label always
lands on it regardless of shape. Don't move these without moving
the .csn-label percentages to match. */
const Band = () => (
<>
<rect x="12" y="46" width="40" height="26" rx="2.5"
fill="#FFFDF8" stroke="#23262D" strokeOpacity={0.22} strokeWidth={0.9} />
<rect x="12" y="46" width="40" height="26" rx="2.5" fill="url(#csnSheen)" opacity={0.5} />
</>
)
const Drop = () => <ellipse className="csn-drop" cx="32" cy="83.5" rx="23" ry="3" fill="#23262D" />
const OUT = { stroke: "#23262D", strokeOpacity: 0.34, strokeWidth: 1.3 } as const
const SEAM = { stroke: "#23262D", strokeOpacity: 0.14, strokeWidth: 0.9, fill: "none" } as const
/* small original "GF" roundel — NOT the GFCO crossed-grain mark,
which is a registered certification trademark */
const Gf = ({ x, y }: { x: number; y: number }) => (
<g transform={`translate(${x},${y})`}>
<circle r="5.2" fill="#FFFDF8" stroke="#23262D" strokeOpacity={0.34} strokeWidth={1} />
<text x="0" y="2" textAnchor="middle" fontFamily="Inter, sans-serif"
fontWeight={700} fontSize="5.1" fill="#23262D">GF</text>
</g>
)
function Art({ shape }: { shape: Shape }) {
switch (shape) {
/* HOME — certified GF oat canister. Cylinder: elliptical lid,
vertical seam, house motif. */
case "oats":
return (
<>
<Drop />
<g className="csn-lid">
<ellipse cx="32" cy="13.5" rx="18.5" ry="4.4" fill="#41454E" />
<rect x="13.5" y="13.5" width="37" height="5.2" rx="2" fill="#363A42" />
<ellipse cx="32" cy="13.5" rx="12" ry="2.6" fill="#4E535C" />
</g>
<path d="M13.5 18h37v58a5 5 0 0 1-5 5H18.5a5 5 0 0 1-5-5z" fill="#EFE0C0" {...OUT} />
<path d="M20 19v62M44 19v62" {...SEAM} />
<path d="M32 24.5l10 8.2v8.8H22v-8.8z" fill="#FBBF3F"
stroke="#23262D" strokeOpacity={0.4} strokeWidth={1.1} strokeLinejoin="round" />
<rect x="29.6" y="35" width="4.8" height="6.5" fill="#FFFDF8"
stroke="#23262D" strokeOpacity={0.3} strokeWidth={0.8} />
<path d="M13.5 18h37v58a5 5 0 0 1-5 5H18.5a5 5 0 0 1-5-5z" fill="url(#csnLight)" />
<Band /><Gf x={45} y={24} />
</>
)
/* ABOUT — rice pasta box with the cellophane window. The window
is the single most recognisable GF-pasta cue. */
case "pasta":
return (
<>
<Drop />
<g className="csn-lid"><path d="M10 23.5V14.5l22 4v5z" fill="#E3D0A8" {...OUT} /></g>
<g className="csn-lid csn-lid-r"><path d="M54 23.5v-9l-22 4v5z" fill="#E3D0A8" {...OUT} /></g>
<rect x="10" y="23.5" width="44" height="57.5" rx="2" fill="#F3E6C9" {...OUT} />
<path d="M16 24v57" {...SEAM} />
<rect x="21" y="28.5" width="22" height="15" rx="1.5"
fill="#FFFDF8" stroke="#23262D" strokeOpacity={0.3} strokeWidth={1} />
<g stroke="#C98F2E" strokeWidth={1.5} strokeLinecap="round" fill="none" opacity={0.85}>
<path d="M25 32.5c0 4 0 5.5 0 7.5M29 32c0 4.5 0 6 0 8M33 32.5c0 4 0 5.5 0 7.5M37 32c0 4.5 0 6 0 8" />
</g>
<rect x="10" y="23.5" width="44" height="57.5" rx="2" fill="url(#csnLight)" />
<Band /><Gf x={46} y={29} />
</>
)
/* OUR IMPACT — canned goods, the staple of any food drive.
Rim + seam, rising bars. */
case "can":
return (
<>
<Drop />
<g className="csn-lid">
<ellipse cx="32" cy="20.5" rx="18.5" ry="4.6" fill="#A5DDD5" {...OUT} />
<ellipse cx="32" cy="20.5" rx="6.2" ry="1.9" fill="none"
stroke="#23262D" strokeOpacity={0.38} strokeWidth={1} />
</g>
<path d="M13.5 20.5v55.5a5 5 0 0 0 5 5h27a5 5 0 0 0 5-5V20.5z" fill="#C4EAE5" {...OUT} />
<path d="M13.5 24.5h37M13.5 77h37" {...SEAM} />
<g fill="#0E6B62">
<rect x="21" y="36.5" width="4.6" height="6" rx="0.8" />
<rect x="29" y="32" width="4.6" height="10.5" rx="0.8" />
<rect x="37" y="27.5" width="4.6" height="15" rx="0.8" />
</g>
<path d="M13.5 20.5v55.5a5 5 0 0 0 5 5h27a5 5 0 0 0 5-5V20.5z" fill="url(#csnLight)" />
<Band /><Gf x={46} y={27} />
</>
)
/* GET INVOLVED — paper grocery bag. Rolled top, gusset folds. */
case "bag":
return (
<>
<Drop />
<g className="csn-lid"><path d="M11 26.5l4.5-8L32 21.5v5.5z" fill="#DFC9A0" {...OUT} /></g>
<g className="csn-lid csn-lid-r"><path d="M53 26.5l-4.5-8L32 21.5v5.5z" fill="#DFC9A0" {...OUT} /></g>
<path d="M11 26.5h42v49.5a5 5 0 0 1-5 5H16a5 5 0 0 1-5-5z" fill="#EBD8B2" {...OUT} />
<path d="M21 27v54M43 27v54" {...SEAM} />
<path d="M32 32.5c-1.7-3.9-8-3.4-8 1.3 0 3.5 5.2 6.2 8 8.2 2.8-2 8-4.7 8-8.2 0-4.7-6.3-5.2-8-1.3z"
fill="#FBBF3F" stroke="#23262D" strokeOpacity={0.36} strokeWidth={1.1} />
<path d="M11 26.5h42v49.5a5 5 0 0 1-5 5H16a5 5 0 0 1-5-5z" fill="url(#csnLight)" />
<Band /><Gf x={46} y={31} />
</>
)
/* DONATE — the heart jar. Primary action: the only gold ring. */
case "jar":
return (
<>
<Drop />
<g className="csn-lid">
<rect x="17" y="8.5" width="30" height="6.8" rx="2.2" fill="#41454E" />
<rect x="13.5" y="14.5" width="37" height="5" rx="2" fill="#363A42" />
</g>
<rect x="13.5" y="19.5" width="37" height="61.5" rx="6" fill="#5CC4B8"
stroke="#E9A81F" strokeWidth={2.2} />
<path d="M13.5 25h37" {...SEAM} />
<rect x="18.5" y="26" width="4" height="13" rx="2" fill="#FFF" opacity={0.32} />
<path d="M32 30c-1.7-4.4-9-4-9 1.5 0 4 5.6 7.2 9 9.6 3.4-2.4 9-5.6 9-9.6 0-5.5-7.3-5.9-9-1.5z"
fill="#FBF8F1" />
<rect x="13.5" y="19.5" width="37" height="61.5" rx="6" fill="url(#csnLight)" />
<Band /><Gf x={46} y={28} />
</>
)
/* NEWS — GF sandwich loaf. The stubby, short-rise shape is the
giveaway; twist tie on top. */
case "loaf":
return (
<>
<Drop />
<g className="csn-lid">
<path d="M28 20c1.5-3 6.5-3 8 0" fill="none"
stroke="#23262D" strokeOpacity={0.5} strokeWidth={1.4} strokeLinecap="round" />
<rect x="27" y="12.5" width="10" height="7.5" rx="2.5" fill="#41454E" />
</g>
<path d="M9 81V43.5c0-9.5 10.5-15.5 23-15.5s23 6 23 15.5V81z" fill="#F2DFB6" {...OUT} />
<path d="M17 30.5V81M47 30.5V81" {...SEAM} />
<g fill="#23262D" opacity={0.3}>
<circle cx="24" cy="38" r="1.6" /><circle cx="32" cy="34.5" r="1.6" />
<circle cx="40" cy="38" r="1.6" />
</g>
<path d="M14.5 29.5l1.5 3.4 3.7.4-2.8 2.5.8 3.6-3.2-1.9-3.2 1.9.8-3.6-2.8-2.5 3.7-.4z"
fill="#FBBF3F" stroke="#23262D" strokeOpacity={0.3} strokeWidth={0.9} />
<path d="M9 81V43.5c0-9.5 10.5-15.5 23-15.5s23 6 23 15.5V81z" fill="url(#csnLight)" />
<Band /><Gf x={48} y={31} />
</>
)
}
}
export default function ShelfNav({
items = SHELF_ITEMS,
activePath = "/",
}: { items?: ShelfItem[]; activePath?: string }) {
const [stuck, setStuck] = useState(false)
useEffect(() => {
const onScroll = () => setStuck(window.scrollY > 40)
window.addEventListener("scroll", onScroll, { passive: true })
onScroll()
return () => window.removeEventListener("scroll", onScroll)
}, [])
const norm = (s: string) => (s || "").replace(/\/$/, "") || "/"
const anyActive = items.some(i => norm(i.href) === norm(activePath))
return (
<nav className={`csn${stuck ? " csn-stuck" : ""}${anyActive ? " csn-has-active" : ""}`}
aria-label="Main">
<style>{CSS}</style>
{/* shared paint defs, declared once */}
<svg width="0" height="0" aria-hidden="true" style={{ position: "absolute" }}>
<defs>
<linearGradient id="csnLight" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#fff" stopOpacity="0.34" />
<stop offset="42%" stopColor="#fff" stopOpacity="0.05" />
<stop offset="100%" stopColor="#23262D" stopOpacity="0.10" />
</linearGradient>
<linearGradient id="csnSheen" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#fff" stopOpacity="0.7" />
<stop offset="60%" stopColor="#fff" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
<div className="csn-inner">
<a className="csn-wordmark" href="/" aria-label="The Celiac Shelf — home">
<span className="csn-script">
Cel<span className="csn-i">i
<svg className="csn-heart" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 21s-8-5.1-8-10.4A4.6 4.6 0 0 1 12 7a4.6 4.6 0 0 1 8 3.6C20 15.9 12 21 12 21z" />
</svg>
</span>ac
</span>
<span className="csn-caps">SHELF</span>
</a>
<ul className="csn-items">
{items.map(it => {
const active = norm(it.href) === norm(activePath)
return (
<li key={it.href}>
<a className="csn-item" href={it.href}
aria-label={`Go to ${it.label}`}
aria-current={active ? "page" : undefined}>
<svg viewBox="0 0 64 84" role="presentation" focusable="false">
<Art shape={it.shape} />
</svg>
<span className="csn-label">{it.label}</span>
</a>
</li>
)
})}
</ul>
</div>
<div className="csn-plank" aria-hidden="true" />
</nav>
)
}
const CSS = `
.csn{position:fixed;top:0;left:0;right:0;z-index:100;
background:var(--paper,#FBF8F1);background:rgba(251,248,241,.84);
backdrop-filter:blur(16px) saturate(150%);-webkit-backdrop-filter:blur(16px) saturate(150%);
transition:box-shadow .4s cubic-bezier(.22,1,.36,1)}
.csn-stuck{box-shadow:0 12px 36px -20px rgba(35,38,45,.5)}
.csn-inner{max-width:1200px;margin:0 auto;padding:8px 24px 0;display:flex;align-items:flex-end;gap:26px}
.csn-wordmark{flex:0 0 auto;padding-bottom:14px;text-decoration:none;line-height:1}
.csn-script{position:relative;display:inline-block;font-family:var(--font-script,'Allura'),cursive;
font-size:44px;line-height:.86;color:var(--wordmark,#D19405)}
.csn-i{position:relative;display:inline-block}
.csn-heart{position:absolute;left:50%;top:-2px;transform:translateX(-50%);
width:11px;height:11px;color:var(--ink,#23262D)}
.csn-caps{display:block;font-family:var(--font-ui,'Inter'),sans-serif;font-weight:600;font-size:12px;
letter-spacing:.42em;color:var(--ink,#23262D);padding-left:4px;margin-top:1px}
.csn-items{flex:1;display:flex;justify-content:flex-end;align-items:flex-end;gap:4px;
list-style:none;margin:0;padding:0}
.csn-item{position:relative;display:block;width:80px;height:104px;text-decoration:none;
transition:transform .32s cubic-bezier(.22,1,.36,1),opacity .32s}
.csn-stuck .csn-item{width:70px;height:91px}
.csn-item svg{width:100%;height:100%;display:block;overflow:visible}
.csn-label{position:absolute;left:18.75%;top:54.8%;width:62.5%;height:31%;
display:flex;align-items:center;justify-content:center;text-align:center;pointer-events:none;
font-family:var(--font-ui,'Inter'),sans-serif;font-weight:700;font-size:8px;line-height:1.12;
letter-spacing:.02em;text-transform:uppercase;color:var(--ink,#23262D)}
.csn-stuck .csn-label{font-size:7.2px}
.csn-lid{transform-box:fill-box;transform-origin:0% 100%;
transition:transform .34s cubic-bezier(.22,1,.36,1)}
.csn-lid-r{transform-origin:100% 100%}
.csn-drop{opacity:.14;transition:opacity .32s}
.csn-item:hover,.csn-item:focus-visible{transform:translateY(-8px) scale(1.06);opacity:1}
.csn-item:hover .csn-lid,.csn-item:focus-visible .csn-lid{transform:rotate(-25deg) translateY(-2px)}
.csn-item:hover .csn-lid-r,.csn-item:focus-visible .csn-lid-r{transform:rotate(25deg) translateY(-2px)}
.csn-item:hover .csn-drop,.csn-item:focus-visible .csn-drop{opacity:.3}
.csn-item:focus-visible{outline:3px solid var(--aqua-ink,#0E6B62);outline-offset:4px;border-radius:8px}
.csn-item[aria-current="page"]{transform:translateY(-10px) scale(1.14)}
.csn-item[aria-current="page"] .csn-drop{opacity:.38}
.csn-item[aria-current="page"] .csn-lid{transform:rotate(-25deg) translateY(-2px)}
.csn-item[aria-current="page"] .csn-lid-r{transform:rotate(25deg) translateY(-2px)}
.csn-has-active .csn-item:not([aria-current="page"]){opacity:.66}
.csn-plank{position:relative;height:17px}
.csn-plank::before{content:"";position:absolute;top:0;left:0;right:0;bottom:5px;
background:linear-gradient(180deg,#A9793F 0%,#8F6433 42%,#6E4C26 100%);border-radius:2px 2px 0 0}
.csn-plank::after{content:"";position:absolute;left:0;right:0;bottom:0;height:6px;
background:linear-gradient(180deg,#6E4C26,#54391C);border-radius:0 0 3px 3px;
box-shadow:0 8px 18px -9px rgba(35,38,45,.55)}
@media (max-width:860px){
.csn-inner{flex-direction:column;align-items:stretch;gap:2px;padding:8px 0 0}
.csn-wordmark{padding:0 18px 2px}
.csn-script{font-size:36px}.csn-caps{font-size:10px;letter-spacing:.36em}
.csn-items{justify-content:flex-start;overflow-x:auto;overflow-y:hidden;padding:0 18px;gap:2px;
scroll-snap-type:x proximity;-webkit-overflow-scrolling:touch;scrollbar-width:none}
.csn-items::-webkit-scrollbar{display:none}
.csn-item{flex:0 0 auto;width:62px;height:81px;scroll-snap-align:center}
.csn-stuck .csn-item{width:57px;height:74px}
.csn-label,.csn-stuck .csn-label{font-size:6.2px}
.csn-item:hover{transform:none}
.csn-item[aria-current="page"]{transform:translateY(-5px) scale(1.1)}
}
@media (prefers-reduced-motion:reduce){
.csn *,.csn *::before,.csn *::after{transition:none!important;animation:none!important}
.csn-item:hover,.csn-item:focus-visible{transform:none}
.csn-item[aria-current="page"]{transform:scale(1.1)}
}
`
Editor is loading...
Leave a Comment