Smalle iconenrail die uitklapt zodra je erop komt of er met het toetsenbord in belandt: de labels schuiven naast de iconen in. Boven een kop-slot (logo), onder een voet-slot (profiel). Smal (< 48rem) wordt het een bovenbalk met een menuknop die een schermvullend paneel van links inschuift (Escape sluit). Links zijn <a> met href of knoppen met onPress.
Actief: Overzicht. Kom over de rail of tab erin; smal wordt het een bovenbalk met menu.
blocks/sidebar/hover-sidebar.tsx
/**
* HoverSidebar — een smalle iconenrail die uitklapt zodra je erop komt (of
* er met het toetsenbord in belandt): de labels schuiven naast de iconen in.
* Boven een kop-slot (logo), onder een voet-slot (profiel). Smal (< 48rem)
* wordt het een bovenbalk met een menuknop die een schermvullend paneel van
* links inschuift.
*
* Geoogst uit 21st.dev Sidebar (aceternity): framer `animate={{ width }}`
* 60→300 px vervalt — breedte animeren is een layout-property (motion-lint).
* De rail houdt zijn layout-breedte; het uitgeklapte paneel ligt óver de
* inhoud en wordt met clip-path onthuld, de labels met opacity/transform.
* lucide Menu/X → inline paden; onClick → usePress; Tailwind neutral-100/
* neutral-800 → --card/--foreground.
*/
"use client";
import { useEffect, useId, useState } from "react";
import type * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
import { usePress } from "../../primitives/ui/press";
export interface SidebarLink {
id: string;
label: string;
icon: React.ReactNode;
href?: string;
active?: boolean;
onPress?: () => void;
}
export interface HoverSidebarProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
links: readonly SidebarLink[];
/** Kop boven de links (logo, werkruimte). */
header?: React.ReactNode;
/** Voet onder de links (profiel). */
footer?: React.ReactNode;
/** Gestuurd uitklappen; zonder: hover/focus. */
open?: boolean;
/** Breedte van het uitgeklapte paneel (15rem) en van de rail (3.75rem). */
width?: string;
railWidth?: string;
labels?: Partial<Record<"nav" | "open" | "close", string>>;
}
function Koppeling({ link }: { link: SidebarLink }) {
const { pressed, pressProps } = usePress({ onPress: link.onPress });
const inhoud = (
<>
<span className="fp-sidebar__icoon" aria-hidden="true">{link.icon}</span>
<span className="fp-sidebar__label">{link.label}</span>
</>
);
if (link.href) {
return <a {...pressProps} href={link.href} className="fp-sidebar__link" aria-current={link.active ? "page" : undefined} data-pressed={pressed ? "" : undefined}>{inhoud}</a>;
}
return <button {...pressProps} type="button" className="fp-sidebar__link" aria-current={link.active ? "page" : undefined} data-pressed={pressed ? "" : undefined}>{inhoud}</button>;
}
export function HoverSidebar({ links, header, footer, open, width = "15rem", railWidth = "3.75rem", labels, className, style, ...rest }: HoverSidebarProps) {
const t = { nav: "Hoofdnavigatie", open: "Menu openen", close: "Menu sluiten", ...labels };
const reduced = useReducedMotion() ?? false;
const veer = useSpringTransition("smooth", { mass: "system" });
const [hover, setHover] = useState(false);
const [mobielOpen, setMobielOpen] = useState(false);
const paneelId = useId();
const uit = open ?? hover;
const openKnop = usePress({ onPress: () => setMobielOpen(true) });
const sluitKnop = usePress({ onPress: () => setMobielOpen(false) });
useEffect(() => {
if (!mobielOpen) return;
const toets = (e: KeyboardEvent) => { if (e.key === "Escape") setMobielOpen(false); };
window.addEventListener("keydown", toets);
return () => window.removeEventListener("keydown", toets);
}, [mobielOpen]);
const lijst = (
<nav className="fp-sidebar__nav" aria-label={t.nav}>
{links.map((l) => <Koppeling key={l.id} link={l} />)}
</nav>
);
return (
<aside
{...rest}
className={["fp-sidebar", className].filter(Boolean).join(" ")}
data-open={uit ? "" : undefined}
style={{ ...style, ["--fp-sidebar-open" as string]: width, ["--fp-sidebar-rail" as string]: railWidth } as React.CSSProperties}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
onFocusCapture={() => setHover(true)}
onBlurCapture={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setHover(false); }}
>
<div className="fp-sidebar__paneel">
{header && <div className="fp-sidebar__kop">{header}</div>}
{lijst}
{footer && <div className="fp-sidebar__voet">{footer}</div>}
</div>
<div className="fp-sidebar__balk">
<div className="fp-sidebar__balk-kop">{header}</div>
<button {...openKnop.pressProps} type="button" className="fp-sidebar__knop" aria-label={t.open} aria-expanded={mobielOpen} aria-controls={paneelId} data-pressed={openKnop.pressed ? "" : undefined}>
<svg aria-hidden="true" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><path d="M4 7h16M4 12h16M4 17h16" /></svg>
</button>
</div>
<AnimatePresence>
{mobielOpen && (
<motion.div
id={paneelId}
className="fp-sidebar__overlay"
role="dialog"
aria-modal="true"
aria-label={t.nav}
initial={reduced ? { opacity: 0 } : { x: "-100%" }}
animate={reduced ? { opacity: 1 } : { x: 0 }}
exit={reduced ? { opacity: 0 } : { x: "-100%" }}
transition={veer}
>
<div className="fp-sidebar__overlay-kop">
{header}
<button {...sluitKnop.pressProps} type="button" className="fp-sidebar__knop" aria-label={t.close} data-pressed={sluitKnop.pressed ? "" : undefined} autoFocus>
<svg aria-hidden="true" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
</button>
</div>
{lijst}
{footer && <div className="fp-sidebar__voet">{footer}</div>}
</motion.div>
)}
</AnimatePresence>
</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; }
}