← alle entries

kinetic-menu

FC-0090Navigatiefullscreen-menublockv1.0.0

Paginavullend menu van een verhalende site: balk met logo en een knop waarvan de tekst omhoog rolt (Menu → Sluiten) terwijl het plusje een kruisje wordt; een paneel schuift van rechts in, in drie lagen die elkaar volgen, en de grote links komen één voor één van onderen omhoog met een lichte draai. Op hover verschijnen abstracte vormen op de achtergrond, per link een andere. Primaire links blijven op desktop in de balk (vindbaarheid).

zelfde component — andere tokens

Een stille pagina.

Open het menu rechtsboven — of tik op Diensten in de balk.

Knoptekst rolt om, plusje wordt kruisje; drie lagen schuiven van rechts in, de links komen van onderen omhoog. Kom op een link voor de vormen. Escape of de scrim sluit.

blocks/menu/kinetic-menu.tsx
/**
 * KineticMenu — het paginavullende menu van een verhalende site: een balk met
 * logo en een knop waarvan de tekst omhoog rolt ("Menu" → "Sluiten") terwijl
 * het plusje naar een kruisje draait; daarachter schuift een paneel van rechts
 * in, in drie lagen die elkaar met een kleine vertraging volgen, en de grote
 * links komen één voor één van onderen omhoog met een lichte draai. Kom je
 * op een link, dan verschijnen abstracte vormen op de achtergrond — per link
 * een andere. Geoogst uit 21st.dev Sterling Gate: Kinetic Navigation.
 *
 * De bron liep op GSAP + CustomEase (0.65, 0.01, 0.05, 0.99) en losse duraties;
 * hier: framer-motion op de smooth-token voor het inschuiven en de links, de
 * pop-token voor de vormen, CSS-transitions op de knop. De Webflow-stylesheet
 * kwam niet mee en is uit de preview herbouwd op het tokencontract; de
 * indigo/violet/roze-vormen zijn tinten van --primary, --info en --danger.
 *
 * Vindbaarheid (Apple-methode, regel 1): `primaryLinks` blijven op desktop in
 * de balk zichtbaar; het paneel is de volledige lijst plus wat het project er
 * in de `extra`-slot bij zet. Escape en de scrim sluiten; focus gaat naar de
 * eerste link en bij sluiten terug naar de knop. Onder reduced motion staat
 * alles direct op zijn plek.
 */

"use client";

