Navigatiekolom van een dashboard: werkruimte-wisselaar bovenin (initiaal-tegel, naam, plan, popover met werkruimtes en 'nieuwe werkruimte'), groepen met kopje en items met icoon, label, badge, sneltoets (op hover) en optioneel kinderen die in- en uitklappen met een verbindingslijn; onderaan vaste items boven een hairline. Kiezen meldt onSelect; gestuurd of met eigen staat. Componeert popover.
Kies een item, klap Projecten of Team open, wissel van werkruimte; de shell eromheen is demo.
blocks/sidebar/sidebar-nav.tsx
/**
* SidebarNav — de navigatiekolom van een dashboard: bovenin een werkruimte-
* wisselaar (initiaal-tegel, naam, plan, popover met werkruimtes en "nieuwe
* werkruimte"), daaronder groepen met een kopje en items met icoon, label,
* badge, sneltoets (verschijnt op hover) en optioneel kinderen die in- en
* uitklappen met een verbindingslijn; onderaan vaste items (instellingen,
* uitloggen) boven een hairline. Alles is data; kiezen meldt onSelect.
*
* Geoogst uit 21st.dev Dashboard Sidebar (arunjdass): twintig lucide-iconen
* → icon-slots per item; de eigen popup met fixed backdrop → popover
* (FC-0007); grid-template-rows-animatie voor de kinderen vervalt (layout-
* property, motion-lint) → opacity/transform bij openen; bg-black/5 en
* bg-white/10 → color-mix op --foreground; mockNavGroups → groups-prop. De
* omringende dashboard-shell met zoekoverlay is demo, geen entry.
*/
"use client";
import { useState } from "react";
import type * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
import { Popover } from "../../primitives/ui/popover";
import { usePress } from "../../primitives/ui/press";
export interface SidebarNavItem {
id: string;
label: string;
icon?: React.ReactNode;
badge?: number | string;
shortcut?: string;
href?: string;
children?: readonly SidebarNavItem[];
}
export interface SidebarNavGroup {
heading?: string;
items: readonly SidebarNavItem[];
}
export interface Workspace {
id: string;
name: string;
plan?: string;
}
export interface SidebarNavProps extends Omit<React.HTMLAttributes<HTMLElement>, "onSelect" | "children"> {
groups: readonly SidebarNavGroup[];
bottomItems?: readonly SidebarNavItem[];
activeId?: string;
defaultActiveId?: string;
onSelect?: (id: string, item: SidebarNavItem) => void;
workspaces?: readonly Workspace[];
workspace?: string;
onWorkspaceChange?: (id: string) => void;
onCreateWorkspace?: () => void;
labels?: Partial<Record<"nav" | "workspaces" | "create" | "expand" | "collapse", string>>;
}
const Chevron = ({ open }: { open?: boolean }) => (
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="fp-sidenav__chevron" data-open={open ? "" : undefined}><path d="M9 6l6 6-6 6" /></svg>
);
function Item({ item, level, activeId, onSelect, t, veer, reduced }: { item: SidebarNavItem; level: number; activeId?: string; onSelect: (item: SidebarNavItem) => void; t: Record<string, string>; veer: ReturnType<typeof useSpringTransition>; reduced: boolean }) {
const [open, setOpen] = useState(false);
const kinderen = item.children && item.children.length > 0;
const actief = activeId === item.id;
const { pressed, pressProps } = usePress({ onPress: () => (kinderen ? setOpen((o) => !o) : onSelect(item)) });
const inhoud = (
<>
<span className="fp-sidenav__links">
{item.icon && <span className="fp-sidenav__icoon" aria-hidden="true">{item.icon}</span>}
<span className="fp-sidenav__label">{item.label}</span>
</span>
<span className="fp-sidenav__rechts">
{item.shortcut && <kbd className="fp-sidenav__kbd">{item.shortcut}</kbd>}
{item.badge !== undefined && item.badge !== "" && <span className="fp-sidenav__badge">{item.badge}</span>}
{kinderen && <Chevron open={open} />}
</span>
</>
);
const gedeeld = { className: "fp-sidenav__item", style: { ["--fp-sidenav-niveau" as string]: level } as React.CSSProperties, "data-pressed": pressed ? "" : undefined, "aria-current": actief ? ("page" as const) : undefined };
return (
<div className="fp-sidenav__tak">
{item.href && !kinderen ? (
<a {...pressProps} {...gedeeld} href={item.href}>{inhoud}</a>
) : (
<button {...pressProps} {...gedeeld} type="button" aria-expanded={kinderen ? open : undefined} aria-label={kinderen ? `${item.label}: ${open ? t.collapse : t.expand}` : undefined}>{inhoud}</button>
)}
{kinderen && open && (
<motion.div className="fp-sidenav__kinderen" initial={reduced ? false : { opacity: 0, y: -4 }} animate={{ opacity: 1, y: 0 }} transition={veer}>
<span className="fp-sidenav__lijn" aria-hidden="true" style={{ ["--fp-sidenav-niveau" as string]: level } as React.CSSProperties} />
{item.children!.map((k) => <Item key={k.id} item={k} level={level + 1} activeId={activeId} onSelect={onSelect} t={t} veer={veer} reduced={reduced} />)}
</motion.div>
)}
</div>
);
}
function WerkruimteOptie({ w, gekozen, onPress }: { w: Workspace; gekozen: boolean; onPress: () => void }) {
const { pressed, pressProps } = usePress({ onPress });
return <button {...pressProps} type="button" role="option" aria-selected={gekozen} className="fp-sidenav__ws-optie" data-gekozen={gekozen ? "" : undefined} data-pressed={pressed ? "" : undefined}>{w.name}</button>;
}
export function SidebarNav({ groups, bottomItems = [], activeId: activeProp, defaultActiveId, onSelect, workspaces, workspace, onWorkspaceChange, onCreateWorkspace, labels, className, ...rest }: SidebarNavProps) {
const t = { nav: "Hoofdnavigatie", workspaces: "Werkruimte kiezen", create: "Nieuwe werkruimte", expand: "uitklappen", collapse: "inklappen", ...labels };
const reduced = useReducedMotion() ?? false;
const veer = useSpringTransition("default", { mass: "content" });
const [eigenActief, setEigenActief] = useState(defaultActiveId);
const activeId = activeProp ?? eigenActief;
const [wsOpen, setWsOpen] = useState(false);
const [eigenWs, setEigenWs] = useState(workspaces?.[0]?.id);
const wsId = workspace ?? eigenWs;
const huidig = workspaces?.find((w) => w.id === wsId);
const kies = (item: SidebarNavItem) => { if (activeProp === undefined) setEigenActief(item.id); onSelect?.(item.id, item); };
const wisselKnop = usePress({ onPress: () => setWsOpen((o) => !o) });
const nieuwKnop = usePress({ onPress: () => { setWsOpen(false); onCreateWorkspace?.(); } });
return (
<aside {...rest} className={["fp-sidenav", className].filter(Boolean).join(" ")}>
{workspaces && huidig && (
<Popover
open={wsOpen}
onOpenChange={setWsOpen}
placement="bottom-start"
aria-label={t.workspaces}
className="fp-sidenav__ws-popover"
anchor={
<button {...wisselKnop.pressProps} type="button" className="fp-sidenav__ws" aria-haspopup="listbox" aria-expanded={wsOpen} data-pressed={wisselKnop.pressed ? "" : undefined}>
<span className="fp-sidenav__ws-tegel" aria-hidden="true">{huidig.name.charAt(0)}</span>
<span className="fp-sidenav__ws-tekst">
<span className="fp-sidenav__ws-naam">{huidig.name}</span>
{huidig.plan && <span className="fp-sidenav__ws-plan">{huidig.plan}</span>}
</span>
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="fp-sidenav__ws-pijl"><path d="M6 9l6 6 6-6" /></svg>
</button>
}
>
<div role="listbox" aria-label={t.workspaces} className="fp-sidenav__ws-lijst">
{workspaces.map((w) => (
<WerkruimteOptie key={w.id} w={w} gekozen={w.id === wsId} onPress={() => { if (workspace === undefined) setEigenWs(w.id); onWorkspaceChange?.(w.id); setWsOpen(false); }} />
))}
{onCreateWorkspace && (
<>
<span className="fp-sidenav__ws-lijn" aria-hidden="true" />
<button {...nieuwKnop.pressProps} type="button" className="fp-sidenav__ws-optie fp-sidenav__ws-optie--nieuw" data-pressed={nieuwKnop.pressed ? "" : undefined}>
<span aria-hidden="true">+</span> {t.create}
</button>
</>
)}
</div>
</Popover>
)}
<nav className="fp-sidenav__groepen" aria-label={t.nav}>
{groups.map((g, i) => (
<div key={g.heading ?? i} className="fp-sidenav__groep">
{g.heading && <span className="fp-sidenav__kopje">{g.heading}</span>}
{g.items.map((it) => <Item key={it.id} item={it} level={0} activeId={activeId} onSelect={kies} t={t} veer={veer} reduced={reduced} />)}
</div>
))}
</nav>
{bottomItems.length > 0 && (
<div className="fp-sidenav__onder">
{bottomItems.map((it) => <Item key={it.id} item={it} level={0} activeId={activeId} onSelect={kies} t={t} veer={veer} reduced={reduced} />)}
</div>
)}
</aside>
);
}
blocks/sidebar/sidebar.css
/** HoverSidebar + SidebarNav — importeren in de app-root (registry-site: app/globals.css). */
/* ── hover-sidebar ─────────────────────────────────────────────────────── */
.fp-sidebar { position: relative; flex: 0 0 auto; width: var(--fp-sidebar-rail, 3.75rem); height: 100%; min-height: 0; color: var(--foreground); }
.fp-sidebar__paneel { position: absolute; inset: 0 auto 0 0; z-index: 20; display: flex; flex-direction: column; gap: 1rem; width: var(--fp-sidebar-open, 15rem); height: 100%; padding: 0.75rem; box-sizing: border-box; overflow: hidden; background: var(--card); color: var(--card-foreground, var(--foreground)); border-right: 1px solid var(--border); clip-path: inset(0 calc(100% - var(--fp-sidebar-rail, 3.75rem)) 0 0); transition: clip-path var(--motion-smooth-duration) var(--motion-smooth); }
.fp-sidebar[data-open] .fp-sidebar__paneel { clip-path: inset(0 0 0 0); box-shadow: var(--shadow-overlay); }
.fp-sidebar__kop, .fp-sidebar__voet { display: flex; align-items: center; gap: 0.5rem; min-height: 2.25rem; }
.fp-sidebar__voet { margin-top: auto; }
.fp-sidebar__nav { display: flex; flex-direction: column; gap: 0.25rem; flex: 1 1 auto; min-height: 0; overflow-y: auto; overflow-x: hidden; }
.fp-sidebar__link { display: flex; align-items: center; gap: 0.625rem; width: 100%; padding: 0.5rem 0.5625rem; appearance: none; border: 0; border-radius: var(--radius); background: transparent; color: var(--muted-foreground); font: inherit; font-size: 0.875rem; text-align: left; text-decoration: none; cursor: pointer; transition: background-color var(--motion-instant-duration) var(--motion-instant), color var(--motion-instant-duration) var(--motion-instant); }
.fp-sidebar__link:hover, .fp-sidebar__link[aria-current="page"] { color: var(--foreground); background: color-mix(in srgb, var(--foreground) 6%, transparent); }
.fp-sidebar__link[data-pressed] { background: color-mix(in srgb, var(--foreground) 12%, transparent); }
.fp-sidebar__link:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: -2px; }
.fp-sidebar__icoon { display: inline-flex; align-items: center; justify-content: center; width: 1.25rem; height: 1.25rem; flex: 0 0 auto; }
.fp-sidebar__label { white-space: nowrap; opacity: 0; transform: translateX(-0.25rem); transition: opacity var(--motion-default-duration) var(--motion-default), transform var(--motion-default-duration) var(--motion-default); }
.fp-sidebar[data-open] .fp-sidebar__label { opacity: 1; transform: none; }
.fp-sidebar__balk { display: none; }
.fp-sidebar__knop { display: inline-flex; align-items: center; justify-content: center; width: 2.25rem; height: 2.25rem; appearance: none; border: 0; border-radius: var(--radius); background: transparent; color: inherit; cursor: pointer; transition: background-color var(--motion-instant-duration) var(--motion-instant); }
.fp-sidebar__knop:hover { background: color-mix(in srgb, var(--foreground) 6%, transparent); }
.fp-sidebar__knop[data-pressed] { background: color-mix(in srgb, var(--foreground) 12%, transparent); }
.fp-sidebar__knop:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: -2px; }
.fp-sidebar__overlay { position: fixed; inset: 0; z-index: 60; display: flex; flex-direction: column; gap: 1rem; padding: 1rem; background: var(--background); color: var(--foreground); }
.fp-sidebar__overlay .fp-sidebar__label, .fp-sidebar__balk .fp-sidebar__label { opacity: 1; transform: none; }
.fp-sidebar__overlay-kop { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
@media (max-width: 47.99rem) {
.fp-sidebar { width: 100%; height: auto; }
.fp-sidebar__paneel { display: none; }
.fp-sidebar__balk { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; padding: 0.5rem 0.75rem; background: var(--card); border-bottom: 1px solid var(--border); }
.fp-sidebar__balk-kop { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
}
@media (prefers-reduced-motion: reduce) {
.fp-sidebar__paneel, .fp-sidebar__label { transition-duration: 0s; }
}
/* ── sidebar-nav ───────────────────────────────────────────────────────── */
.fp-sidenav { display: flex; flex-direction: column; width: 16.25rem; height: 100%; min-height: 0; padding: 0.75rem; box-sizing: border-box; background: color-mix(in srgb, var(--card) 60%, transparent); color: var(--foreground); border-right: 1px solid var(--border); }
.fp-sidenav__ws-popover { display: block; margin-bottom: 1rem; }
.fp-sidenav__ws-popover .fp-popover { width: 100%; }
.fp-sidenav__ws { display: flex; align-items: center; gap: 0.75rem; width: 100%; padding: 0.5rem; appearance: none; border: 0; border-radius: var(--radius); background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; transition: background-color var(--motion-instant-duration) var(--motion-instant); }
.fp-sidenav__ws:hover { background: color-mix(in srgb, var(--foreground) 5%, transparent); }
.fp-sidenav__ws[data-pressed] { background: color-mix(in srgb, var(--foreground) 10%, transparent); }
.fp-sidenav__ws:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: -2px; }
.fp-sidenav__ws-tegel { display: inline-flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; flex: 0 0 auto; border-radius: calc(var(--radius) * 0.75); background: var(--primary); color: var(--primary-foreground); font-size: 0.8125rem; font-weight: 600; }
.fp-sidenav__ws-tekst { display: flex; flex-direction: column; min-width: 0; flex: 1 1 auto; }
.fp-sidenav__ws-naam { font-size: 0.8125rem; font-weight: 500; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fp-sidenav__ws-plan { font-size: 0.6875rem; line-height: 1.2; color: var(--muted-foreground); }
.fp-sidenav__ws-pijl { flex: 0 0 auto; color: var(--muted-foreground); }
.fp-sidenav__ws-lijst { display: flex; flex-direction: column; gap: 0.125rem; }
.fp-sidenav__ws-optie { display: flex; align-items: center; gap: 0.5rem; width: 100%; padding: 0.5rem 0.75rem; appearance: none; border: 0; border-radius: var(--radius); background: transparent; color: var(--foreground); font: inherit; font-size: 0.8125rem; text-align: left; cursor: pointer; transition: background-color var(--motion-instant-duration) var(--motion-instant); }
.fp-sidenav__ws-optie:hover { background: color-mix(in srgb, var(--foreground) 5%, transparent); }
.fp-sidenav__ws-optie[data-pressed] { background: color-mix(in srgb, var(--foreground) 10%, transparent); }
.fp-sidenav__ws-optie[data-gekozen] { background: color-mix(in srgb, var(--primary) 10%, transparent); color: var(--primary); font-weight: 500; }
.fp-sidenav__ws-optie--nieuw { color: var(--muted-foreground); }
.fp-sidenav__ws-optie:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: -2px; }
.fp-sidenav__ws-lijn { display: block; height: 1px; margin: 0.25rem 0.5rem; background: var(--border); }
.fp-sidenav__groepen { display: flex; flex-direction: column; gap: 1rem; flex: 1 1 auto; min-height: 0; overflow-y: auto; overflow-x: hidden; scrollbar-width: none; }
.fp-sidenav__groepen::-webkit-scrollbar { display: none; }
.fp-sidenav__groep { display: flex; flex-direction: column; gap: 0.125rem; }
.fp-sidenav__kopje { padding: 0 0.625rem; margin-bottom: 0.25rem; font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: color-mix(in srgb, var(--muted-foreground) 60%, transparent); }
.fp-sidenav__tak { display: flex; flex-direction: column; width: 100%; }
.fp-sidenav__item { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; width: 100%; padding: 0.4375rem 0.625rem; padding-left: calc(0.625rem + var(--fp-sidenav-niveau, 0) * 0.75rem); appearance: none; border: 0; border-radius: calc(var(--radius) * 0.75); background: transparent; color: var(--muted-foreground); font: inherit; font-size: 0.8125rem; letter-spacing: 0.01em; text-align: left; text-decoration: none; cursor: pointer; transition: background-color var(--motion-instant-duration) var(--motion-instant), color var(--motion-instant-duration) var(--motion-instant); }
.fp-sidenav__item:hover { background: color-mix(in srgb, var(--foreground) 5%, transparent); color: var(--foreground); }
.fp-sidenav__item[data-pressed] { background: color-mix(in srgb, var(--foreground) 10%, transparent); }
.fp-sidenav__item[aria-current="page"] { background: color-mix(in srgb, var(--foreground) 6%, transparent); color: var(--foreground); font-weight: 500; }
.fp-sidenav__item:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: -2px; }
.fp-sidenav__links { display: flex; align-items: center; gap: 0.625rem; min-width: 0; }
.fp-sidenav__icoon { display: inline-flex; width: 1rem; height: 1rem; flex: 0 0 auto; color: color-mix(in srgb, var(--muted-foreground) 70%, transparent); transition: color var(--motion-instant-duration) var(--motion-instant); }
.fp-sidenav__item:hover .fp-sidenav__icoon, .fp-sidenav__item[aria-current="page"] .fp-sidenav__icoon { color: var(--foreground); }
.fp-sidenav__label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fp-sidenav__rechts { display: flex; align-items: center; gap: 0.5rem; flex: 0 0 auto; }
.fp-sidenav__kbd { display: none; align-items: center; justify-content: center; height: 1.25rem; padding: 0 0.375rem; border: 1px solid var(--border); border-radius: calc(var(--radius) * 0.5); background: color-mix(in srgb, var(--background) 50%, transparent); font: inherit; font-size: 0.625rem; font-weight: 500; color: color-mix(in srgb, var(--muted-foreground) 60%, transparent); }
.fp-sidenav__item:hover .fp-sidenav__kbd { display: inline-flex; }
.fp-sidenav__badge { display: inline-flex; align-items: center; justify-content: center; min-width: 1.25rem; height: 1.25rem; padding: 0 0.375rem; border-radius: 999px; background: color-mix(in srgb, var(--primary) 10%, transparent); color: var(--primary); font-size: 0.625rem; font-weight: 500; }
.fp-sidenav__chevron { color: color-mix(in srgb, var(--muted-foreground) 50%, transparent); transition: transform var(--motion-default-duration) var(--motion-default); }
.fp-sidenav__chevron[data-open] { transform: rotate(90deg); }
.fp-sidenav__kinderen { position: relative; display: flex; flex-direction: column; gap: 0.125rem; margin-top: 0.125rem; }
.fp-sidenav__lijn { position: absolute; top: 0; bottom: 0; left: calc(1.09375rem + var(--fp-sidenav-niveau, 0) * 0.75rem); border-left: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent); }
.fp-sidenav__onder { display: flex; flex-direction: column; gap: 0.125rem; margin-top: auto; padding-top: 1rem; border-top: 1px solid var(--border); }
@media (prefers-reduced-motion: reduce) {
.fp-sidenav__chevron { transition-duration: 0s; }
}