Collapsible Component Quickstart Guide
A simple, global collapsible wrapper with persistent state, cross-tab sync, and React hooks.
1. Setup Path Alias (Optional)
Add to tsconfig.json so you can import from @reusable/:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@reusable/*": ["src/reusable/*"]
}
}
}
2. Install Files
Drop these under src/reusable/:
2.1 CollapsibleManager.ts
// src/reusable/CollapsibleManager.ts
// In-memory subscribers per storageKey
const subscribers: Record<string, ((v: boolean) => void)[]> = {}
// Subscribe to changes
export function subscribeCollapsible(key: string, cb: (v: boolean) => void) {
subscribers[key] ??= []
subscribers[key].push(cb)
}
export function unsubscribeCollapsible(key: string, cb: (v: boolean) => void) {
subscribers[key] = (subscribers[key] || []).filter((fn) => fn !== cb)
}
// Read persisted value (fallback to default)
export function getCollapsedGlobal(key: string, defaultState = false): boolean {
if (typeof window === "undefined") return defaultState
const raw = localStorage.getItem(key)
return raw !== null ? JSON.parse(raw) : defaultState
}
// Write + notify subscribers
export function setCollapsedGlobal(key: string, value: boolean) {
if (typeof window === "undefined") return
localStorage.setItem(key, JSON.stringify(value))
for (const fn of subscribers[key] || []) fn(value)
}
// Toggle helper
export function toggleCollapsedGlobal(key: string) {
const current = getCollapsedGlobal(key, false)
setCollapsedGlobal(key, !current)
}
// Cross-tab sync
if (typeof window !== "undefined") {
window.addEventListener("storage", (e) => {
if (e.key && subscribers[e.key]) {
const newVal = e.newValue === null ? false : JSON.parse(e.newValue)
for (const fn of subscribers[e.key]) fn(newVal)
}
})
}
2.2 Collapsible.tsx
// src/reusable/Collapsible.tsx
"use client"
import {
createContext,
ReactNode,
useContext,
useEffect,
useState,
} from "react"
import {
subscribeCollapsible,
unsubscribeCollapsible,
getCollapsedGlobal,
toggleCollapsedGlobal,
setCollapsedGlobal,
} from "./CollapsibleManager"
interface CollapsibleProps {
storageKey: string
children: ReactNode
collapsedClass?: string
expandedClass?: string
defaultCollapsed?: boolean
}
interface ContextType {
isCollapsed: boolean
toggle: () => void
setCollapsed: (v: boolean) => void
}
const CollapsibleContext = createContext<ContextType | null>(null)
export function useCollapsible() {
const ctx = useContext(CollapsibleContext)
if (!ctx) throw new Error("useCollapsible must be used inside <Collapsible>")
return ctx
}
export default function Collapsible({
storageKey,
children,
collapsedClass = "w-20",
expandedClass = "w-52",
defaultCollapsed = false,
}: CollapsibleProps) {
const [isCollapsed, setIsCollapsed] = useState(() =>
getCollapsedGlobal(storageKey, defaultCollapsed)
)
// Subscribe to global changes
useEffect(() => {
const cb = (val: boolean) => setIsCollapsed(val)
subscribeCollapsible(storageKey, cb)
return () => unsubscribeCollapsible(storageKey, cb)
}, [storageKey])
// Local controls mirror global store
const toggle = () => toggleCollapsedGlobal(storageKey)
const setCollapsed = (v: boolean) => setCollapsedGlobal(storageKey, v)
return (
<CollapsibleContext.Provider value={{ isCollapsed, toggle, setCollapsed }}>
<div
className={`transition-all duration-300 ${
isCollapsed ? collapsedClass : expandedClass
}`}
>
{children}
</div>
</CollapsibleContext.Provider>
)
}
3. Usage
3.1 Wrap a sidebar (or any panel)
import Collapsible from "@reusable/Collapsible"
;<Collapsible
storageKey="isSidebarCollapsed"
collapsedClass="w-16"
expandedClass="w-64"
>
<Sidebar userName={userName} />
</Collapsible>
3.2 Inside child components
import { useCollapsible } from "@reusable/Collapsible"
const { isCollapsed, toggle, setCollapsed } = useCollapsible()
3.3 Global control (anywhere)
import {
toggleCollapsedGlobal,
setCollapsedGlobal,
getCollapsedGlobal,
} from "@reusable/CollapsibleManager"
// Toggle
toggleCollapsedGlobal("isSidebarCollapsed")
// Collapse
setCollapsedGlobal("isSidebarCollapsed", true)
// Read state
const isCollapsed = getCollapsedGlobal("isSidebarCollapsed")
4. Auto-opening Submenus
To automatically open a parent menu when landing on a child route, choose one of two approaches:
Option A: Add parentKey to NavItem
-
Extend your
NavIteminterface:export interface NavItem {
key: string
label: string
icon: React.FC<SVGProps<SVGSVGElement>>
path?: string
badge?: boolean
submenu?: NavItem[]
onClick?: (router: NextRouter) => void
/** ← new */
parentKey?: string
} -
Annotate each submenu item with its parent’s key:
const mainNavItems: NavItem[] = [
{
key: "4",
label: "Resumes",
icon: ResumeIcon,
submenu: [
{
key: "4-1",
label: "Candidates",
path: "/company/candidates",
parentKey: "4",
},
// …
],
},
// …
] -
In your sidebar’s
useEffect, auto‑open viaparentKey:useEffect(() => {
const match = items.find((i) => i.path && pathname.startsWith(i.path))
if (match?.parentKey && !openKeys.includes(match.parentKey)) {
setOpenKeys((keys) => [...keys, match.parentKey!])
}
}, [pathname, items, openKeys])
Option B: Infer parent dynamically
Without modifying NavItem, scan for the parent:
useEffect(() => {
const match = items.find((i) => i.path && pathname.startsWith(i.path))
const parent = items.find((i) =>
i.submenu?.some((sub) => sub.key === match?.key)
)
if (parent && !openKeys.includes(parent.key)) {
setOpenKeys((keys) => [...keys, parent.key])
}
}, [pathname, items, openKeys])
5. API Summary
| Function / Component | Description |
|---|---|
Collapsible (React component) | Wraps content with collapse/expand animation |
useCollapsible() (hook) | Access isCollapsed, toggle(), setCollapsed() |
subscribeCollapsible(key, cb) | Listen for changes to key |
unsubscribeCollapsible(key, cb) | Remove listener |
getCollapsedGlobal(key, default) | Read persisted state |
setCollapsedGlobal(key, value) | Write state & notify subscribers |
toggleCollapsedGlobal(key) | Shortcut to toggle + notify |