← alle entries

arrow-button

FC-0145Knoppenactionprimitivev1.0.0

Pil in de primaire kleur waarvan de pijl de cursor volgt (360°, kortste draai) en die bij hover van binnenuit omkeert: een cirkel in de achtergrondkleur groeit vanuit het midden tot hij de pil vult, de tekst kleurt mee. Indrukken via usePress.

zelfde component — andere tokens

Beweeg de muis rond de knop: de pijl volgt je. Kom erop: de pil keert om vanuit het midden. Gedrukt: 0×.

primitives/buttons/arrow-button.tsx
/**
 * ArrowButton — een pil in de primaire kleur waarvan de pijl de cursor volgt
 * (360°, altijd via de kortste draai) en die bij hover van binnenuit
 * 'omkeert': een cirkel in de achtergrondkleur groeit vanuit het midden tot
 * hij de pil vult, de tekst kleurt mee. Stijlvariant naast `Button`
 * (capability action; akkoord eigenaar 2026-09-11); indrukken via `usePress`.
 *
 * Geoogst uit 21st.dev Get Started Button (marcellinleclercq): zinc-900/50
 * + dark-varianten → --primary/--primary-foreground/--background, duration-
 * 500 ease-out/150 linear → motion-tokens (smooth voor de cirkel, instant
 * voor de pijl), onClick → onPress, aria-label → de tekst zelf. De hoek is
 * een CSS-variabele (--fp-hoek) zodat de pijl zonder re-render van de
 * stylesheet draait.
 */

"use client";

import { forwardRef, useImperativeHandle, useRef } from "react";
import type * as React from "react";
import { usePress } from "../ui/press";

export interface ArrowButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> {
  onPress?: () => void;
  /** Rusthoek van de pijl in graden. Default -45 (schuin omhoog). */
  restAngle?: number;
}

/** Van `from` naar `to` via de kortste draai (nooit 350° terug voor 10° vooruit). */
function kortsteHoek(from: number, to: number): number {
  return from + (((to - from + 540) % 360) - 180);
}

export const ArrowButton = forwardRef<HTMLButtonElement, ArrowButtonProps>(function ArrowButton(
  { onPress, restAngle = -45, disabled, className, type, children, style, ...rest },
  ref,
) {
  const knop = useRef<HTMLButtonElement>(null);
  useImperativeHandle(ref, () => knop.current as HTMLButtonElement);
  const hoek = useRef(restAngle);
  const { pressed, pressProps } = usePress({ onPress, disabled: disabled ?? false });

  const zet = (naar: number) => {
    hoek.current = kortsteHoek(hoek.current, naar);
    knop.current?.style.setProperty("--fp-hoek", `${hoek.current}deg`);
  };
  const volg = (e: React.PointerEvent<HTMLButtonElement>) => {
    pressProps.onPointerMove(e);
    if (e.pointerType !== "mouse") return;
    const r = knop.current?.getBoundingClientRect();
    if (!r) return;
    zet(Math.atan2(e.clientY - (r.top + r.height / 2), e.clientX - (r.left + r.width / 2)) * (180 / Math.PI));
  };

  return (
    <button
      {...rest}
      {...pressProps}
      onPointerMove={volg}
      onPointerLeave={() => zet(restAngle)}
      ref={knop}
      type={type ?? "button"}
      disabled={disabled}
      data-pressed={pressed ? "" : undefined}
      style={{ ...style, ["--fp-hoek" as string]: `${restAngle}deg` } as React.CSSProperties}
      className={["fp-arrowbtn", className].filter(Boolean).join(" ")}
    >
      <span className="fp-arrowbtn__vulling" aria-hidden="true" />
      <span className="fp-arrowbtn__tekst">{children}</span>
      <svg className="fp-arrowbtn__pijl" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
        <path d="M5 12h14M12 5l7 7-7 7" />
      </svg>
    </button>
  );
});
primitives/buttons/buttons.css
/**
 * Knoppen II — importeren in de app-root (registry-site: app/globals.css).
 * Token-contract 2. Varianten naast de basisknop (primitives/ui/button.tsx).
 */

/* ── arrow-button: pijl volgt de cursor, cirkel keert de pil om bij hover ── */
.fp-arrowbtn {
  --fp-hoek: -45deg;
  position: relative; display: inline-flex; align-items: center; justify-content: center; gap: var(--space-xs);
  height: 3.5rem; padding: 0 var(--space-xl); overflow: hidden; isolation: isolate;
  border: calc(var(--border-width) * 2) solid transparent; border-radius: 999px;
  background: var(--primary); color: var(--primary-foreground); cursor: pointer;
  font: inherit; font-size: var(--text-body-sm); font-weight: 500;
  transition: color var(--motion-default-duration) var(--motion-default), border-color var(--motion-default-duration) var(--motion-default);
}
.fp-arrowbtn:hover, .fp-arrowbtn:focus-visible { border-color: var(--primary); color: var(--primary); }
.fp-arrowbtn:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.fp-arrowbtn:disabled { cursor: default; opacity: 0.6; }
.fp-arrowbtn[data-pressed] .fp-arrowbtn__tekst { transform: scale(0.97); }
.fp-arrowbtn[data-pressed] .fp-arrowbtn__pijl { transform: rotate(var(--fp-hoek)) scale(0.9); }
/* De vulling: een kleine cirkel in het midden die bij hover 40× groeit (transform, geen layout). */
.fp-arrowbtn__vulling {
  position: absolute; left: 50%; top: 50%; z-index: 0; width: 1.25rem; height: 1.25rem; pointer-events: none;
  border-radius: 50%; background: var(--background);
  transform: translate(-50%, -50%) scale(0);
  transition: transform var(--motion-smooth-duration) var(--motion-smooth);
}
.fp-arrowbtn:hover .fp-arrowbtn__vulling, .fp-arrowbtn:focus-visible .fp-arrowbtn__vulling { transform: translate(-50%, -50%) scale(40); }
.fp-arrowbtn__tekst { position: relative; z-index: 1; }
.fp-arrowbtn__pijl {
  position: relative; z-index: 1; width: 1.125rem; height: 1.125rem;
  transform: rotate(var(--fp-hoek));
  transition: transform var(--motion-instant-duration) var(--motion-instant);
}
@media (prefers-reduced-motion: reduce) {
  .fp-arrowbtn__vulling, .fp-arrowbtn__pijl { transition: none; }
}