import { useEffect, useId, useRef, 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 KineticMenuLink {
  id?: string;
  label: string;
  href: string;
  /** Welke vorm bij hover verschijnt (1–5); zonder: op volgorde. */
  shape?: 1 | 2 | 3 | 4 | 5;
}

export interface KineticMenuProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  /** De volledige lijst in het paneel. */
  links: readonly KineticMenuLink[];
  /** Logo of merknaam links in de balk. */
  logo?: React.ReactNode;
  /** Bestemmingen die op desktop in de balk blijven staan (max. 5 — vindbaarheid). */
  primaryLinks?: readonly KineticMenuLink[];
  /** Klein woord naast de knop ("klik hier"); leeg = geen. */
  hint?: string;
  /** Extra inhoud onder de lijst (contact, sociale links) — komt vervaagd omhoog. */
  extra?: React.ReactNode;
  labels?: { open?: string; close?: string; nav?: string };
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Component voor links (default "a"; geef Next's Link mee in een Next-project). */
  linkComponent?: React.ElementType;
  /** Balk en paneel aan de viewport (default); false = absoluut in een relatieve ouder (demo, paneel). */
  fixed?: boolean;
  /** Abstracte vormen bij hover (default true). */
  shapes?: boolean;
}

const cx = (...c: (string | false | null | undefined)[]) => c.filter(Boolean).join(" ");

/* De vijf vormen uit de bron: cirkels, golven, stippenraster, blobs, schuine lijnen. Kleur via CSS (tinten a/b/c). */
const VORMEN: React.ReactNode[][] = [
  [
    <circle key="1" className="fp-kmenu__vorm-el fp-kmenu__vorm-el--a" cx="80" cy="120" r="40" />,
    <circle key="2" className="fp-kmenu__vorm-el fp-kmenu__vorm-el--b" cx="300" cy="80" r="60" />,
    <circle key="3" className="fp-kmenu__vorm-el fp-kmenu__vorm-el--c" cx="200" cy="300" r="80" />,
    <circle key="4" className="fp-kmenu__vorm-el fp-kmenu__vorm-el--a" cx="350" cy="280" r="30" />,
  ],
  [
    <path key="1" className="fp-kmenu__vorm-el fp-kmenu__vorm-el--a fp-kmenu__vorm-el--lijn" d="M0 200 Q100 100, 200 200 T 400 200" strokeWidth="60" />,
    <path key="2" className="fp-kmenu__vorm-el fp-kmenu__vorm-el--b fp-kmenu__vorm-el--lijn" d="M0 280 Q100 180, 200 280 T 400 280" strokeWidth="40" />,
  ],
  [50, 150, 250, 350].flatMap((x, i) =>
    [50, 150, 250, 350].map((y, j) => (
      <circle key={`${x}-${y}`} className={`fp-kmenu__vorm-el fp-kmenu__vorm-el--${["a", "b", "c"][(i + j) % 3]}`} cx={x} cy={y} r={j === 1 ? 12 : j === 3 ? 6 : 8} />
    )),
  ),
  [
    <path key="1" className="fp-kmenu__vorm-el fp-kmenu__vorm-el--a" d="M100 100 Q150 50, 200 100 Q250 150, 200 200 Q150 250, 100 200 Q50 150, 100 100" />,
    <path key="2" className="fp-kmenu__vorm-el fp-kmenu__vorm-el--c" d="M250 200 Q300 150, 350 200 Q400 250, 350 300 Q300 350, 250 300 Q200 250, 250 200" />,
  ],
  [
    <line key="1" className="fp-kmenu__vorm-el fp-kmenu__vorm-el--a fp-kmenu__vorm-el--lijn" x1="0" y1="100" x2="300" y2="400" strokeWidth="30" />,
    <line key="2" className="fp-kmenu__vorm-el fp-kmenu__vorm-el--b fp-kmenu__vorm-el--lijn" x1="100" y1="0" x2="400" y2="300" strokeWidth="25" />,
    <line key="3" className="fp-kmenu__vorm-el fp-kmenu__vorm-el--c fp-kmenu__vorm-el--lijn" x1="200" y1="0" x2="400" y2="200" strokeWidth="20" />,
  ],
];

function Plus() {
  return (
    <svg aria-hidden="true" viewBox="0 0 16 16" fill="none" className="fp-kmenu__icoon">
      <path d="M7.33333 16L7.33333 0L8.66667 0L8.66667 16L7.33333 16Z" fill="currentColor" />
      <path d="M16 8.66667L0 8.66667L0 7.33333L16 7.33333L16 8.66667Z" fill="currentColor" />
      <path d="M6 7.33333L7.33333 7.33333L7.33333 6C7.33333 6.73637 6.73638 7.33333 6 7.33333Z" fill="currentColor" />
      <path d="M10 7.33333L8.66667 7.33333L8.66667 6C8.66667 6.73638 9.26362 7.33333 10 7.33333Z" fill="currentColor" />
      <path d="M6 8.66667L7.33333 8.66667L7.33333 10C7.33333 9.26362 6.73638 8.66667 6 8.66667Z" fill="currentColor" />
      <path d="M10 8.66667L8.66667 8.66667L8.66667 10C8.66667 9.26362 9.26362 8.66667 10 8.66667Z" fill="currentColor" />
    </svg>
  );
}

export function KineticMenu({
  links,
  logo,
  primaryLinks = [],
  hint,
  extra,
  labels,
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  linkComponent: Link = "a",
  fixed = true,
  shapes = true,
  className,
  ...rest
}: KineticMenuProps) {
  const tekst = { open: "Menu", close: "Sluiten", nav: "Hoofdmenu", ...labels };
  const [eigenOpen, setEigenOpen] = useState(defaultOpen);
  const open = openProp ?? eigenOpen;
  const zet = (v: boolean) => {
    if (openProp === undefined) setEigenOpen(v);
    onOpenChange?.(v);
  };
  const reduced = useReducedMotion() ?? false;
  const glijd = useSpringTransition("smooth", { mass: "system" });
  const pop = useSpringTransition("pop");
  const paneelId = useId();
  const knop = useRef<HTMLButtonElement>(null);
  const eersteLink = useRef<HTMLElement>(null);
  const [hover, setHover] = useState<number | null>(null);
  const { pressed, pressProps } = usePress({ onPress: () => zet(!open) });

  /* Escape sluit; focus naar de eerste link bij openen, terug naar de knop bij sluiten. */
  useEffect(() => {
    if (!open) return;
    const toets = (e: KeyboardEvent) => {
      if (e.key === "Escape") zet(false);
    };
    window.addEventListener("keydown", toets);
    const t = window.setTimeout(() => eersteLink.current?.focus({ preventScroll: true }), reduced ? 0 : 400);
    if (fixed) document.documentElement.style.overflow = "hidden";
    return () => {
      window.removeEventListener("keydown", toets);
      window.clearTimeout(t);
      if (fixed) document.documentElement.style.overflow = "";
      knop.current?.focus({ preventScroll: true });
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps -- zet leest open via de closure van de render waarin het paneel opende
  }, [open, fixed, reduced]);

  const vertraging = (s: number) => (reduced ? 0 : s);

  return (
    <div {...rest} className={cx("fp-kmenu", fixed ? "fp-kmenu--vast" : "fp-kmenu--los", open && "fp-kmenu--open", className)}>
      <header className="fp-kmenu__balk">
        <div className="fp-kmenu__logo">{logo}</div>
        <div className="fp-kmenu__rechts">
          {primaryLinks.length > 0 && (
            <nav className="fp-kmenu__primair" aria-label={tekst.nav}>
              {primaryLinks.slice(0, 5).map((l) => (
                <Link key={l.id ?? l.href} href={l.href} className="fp-kmenu__primair-link">
                  {l.label}
                </Link>
              ))}
            </nav>
          )}
          {hint && (
            <span className="fp-kmenu__hint" aria-hidden="true">
              {hint}
            </span>
          )}
          <button
            {...pressProps}
            ref={knop}
            type="button"
            className="fp-kmenu__knop"
            aria-expanded={open}
            aria-controls={paneelId}
            data-pressed={pressed ? "" : undefined}
          >
            <span className="fp-kmenu__knop-tekst" aria-hidden="true">
              <span>{tekst.open}</span>
              <span>{tekst.close}</span>
            </span>
            <span className="fp-kmenu__sr">{open ? tekst.close : tekst.open}</span>
            <span className="fp-kmenu__icoon-vak">
              <Plus />
            </span>
          </button>
        </div>
      </header>

      <AnimatePresence>
        {open && (
          <div className="fp-kmenu__laag" id={paneelId} role="dialog" aria-modal="true" aria-label={tekst.nav}>
            <motion.div
              className="fp-kmenu__scrim"
              initial={reduced ? false : { opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={glijd}
              onClick={() => zet(false)}
            />
            <motion.nav
              className="fp-kmenu__paneel"
              initial={false}
              animate={{ x: "0%" }}
              exit={reduced ? { opacity: 0 } : { x: "120%" }}
              transition={glijd}
            >
              <div className="fp-kmenu__achter" aria-hidden="true">
                {[0, 1, 2].map((i) => (
                  <motion.div
                    key={i}
                    className={`fp-kmenu__vlak fp-kmenu__vlak--${i + 1}`}
                    initial={reduced ? false : { x: "101%" }}
                    animate={{ x: "0%" }}
                    transition={{ ...glijd, delay: vertraging(i * 0.12) }}
                  />
                ))}
                {shapes && (
                  <div className="fp-kmenu__vormen">
                    {VORMEN.map((els, i) => (
                      <motion.svg
                        key={i}
                        className={`fp-kmenu__vorm fp-kmenu__vorm--${i + 1}`}
                        viewBox="0 0 400 400"
                        fill="none"
                        initial="uit"
                        animate={hover === i ? "aan" : "uit"}
                      >
                        {els.map((el, j) => (
                          <motion.g
                            key={j}
                            variants={{
                              aan: { scale: 1, opacity: 1, rotate: 0, transition: { ...pop, delay: vertraging(j * 0.08) } },
                              uit: { scale: 0.8, opacity: 0, rotate: -10, transition: { ...pop, delay: 0 } },
                            }}
                            style={{ transformOrigin: "200px 200px" }}
                          >
                            {el}
                          </motion.g>
                        ))}
                      </motion.svg>
                    ))}
                  </div>
                )}
              </div>

              <div className="fp-kmenu__inhoud">
                <ul className="fp-kmenu__lijst">
                  {links.map((l, i) => {
                    const vorm = (l.shape ?? ((i % 5) + 1)) - 1;
                    return (
                      <li
                        key={l.id ?? l.href}
                        className="fp-kmenu__item"
                        onMouseEnter={() => setHover(vorm)}
                        onMouseLeave={() => setHover((h) => (h === vorm ? null : h))}
                        onFocus={() => setHover(vorm)}
                        onBlur={() => setHover((h) => (h === vorm ? null : h))}
                      >
                        <Link ref={i === 0 ? eersteLink : undefined} href={l.href} className="fp-kmenu__link" onClick={() => zet(false)}>
                          <span className="fp-kmenu__link-vak">
                            <motion.span
                              className="fp-kmenu__link-tekst"
                              initial={reduced ? false : { y: "140%", rotate: 10 }}
                              animate={{ y: "0%", rotate: 0 }}
                              transition={{ ...glijd, delay: vertraging(0.35 + i * 0.05) }}
                            >
                              {l.label}
                            </motion.span>
                          </span>
                          <span className="fp-kmenu__glans" aria-hidden="true" />
                        </Link>
                      </li>
                    );
                  })}
                </ul>
                {extra && (
                  <motion.div
                    className="fp-kmenu__extra"
                    initial={reduced ? false : { opacity: 0, y: "50%" }}
                    animate={{ opacity: 1, y: "0%" }}
                    transition={{ ...glijd, delay: vertraging(0.55) }}
                  >
                    {extra}
                  </motion.div>
                )}
              </div>
            </motion.nav>
          </div>
        )}
      </AnimatePresence>
    </div>
  );
}
blocks/menu/menu.css
/**
 * Menu-blocks: kinetic-menu.
 * Importeren in de app-root (de registry-site doet dat in app/globals.css).
 */

/* ── KineticMenu ────────────────────────────────────────────────────────── */

.fp-kmenu { color: var(--foreground); }
/* Vast aan de viewport (site) of absoluut in een relatieve ouder (paneel/demo). */
.fp-kmenu--vast .fp-kmenu__balk, .fp-kmenu--vast .fp-kmenu__laag { position: fixed; }
.fp-kmenu--los .fp-kmenu__balk, .fp-kmenu--los .fp-kmenu__laag { position: absolute; }

/* De balk: logo links, primaire links + hint + knop rechts. Laat de muis door, behalve op de kinderen. */
.fp-kmenu__balk {
  top: 0; left: 0; right: 0; z-index: 60;
  display: flex; align-items: center; justify-content: space-between; gap: 1rem;
  padding: 1rem clamp(1rem, 3vw, 2rem); pointer-events: none;
}
.fp-kmenu__balk > * { pointer-events: auto; }
.fp-kmenu__logo { display: inline-flex; align-items: center; font: inherit; font-weight: 700; letter-spacing: -0.02em; }
.fp-kmenu__rechts { display: flex; align-items: center; gap: clamp(0.75rem, 2vw, 1.5rem); }
.fp-kmenu__primair { display: flex; gap: clamp(0.75rem, 1.6vw, 1.5rem); }
.fp-kmenu__primair-link { font: inherit; font-size: 0.9375rem; color: var(--muted-foreground); text-decoration: none; transition: color var(--motion-instant-duration) var(--motion-instant); }
.fp-kmenu__primair-link:hover { color: var(--foreground); }
.fp-kmenu__hint { font: inherit; font-style: italic; font-size: 0.9375rem; color: var(--muted-foreground); }

/* De knop: twee woorden boven elkaar in een venster van één regel; open = het venster rolt één regel omhoog. */
.fp-kmenu__knop {
  display: inline-flex; align-items: center; gap: 0.625rem; padding: 0.375rem 0.5rem;
  appearance: none; border: 0; background: transparent; cursor: pointer;
  font: inherit; font-weight: 700; font-size: 1.375rem; line-height: 1.2; color: var(--foreground);
  border-radius: var(--radius);
}
.fp-kmenu__knop:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 2px; }
.fp-kmenu__knop[data-pressed] { opacity: 0.7; }
.fp-kmenu__knop-tekst { position: relative; display: block; height: 1.2em; overflow: hidden; }
.fp-kmenu__knop-tekst > span {
  display: block; height: 1.2em;
  transition: transform var(--motion-smooth-duration) var(--motion-smooth);
}
.fp-kmenu--open .fp-kmenu__knop-tekst > span { transform: translateY(-100%); }
.fp-kmenu__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.fp-kmenu__icoon-vak { display: inline-flex; width: 1em; height: 1em; }
.fp-kmenu__icoon { width: 100%; height: 100%; transition: transform var(--motion-smooth-duration) var(--motion-smooth); }
.fp-kmenu--open .fp-kmenu__icoon { transform: rotate(315deg); }

/* De laag: scrim over alles, paneel rechts met afgeronde linkerhoeken. */
.fp-kmenu__laag { inset: 0; z-index: 50; }
.fp-kmenu__scrim { position: absolute; inset: 0; background: var(--overlay, color-mix(in srgb, var(--foreground) 50%, transparent)); }
.fp-kmenu__paneel {
  position: absolute; top: 0; right: 0; bottom: 0; overflow: hidden;
  width: min(100%, 66%); min-width: min(100%, 22rem);
  border-radius: calc(var(--radius) * 4) 0 0 calc(var(--radius) * 4);
  color: var(--card-foreground, var(--foreground));
}
@media (max-width: 40rem) {
  .fp-kmenu__paneel { width: 100%; border-radius: 0; }
  .fp-kmenu__primair { display: none; }
}
/* Drie vlakken die elkaar met een vertraging volgen; het voorste is het echte paneel. */
.fp-kmenu__achter { position: absolute; inset: 0; }
.fp-kmenu__vlak { position: absolute; inset: 0; }
.fp-kmenu__vlak--1 { background: color-mix(in srgb, var(--primary) 80%, var(--background)); }
.fp-kmenu__vlak--2 { background: var(--muted); }
.fp-kmenu__vlak--3 { background: var(--card); }
/* De vormen: rechtsonder, groot, één tegelijk zichtbaar; kleur = tinten van de tokens. */
.fp-kmenu__vormen { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.fp-kmenu__vorm { position: absolute; right: -5%; bottom: -5%; width: min(60%, 34rem); aspect-ratio: 1; }
.fp-kmenu__vorm-el { fill: var(--fp-kmenu-tint); }
.fp-kmenu__vorm-el--lijn { fill: none; stroke: var(--fp-kmenu-tint); }
.fp-kmenu__vorm-el--a { --fp-kmenu-tint: color-mix(in srgb, var(--primary) 18%, transparent); }
.fp-kmenu__vorm-el--b { --fp-kmenu-tint: color-mix(in srgb, var(--info) 16%, transparent); }
.fp-kmenu__vorm-el--c { --fp-kmenu-tint: color-mix(in srgb, var(--danger) 12%, transparent); }

/* De inhoud: grote lijst, links uitgelijnd, boven de vormen. */
.fp-kmenu__inhoud {
  position: relative; z-index: 1; height: 100%; overflow: auto; box-sizing: border-box;
  display: flex; flex-direction: column; justify-content: center; gap: 2rem;
  padding: clamp(4.5rem, 12vh, 7rem) clamp(1.5rem, 6vw, 5rem) clamp(2rem, 6vh, 4rem);
}
.fp-kmenu__lijst { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.fp-kmenu__item { margin: 0; }
.fp-kmenu__link {
  position: relative; display: inline-block; text-decoration: none; color: inherit;
  font: inherit; font-weight: 800; font-size: var(--fp-kmenu-maat, clamp(2.25rem, 7vw, 5.5rem)); line-height: 1; letter-spacing: -0.02em; text-transform: uppercase;
  padding: 0.06em 0;
}
.fp-kmenu__link:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 4px; border-radius: var(--radius); }
/* Venster van één regel: de tekst komt van onderen (140%) met een draai omhoog. */
.fp-kmenu__link-vak { display: block; overflow: hidden; padding: 0.02em 0.1em; margin: 0 -0.1em; }
.fp-kmenu__link-tekst { display: block; transform-origin: left bottom; }
/* Hover-vlak achter de tekst: schuift van links open. */
.fp-kmenu__glans {
  position: absolute; inset: 0.08em -0.15em; z-index: -1; border-radius: calc(var(--radius) * 0.75);
  background: color-mix(in srgb, var(--primary) 16%, transparent);
  transform: scaleX(0); transform-origin: left center;
  transition: transform var(--motion-default-duration) var(--motion-default);
}
.fp-kmenu__link:hover .fp-kmenu__glans, .fp-kmenu__link:focus-visible .fp-kmenu__glans { transform: scaleX(1); }
.fp-kmenu__extra { font: inherit; font-size: 0.9375rem; color: var(--muted-foreground); }