← alle entries

hover-sidebar

FC-0131Navigatiesidebarblockv1.0.0

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.

zelfde component — andere tokens

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