coders-club / src /components /notch-nav.tsx
kumar-aditya's picture
Upload 108 files
a7b8df9 verified
import * as React from "react"
type Item = {
value: string
label: string
href?: string
}
type NotchNavProps = {
items: Item[]
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
ariaLabel?: string
className?: string
}
export function NotchNav({
items,
value,
defaultValue,
onValueChange,
ariaLabel = "Primary",
className,
}: NotchNavProps) {
const isControlled = value !== undefined
const [active, setActive] = React.useState<string>(value ?? defaultValue ?? items[0]?.value ?? "")
const [ready, setReady] = React.useState(false)
const [reducedMotion, setReducedMotion] = React.useState(false)
// Sync when controlled
React.useEffect(() => {
if (isControlled && value !== undefined) setActive(value)
}, [isControlled, value])
const containerRef = React.useRef<HTMLDivElement | null>(null)
const itemRefs = React.useRef<Array<HTMLButtonElement | null>>([])
const [notchRect, setNotchRect] = React.useState<{ left: number; width: number } | null>(null)
const activeIndex = React.useMemo(
() =>
Math.max(
0,
items.findIndex((i) => i.value === active),
),
[items, active],
)
const updateNotch = React.useCallback(() => {
const c = containerRef.current
const el = itemRefs.current[activeIndex]
if (!c || !el) return
const cRect = c.getBoundingClientRect()
const eRect = el.getBoundingClientRect()
const left = eRect.left - cRect.left
const width = eRect.width
setNotchRect({ left, width })
setReady(true)
}, [activeIndex])
React.useLayoutEffect(() => {
updateNotch()
// Update on resize
const onResize = () => updateNotch()
window.addEventListener("resize", onResize)
return () => window.removeEventListener("resize", onResize)
}, [updateNotch])
const focusItem = (index: number) => {
const el = itemRefs.current[Math.max(0, Math.min(items.length - 1, index))]
el?.focus()
}
const commitChange = (next: string) => {
if (!isControlled) setActive(next)
onValueChange?.(next)
}
React.useEffect(() => {
const mql = window.matchMedia("(prefers-reduced-motion: reduce)")
const onChange = () => setReducedMotion(mql.matches)
onChange()
mql.addEventListener?.("change", onChange)
return () => mql.removeEventListener?.("change", onChange)
}, [])
return (
<nav aria-label={ariaLabel} className={["w-fit mx-auto", className].filter(Boolean).join(" ")}>
<div ref={containerRef} className="relative rounded-full border border-white/15 bg-white/5 backdrop-blur-xl text-white shadow-xl">
{/* Items */}
<ul
role="menubar"
className="flex items-center justify-center gap-1 p-1"
onKeyDown={(e) => {
const key = e.key
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(key)) return
e.preventDefault()
if (key === "ArrowRight") focusItem(activeIndex + 1)
if (key === "ArrowLeft") focusItem(activeIndex - 1)
if (key === "Home") focusItem(0)
if (key === "End") focusItem(items.length - 1)
}}
>
{items.map((item, idx) => {
const isActive = item.value === active
return (
<li key={item.value} role="none">
<button
ref={(el) => (itemRefs.current[idx] = el)}
role="menuitem"
aria-current={isActive ? "page" : undefined}
aria-pressed={isActive || undefined}
tabIndex={isActive ? 0 : -1}
onClick={() => commitChange(item.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
commitChange(item.value)
}
}}
className={[
"relative rounded-full px-4 py-2 text-sm font-medium outline-none transition-all duration-300",
"focus-visible:ring-2 focus-visible:ring-primary/50",
"hover:bg-white/15 hover:backdrop-blur-sm hover:scale-105",
isActive ? "text-white font-semibold bg-white/20 shadow-lg" : "text-white/80 hover:text-white",
].join(" ")}
>
<span className="text-pretty">{item.label}</span>
</button>
</li>
)
})}
</ul>
{/* Notch indicator (SVG) */}
{notchRect && (
<div
aria-hidden="true"
className={[
"pointer-events-none absolute",
// rounded container for soft corners
"overflow-hidden rounded-sm",
"transition-all",
reducedMotion ? "duration-0" : "duration-300",
"ease-out",
ready ? "opacity-100" : "opacity-0",
].join(" ")}
style={{
transform: `translate3d(${notchRect.left}px, 0, 0)`,
width: notchRect.width,
bottom: -4,
height: 10,
willChange: "transform, width, opacity",
}}
>
{/*
unique notched bar shape under active item
- Uses currentColor so theming comes from text-primary
- Slight path rounding via Q commands
*/}
<svg
width="100%"
height="100%"
viewBox="0 0 100 8"
preserveAspectRatio="none"
className="block text-white"
>
{/* iPhone-style indicator - simple rounded rectangle */}
<rect
x="10"
y="2"
width="80"
height="4"
rx="2"
fill="currentColor"
opacity="0.8"
/>
</svg>
</div>
)}
</div>
</nav>
)
}