Sifa professional network frontend (Next.js, React, TailwindCSS)
sifa.id/
1'use client';
2
3import { useEffect, useState, useRef, useCallback } from 'react';
4import { cn } from '@/lib/utils';
5
6export interface SectionNavItem {
7 id: string;
8 label: string;
9}
10
11interface SectionNavProps {
12 sections: SectionNavItem[];
13}
14
15function useScrollSpy(sections: SectionNavItem[]) {
16 const [activeId, setActiveId] = useState<string | null>(null);
17 const observerRef = useRef<IntersectionObserver | null>(null);
18
19 useEffect(() => {
20 if (sections.length < 3) return;
21
22 const handleIntersect = (entries: IntersectionObserverEntry[]) => {
23 const visible = entries
24 .filter((e) => e.isIntersecting)
25 .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
26
27 const first = visible[0];
28 if (first) {
29 setActiveId(first.target.id);
30 }
31 };
32
33 observerRef.current = new IntersectionObserver(handleIntersect, {
34 rootMargin: '-80px 0px -60% 0px',
35 threshold: 0,
36 });
37
38 for (const section of sections) {
39 const el = document.getElementById(section.id);
40 if (el) observerRef.current.observe(el);
41 }
42
43 return () => {
44 observerRef.current?.disconnect();
45 };
46 }, [sections]);
47
48 return activeId;
49}
50
51export function SectionNav({ sections }: SectionNavProps) {
52 const activeId = useScrollSpy(sections);
53
54 const handleClick = useCallback((id: string) => {
55 const el = document.getElementById(id);
56 if (el) {
57 el.scrollIntoView({ behavior: 'smooth', block: 'start' });
58 }
59 }, []);
60
61 if (sections.length < 3) return null;
62
63 return (
64 <>
65 {/* Desktop: left sidebar — positioned by parent flex container */}
66 <nav
67 className="sticky top-20 hidden max-h-[calc(100vh-6rem)] w-44 shrink-0 self-start lg:block"
68 aria-label="Profile sections"
69 >
70 <ul className="space-y-1">
71 {sections.map((section) => (
72 <li key={section.id}>
73 <button
74 type="button"
75 onClick={() => handleClick(section.id)}
76 className={cn(
77 'block w-full rounded-md px-3 py-1.5 text-left text-sm transition-colors',
78 activeId === section.id
79 ? 'bg-primary/10 font-medium text-primary'
80 : 'text-muted-foreground hover:bg-accent hover:text-foreground',
81 )}
82 >
83 {section.label}
84 </button>
85 </li>
86 ))}
87 </ul>
88 </nav>
89
90 {/* Mobile: horizontal pill bar */}
91 <nav
92 className="sticky top-14 z-30 -mx-4 overflow-x-auto border-b border-border bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/80 lg:hidden"
93 aria-label="Profile sections"
94 >
95 <ul className="flex gap-2">
96 {sections.map((section) => (
97 <li key={section.id}>
98 <button
99 type="button"
100 onClick={() => handleClick(section.id)}
101 className={cn(
102 'whitespace-nowrap rounded-full px-3 py-1 text-sm transition-colors',
103 activeId === section.id
104 ? 'bg-primary text-primary-foreground'
105 : 'bg-muted text-muted-foreground hover:bg-accent hover:text-foreground',
106 )}
107 >
108 {section.label}
109 </button>
110 </li>
111 ))}
112 </ul>
113 </nav>
114 </>
115 );
116}