De pil boven een hero-kop: icoon vóór, tekst, icoon erna; komt van boven in en het voorste icoon kantelt en kleurt naar --primary als je erop komt. Als link of knop. Drie vormen (default, outline, ghost) en drie maten (typerollen label, tekst-klein, tekst).
Nieuw! Componenten v2
Komt van boven in; kom erop en het voorste icoon kantelt en kleurt naar de merkkleur. Gedrukt: 0×.
primitives/badges/hero-badge.tsx
/**
* HeroBadge — de pil boven een hero-kop ('Nieuw! Componenten v2 →'): een
* icoon vóór, tekst, een icoon erna; komt bij verschijnen van boven in en
* het voorste icoon kantelt licht als je erop komt (en kleurt naar
* --primary). Als link (href) of knop (onPress). Drie vormen (default,
* outline, ghost) en drie maten (sm, md, lg) — de maten zijn typerollen.
*
* Geoogst uit 21st.dev Hero Badge (Codehagen): next/link → linkComponent,
* losse cubic-bezier [0.16, 1, 0.3, 1] + 0.8 s → smooth-token, spring
* stiffness 300/damping 10 → pop-token, useAnimation → whileHover-varianten,
* tekst-xs/sm/base → --text-label/--text-body-sm/--text-body, onClick →
* onPress (usePress), PrismUI-iconen uit de demo → slots.
*/
"use client";
import { useMemo } from "react";
import type * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { useSpringTransition } from "../motion";
import { usePress } from "../ui/press";
export interface HeroBadgeProps extends Omit<React.HTMLAttributes<HTMLElement>, "onClick" | "children"> {
children: React.ReactNode;
/** Icoon vóór de tekst (kantelt bij hover). */
icon?: React.ReactNode;
/** Icoon na de tekst (bv. een chevron). */
endIcon?: React.ReactNode;
variant?: "default" | "outline" | "ghost";
size?: "sm" | "md" | "lg";
href?: string;
onPress?: () => void;
linkComponent?: React.ElementType;
}
export function HeroBadge({ children, icon, endIcon, variant = "default", size = "md", href, onPress, linkComponent: Link = "a", className, ...rest }: HeroBadgeProps) {
const reduced = useReducedMotion() ?? false;
const entree = useSpringTransition("smooth", { mass: "content" });
const kantel = useSpringTransition("pop");
const { pressed, pressProps } = usePress({ onPress, disabled: !onPress });
const MotionLink = useMemo(() => motion.create(Link as React.ElementType), [Link]);
const klassen = ["fp-hbadge", `fp-hbadge--${variant}`, `fp-hbadge--${size}`, className].filter(Boolean).join(" ");
const pil = (
<motion.span className="fp-hbadge__pil" initial={reduced ? false : { opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} transition={entree}>
{icon && (
<motion.span className="fp-hbadge__icoon fp-hbadge__icoon--voor" variants={{ rest: { rotate: 0 }, hover: { rotate: -10 } }} transition={kantel}>
{icon}
</motion.span>
)}
<span>{children}</span>
{endIcon && <span className="fp-hbadge__icoon">{endIcon}</span>}
</motion.span>
);
if (href) {
return (
<MotionLink {...rest} href={href} className={klassen} initial="rest" animate="rest" whileHover="hover" whileFocus="hover">
{pil}
</MotionLink>
);
}
return (
<motion.button
{...(rest as React.ComponentProps<typeof motion.button>)}
{...pressProps}
type="button"
className={klassen}
data-pressed={pressed ? "" : undefined}
initial="rest"
animate="rest"
whileHover="hover"
whileFocus="hover"
>
{pil}
</motion.button>
);
}
primitives/badges/badges.css
/**
* Badges & banners — importeren in de app-root (registry-site: app/globals.css).
* Token-contract 2. Toon-varianten lopen via één CSS-variabele (--fp-toon) die
* uit de status-tokens komt; de entries zelf bevatten nul kleuren.
*/
/* ── announcement: pil met een tag links en een titel ─────────────────────── */
.fp-ann {
--fp-toon: var(--foreground);
display: inline-flex; align-items: center; gap: var(--space-xs); max-width: 100%;
padding: 0.125rem var(--space-sm) 0.125rem 0.125rem; border-radius: 999px;
border: var(--border-width) solid var(--border); background: var(--background); color: var(--foreground);
font-size: var(--text-body-sm); font-weight: 500; line-height: 1.4; text-decoration: none;
box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 6%, transparent);
transition: box-shadow var(--motion-instant-duration) var(--motion-instant), border-color var(--motion-instant-duration) var(--motion-instant);
}
a.fp-ann:hover, button.fp-ann:hover { box-shadow: 0 2px 6px color-mix(in srgb, var(--foreground) 10%, transparent); }
.fp-ann:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
button.fp-ann { cursor: pointer; font: inherit; font-size: var(--text-body-sm); font-weight: 500; }
button.fp-ann[data-pressed] { transform: scale(0.98); }
/* Getinte tonen: vlak en rand uit de status-token, tekst in de token zelf. */
.fp-ann--info { --fp-toon: var(--info); }
.fp-ann--success { --fp-toon: var(--success); }
.fp-ann--warning { --fp-toon: var(--warning); }
.fp-ann--danger { --fp-toon: var(--danger); }
.fp-ann--info, .fp-ann--success, .fp-ann--warning, .fp-ann--danger {
color: var(--fp-toon);
background: color-mix(in srgb, var(--fp-toon) 10%, var(--background));
border-color: color-mix(in srgb, var(--fp-toon) 20%, transparent);
}
.fp-ann__tag {
flex: 0 0 auto; padding: 0.125rem var(--space-xs); border-radius: 999px;
font-size: var(--text-label); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
background: color-mix(in srgb, var(--fp-toon) 8%, transparent);
}
.fp-ann--info .fp-ann__tag, .fp-ann--success .fp-ann__tag, .fp-ann--warning .fp-ann__tag, .fp-ann--danger .fp-ann__tag { background: color-mix(in srgb, var(--background) 60%, transparent); }
/* 'raised': de tag als los kaartje met rand (de Badge Tag-vorm). */
.fp-ann--raised .fp-ann__tag { background: var(--card); border: var(--border-width) solid var(--border); }
.fp-ann--raised { background: var(--muted); }
.fp-ann__titel { display: inline-flex; align-items: center; gap: var(--space-2xs); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fp-ann__titel > svg { flex: 0 0 auto; width: 1em; height: 1em; opacity: 0.7; }
/* ── promo-banner: één regel met onderstreepte actie, toelichting en sluiten ── */
.fp-promo { position: relative; display: inline-flex; }
.fp-promo__vlak {
position: relative; display: flex; align-items: center; gap: var(--space-2xs); height: 2.25rem;
padding: 0 var(--space-2xs) 0 var(--space-xs); border-radius: var(--radius-sm);
border: var(--border-width) solid color-mix(in srgb, var(--info) 30%, transparent);
background: color-mix(in srgb, var(--info) 8%, var(--background));
font-size: var(--text-body-sm); color: var(--info);
}
.fp-promo__actie {
margin: 0; padding: var(--space-2xs) 0; border: 0; background: transparent; cursor: pointer;
font: inherit; font-size: var(--text-body-sm); font-weight: 500; color: var(--foreground);
text-decoration: underline; text-decoration-color: color-mix(in srgb, var(--info) 35%, transparent); text-underline-offset: 0.35em;
border-radius: var(--radius-sm);
transition: color var(--motion-instant-duration) var(--motion-instant), text-decoration-color var(--motion-instant-duration) var(--motion-instant);
}
.fp-promo__actie:hover { color: var(--info); text-decoration-color: color-mix(in srgb, var(--info) 60%, transparent); }
.fp-promo__actie:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.fp-promo__tekst { margin: 0; white-space: nowrap; }
.fp-promo__sluit {
display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;
width: 1.5rem; height: 1.5rem; margin: 0; padding: 0; border: 0; border-radius: var(--radius-sm);
background: transparent; color: var(--info); cursor: pointer;
transition: background var(--motion-instant-duration) var(--motion-instant);
}
.fp-promo__sluit:hover { background: color-mix(in srgb, var(--info) 15%, transparent); }
.fp-promo__sluit:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; }
.fp-promo__sluit > svg { width: 1rem; height: 1rem; }
/* De twee tandwielen die bij hover uit de hoeken zweven — verf, geen layout. */
.fp-promo__wiel { position: absolute; z-index: 1; width: 1rem; height: 1rem; color: var(--info); pointer-events: none; }
.fp-promo__wiel--lb { left: 0.25rem; top: 0.125rem; }
.fp-promo__wiel--ro { left: 6rem; bottom: 0.125rem; }
/* ── hero-badge: pil boven een hero, met icoon dat kantelt bij hover ──────── */
.fp-hbadge { display: inline-flex; cursor: pointer; text-decoration: none; color: inherit; border: 0; background: transparent; padding: 0; margin: 0; font: inherit; border-radius: 999px; }
.fp-hbadge:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.fp-hbadge__pil {
display: inline-flex; align-items: center; gap: var(--space-xs);
padding: 0.375rem var(--space-md); border-radius: 999px;
border: var(--border-width) solid var(--border); background: var(--background); color: var(--foreground);
font-size: var(--text-body-sm); font-weight: 500; line-height: 1.2;
transition: background var(--motion-instant-duration) var(--motion-instant), border-color var(--motion-instant-duration) var(--motion-instant);
}
.fp-hbadge:hover .fp-hbadge__pil { background: var(--muted); }
.fp-hbadge--outline .fp-hbadge__pil { border-width: calc(var(--border-width) * 2); }
.fp-hbadge--ghost .fp-hbadge__pil { border-color: transparent; background: transparent; }
.fp-hbadge--ghost:hover .fp-hbadge__pil { background: color-mix(in srgb, var(--muted) 50%, transparent); }
.fp-hbadge--sm .fp-hbadge__pil { gap: var(--space-2xs); padding: var(--space-2xs) var(--space-sm); font-size: var(--text-label); }
.fp-hbadge--lg .fp-hbadge__pil { gap: var(--space-xs); padding: var(--space-xs) var(--space-lg); font-size: var(--text-body); }
.fp-hbadge__icoon { display: inline-flex; color: color-mix(in srgb, var(--foreground) 60%, transparent); transition: color var(--motion-instant-duration) var(--motion-instant); }
.fp-hbadge:hover .fp-hbadge__icoon--voor { color: var(--primary); }
.fp-hbadge__icoon > svg { width: 1em; height: 1em; }