Sliding Nav
An ink pill that follows your attention, then settles back on the place you call home.
'use client'
import { useEffect, useRef, useState } from 'react'
import type { KeyboardEvent, MouseEvent } from 'react'
import './sliding-nav.css'
export interface SlidingNavItem {
id: string
label: string
href: string
}
export interface SlidingNavProps {
items: SlidingNavItem[]
activeId?: string
className?: string
label?: string
onNavigate?: (id: string, event: MouseEvent<HTMLAnchorElement>) => void
}
interface IndicatorBounds { left: number; top: number; width: number; height: number }
/** Ordinary navigation links with a shared indicator that follows attention. */
export function SlidingNav({
items,
activeId,
className = '',
label = 'Primary navigation',
onNavigate,
}: SlidingNavProps) {
const viewportRef = useRef<HTMLElement>(null)
const trackRef = useRef<HTMLDivElement>(null)
const linksRef = useRef(new Map<string, HTMLAnchorElement>())
const [previewId, setPreviewId] = useState<string | null>(null)
const [indicator, setIndicator] = useState<IndicatorBounds | null>(null)
const targetId = previewId ?? activeId
useEffect(() => {
const viewport = viewportRef.current
const track = trackRef.current
if (!viewport || !track) return
let frame = 0
let alive = true
function measure() {
frame = 0
const link = targetId ? linksRef.current.get(targetId) : undefined
if (!link) {
setIndicator(null)
return
}
const bounds = { left: link.offsetLeft, top: link.offsetTop, width: link.offsetWidth, height: link.offsetHeight }
setIndicator((previous) => (
previous && previous.left === bounds.left && previous.top === bounds.top &&
previous.width === bounds.width && previous.height === bounds.height ? previous : bounds
))
}
function schedule() {
if (!alive || frame) return
frame = requestAnimationFrame(measure)
}
const observer = new ResizeObserver(schedule)
observer.observe(track)
observer.observe(viewport)
linksRef.current.forEach((link) => observer.observe(link))
viewport.addEventListener('scroll', schedule, { passive: true })
window.addEventListener('resize', schedule)
document.fonts?.ready.then(schedule)
document.fonts?.addEventListener('loadingdone', schedule)
schedule()
return () => {
alive = false
cancelAnimationFrame(frame)
observer.disconnect()
viewport.removeEventListener('scroll', schedule)
window.removeEventListener('resize', schedule)
document.fonts?.removeEventListener('loadingdone', schedule)
}
}, [targetId, items])
function moveFocus(event: KeyboardEvent<HTMLAnchorElement>, index: number) {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || items.length < 2) return
const rtl = getComputedStyle(event.currentTarget).direction === 'rtl'
let next = index
if (event.key === 'ArrowRight') next += rtl ? -1 : 1
else if (event.key === 'ArrowLeft') next += rtl ? 1 : -1
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = items.length - 1
else return
event.preventDefault()
const link = linksRef.current.get(items[(next + items.length) % items.length].id)
link?.focus({ preventScroll: true })
link?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}
return (
<nav
aria-label={label}
className={`ac-sliding-nav ${className}`.trim()}
ref={viewportRef}
onPointerLeave={() => setPreviewId(null)}
onPointerCancel={() => setPreviewId(null)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setPreviewId(null)
}}
>
<div className="ac-sliding-track" ref={trackRef} data-indicator-ready={indicator ? 'true' : undefined}>
{indicator && (
<span
className="ac-sliding-indicator"
aria-hidden="true"
style={{
width: indicator.width,
height: indicator.height,
transform: `translate3d(${indicator.left}px, ${indicator.top}px, 0)`,
}}
/>
)}
{items.map((item, index) => (
<a
key={item.id}
className="ac-sliding-link"
href={item.href}
aria-current={item.id === activeId ? 'page' : undefined}
data-highlighted={item.id === targetId ? 'true' : undefined}
ref={(node) => {
if (node) linksRef.current.set(item.id, node)
else linksRef.current.delete(item.id)
}}
onPointerEnter={(event) => {
if (event.pointerType === 'mouse') setPreviewId(item.id)
}}
onFocus={() => setPreviewId(item.id)}
onKeyDown={(event) => moveFocus(event, index)}
onClick={(event) => onNavigate?.(item.id, event)}
>
{item.label}
</a>
))}
</div>
</nav>
)
}
Installation
Run this in your shadcn-configured React project. The component and stylesheet are added together.
npx shadcn@latest add http://localhost:5173/r/sliding-nav.jsonThis command uses the current site address. For localhost, keep this preview running on the same computer. Files follow your configured components alias.
Usage
Import the component and make the details your own.
import { SlidingNav } from '@/components/anticraft/SlidingNav';
export function Example() {
return (
<SlidingNav
activeId="work"
label="Studio sections"
items={[
{ id: 'work', label: 'Selected work', href: '/#work' },
{ id: 'about', label: 'The studio', href: '/#about' },
{ id: 'contact', label: 'Say hello', href: '/#contact' },
]}
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | SlidingNavItem[] | Required | Destinations with a unique id, visible label, and real href. Replace the example URLs with destinations in your project. |
activeId | string | undefined | The controlled id of the current destination. Only that link receives aria-current="page". Update this from your route or selected section. |
label | string | 'Primary navigation' | The accessible navigation label. Disclosure components also use it for their visible trigger. |
onNavigate | (id: string, event: MouseEvent<HTMLAnchorElement>) => void | undefined | Runs when a destination is activated. Anchors navigate normally; call event.preventDefault() only when handling navigation yourself, such as a local preview or router adapter. |
className | string | '' | Additional classes for sizing and CSS custom properties. Each component imports its matching stylesheet; keep both files together. |
Motion & accessibility
Hover or focus moves the shared indicator; leaving the navigation restores activeId. The active page stays separate from the transient preview. Every link remains in the ordinary Tab order, with optional Left/Right, Home, and End shortcuts. Measurements update after resizing, scrolling, and font loading. The strip scrolls horizontally on narrow screens. Reduced motion removes indicator and color transitions. Theme it with --ac-sliding-surface, --ac-sliding-color, --ac-sliding-active-surface, and --ac-sliding-active-color.
Use semantic content and meaningful labels in your composition. Check contrast and keyboard behavior with your own styles and content.