Pil die iets aankondigt: links een kleine tag ('Nieuw', 'Versie 7.8'), rechts de titel met optioneel een icoon. Vijf tonen uit de status-tokens en twee vormen van de tag: ingebed of 'raised' (los kaartje met rand op een gedempt vlak). Als link of knop tapbaar, anders een statisch label.
NieuwComponenten v2 is erGereedBetaling verwerktVersie 7.8Nieuwe functie klaar om te proberen
Vijf tonen uit de status-tokens; de onderste is de 'raised'-vorm (Badge Tag). Gedrukt: 0×.
primitives/badges/announcement.tsx
/**
* Announcement — een pil die iets aankondigt: links een kleine tag
* ('Nieuw', 'Waarschuwing', 'Versie 7.8'), rechts de titel met optioneel een
* icoon. Vijf tonen uit de status-tokens (neutral, info, success, warning,
* danger) en twee vormen van de tag: ingebed (op lage dekking) of `raised`
* (los kaartje met rand op een gedempt vlak). Als link (href) of knop
* (onPress) wordt hij tapbaar; anders is het een statisch label.
*
* Geoogst uit 21st.dev Announcement (haydenbleasel) én Badge Tag
* (prebuiltui) — hetzelfde onderdeel in twee vormen, daarom één entry:
* shadcn Badge + cva + context → één component met `tone`/`raised`,
* bg-orange-100/text-orange-700 uit de demo → --warning via color-mix,
* bg-gray-500/10 + wit kaartje (Badge Tag) → de raised-vorm op --muted/--card,
* lucide ArrowUpRight → `icon`-slot, next/link → linkComponent.
*/
"use client";
import type * as React from "react";
import { usePress } from "../ui/press";
export type AnnouncementTone = "neutral" | "info" | "success" | "warning" | "danger";
export interface AnnouncementProps extends Omit<React.HTMLAttributes<HTMLElement>, "onClick" | "children"> {
/** De kleine tag links ('Nieuw', 'Versie 7.8'). */
tag?: React.ReactNode;
children: React.ReactNode;
/** Icoon achter de titel (bv. een pijl), van het project. */
icon?: React.ReactNode;
tone?: AnnouncementTone;
/** De tag als los kaartje met rand op een gedempt vlak (de Badge Tag-vorm). */
raised?: boolean;
/** Maakt er een link van. */
href?: string;
/** Maakt er een knop van (via usePress). */
onPress?: () => void;
linkComponent?: React.ElementType;
}
export function Announcement({ tag, children, icon, tone = "neutral", raised = false, href, onPress, linkComponent: Link = "a", className, ...rest }: AnnouncementProps) {
const { pressed, pressProps } = usePress({ onPress, disabled: !onPress });
const klassen = ["fp-ann", tone !== "neutral" ? `fp-ann--${tone}` : "", raised ? "fp-ann--raised" : "", className].filter(Boolean).join(" ");
const inhoud = (
<>
{tag !== undefined && <span className="fp-ann__tag">{tag}</span>}
<span className="fp-ann__titel">{children}{icon}</span>
</>
);
if (href) {
return <Link {...rest} href={href} className={klassen}>{inhoud}</Link>;
}
if (onPress) {
return (
<button {...(rest as React.ButtonHTMLAttributes<HTMLButtonElement>)} {...pressProps} type="button" className={klassen} data-pressed={pressed ? "" : undefined}>
{inhoud}
</button>
);
}
return <span {...rest} className={klassen}>{inhoud}</span>;
}
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; }