← alle entries

announcement

FC-0142Badges & bannersannouncementprimitivev1.0.0

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.

zelfde component — andere tokens
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; }