Smalle promotieregel in de info-kleur: een onderstreepte actie, een korte toelichting en optioneel een sluitknop. Schuift bij verschijnen van rechts in; zodra je op de actie komt, zweven twee tandwielen uit de hoeken.
voor 2× zoveel ruimte en snellere builds
Kom op de actie: twee tandwielen zweven uit de hoeken. Het kruisje meldt onClose; het project verbergt de banner. Gedrukt: 0×.
primitives/badges/promo-banner.tsx
/**
* PromoBanner — een smalle promotieregel in de info-kleur: een onderstreepte
* actie ('Upgrade naar Pro'), een korte toelichting en optioneel een
* sluitknop. Schuift bij verschijnen van rechts in; zodra je op de actie
* komt, zweven twee tandwielen uit de hoeken (verschuiven + draaien).
*
* Geoogst uit 21st.dev Upgrade Banner (victorwelander): de twaalf blauwe
* hexwaarden (#005FF2, #F0F7FF, #CBE7FF, …) → --info via color-mix, Geist
* SettingsFilled → eigen tandwiel-pad, lucide X → inline kruis, onClick →
* onPress (usePress), framer easeOut/spring stiffness → motion-tokens
* (smooth voor de entree, pop voor de wielen), Engelse defaults → verplichte
* `action`/`description`.
*/
"use client";
import { useState } 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 PromoBannerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onClick"> {
/** De onderstreepte actie. */
action: React.ReactNode;
/** De toelichting erachter. */
description?: React.ReactNode;
onPress?: () => void;
/** Toont de sluitknop; het project verbergt de banner zelf. */
onClose?: () => void;
closeLabel?: string;
}
function Wiel() {
return (
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" clipRule="evenodd" d="M9.5 0h-3l-.27 1.46a.5.5 0 0 1-.35.4c-.25.08-.49.18-.72.3a.5.5 0 0 1-.53-.04L3.4 1.28 1.28 3.4l.84 1.23a.5.5 0 0 1 .04.53c-.12.23-.22.47-.3.72a.5.5 0 0 1-.4.35L0 6.5v3l1.46.27a.5.5 0 0 1 .4.35c.08.25.18.49.3.72a.5.5 0 0 1-.04.53L1.28 12.6l2.12 2.12 1.23-.84a.5.5 0 0 1 .53-.04c.23.12.47.22.72.3a.5.5 0 0 1 .35.4L6.5 16h3l.27-1.46a.5.5 0 0 1 .35-.4c.25-.08.49-.18.72-.3a.5.5 0 0 1 .53.04l1.23.84 2.12-2.12-.84-1.23a.5.5 0 0 1-.04-.53c.12-.23.22-.47.3-.72a.5.5 0 0 1 .4-.35L16 9.5v-3l-1.46-.27a.5.5 0 0 1-.4-.35 6 6 0 0 0-.3-.72.5.5 0 0 1 .04-.53l.84-1.23L12.6 1.28l-1.23.84a.5.5 0 0 1-.53.04 6 6 0 0 0-.72-.3.5.5 0 0 1-.35-.4L9.5 0ZM8 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
</svg>
);
}
export function PromoBanner({ action, description, onPress, onClose, closeLabel = "Sluiten", className, ...rest }: PromoBannerProps) {
const [hover, setHover] = useState(false);
const reduced = useReducedMotion() ?? false;
const entree = useSpringTransition("smooth", { mass: "content" });
const zweef = useSpringTransition("pop");
const { pressed, pressProps } = usePress({ onPress });
const sluit = usePress({ onPress: onClose, disabled: !onClose });
const wiel = (dx: number, dy: number) => ({
initial: false as const,
animate: hover && !reduced ? { x: dx, y: dy, opacity: 1, rotate: 360 } : { x: 0, y: 0, opacity: 0, rotate: 0 },
transition: zweef,
});
return (
<motion.div {...(rest as React.ComponentProps<typeof motion.div>)} className={["fp-promo", className].filter(Boolean).join(" ")} initial={reduced ? false : { opacity: 0, x: 30 }} animate={{ opacity: 1, x: 0 }} transition={entree}>
<motion.span className="fp-promo__wiel fp-promo__wiel--lb" aria-hidden="true" {...wiel(-10, -10)}><Wiel /></motion.span>
<motion.span className="fp-promo__wiel fp-promo__wiel--ro" aria-hidden="true" {...wiel(10, 10)}><Wiel /></motion.span>
<div className="fp-promo__vlak">
<button
{...pressProps}
type="button"
className="fp-promo__actie"
data-pressed={pressed ? "" : undefined}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
onFocus={() => setHover(true)}
onBlur={() => setHover(false)}
>
{action}
</button>
{description && <span className="fp-promo__tekst">{description}</span>}
{onClose && (
<button {...sluit.pressProps} type="button" className="fp-promo__sluit" data-pressed={sluit.pressed ? "" : undefined} aria-label={closeLabel}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
)}
</div>
</motion.div>
);
}
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; }