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