← alle entries

sidebar-nav

FC-0132Navigatiesidebar-navblockv1.0.0

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.

zelfde component — andere tokens
Acme BV/Start

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; }
}