Skip to content
UI Kit
Accent color

Product badges

Badges for new, sale, sustainable, limited, bestseller and rating. On cards, explained, as drawn stickers or as filters.

4 variantsOne of 2 components in Sales and promotions

Small labels with a clear job

A product badge tells a shopper in one word why a second look pays off. These cover new, sale, sustainable, limited, bestseller and rating, each kind with its own quiet color and a fixed place, so a full grid stays calm.

On product cards stacks status badges top left, two at most, puts the rating top right and sustainability at the foot of the picture. Explained opens a short explanation with the proof behind each badge on a product page, one at a time, and Escape or a click outside closes it. Stickers are drawn for campaign pictures, a slowly turning sale burst, a ring with text running round the edge, a corner ribbon for new pieces and a stamp for sustainable ones. As filters turns the badges into toggles above a grid, each counts what it would leave.

Limit each card to two status badges, then each one keeps its meaning. Back claims like sustainable with the explained version. Keep stickers for campaign pictures and banners, cards read best with the quiet badges.

Variants

On product cardsNew in 1.2

Fixed places keep a grid calm. Status badges stack top left (two at most), the rating sits top right and sustainability at the foot of the picture. Each kind has its own quiet color.

ExplainedNew in 1.2

On a product page every badge opens a short explanation with the proof behind it. One opens at a time, Escape and a click outside close it, focus goes back to the badge.

StickersNew in 1.2

Drawn stickers for campaign pictures. A slowly turning sale burst, a ring whose text runs around the edge, a corner ribbon for new pieces and a stamp for sustainable ones.

As filtersNew in 1.2

The badges as toggles above a grid, several at once. Each counts what it would leave, empty choices step back, the grid rebuilds with a short rise and a clear text when nothing fits.

Details

What Product badges brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/product-badges.tsx
Brings along
utilskitbuttoniconproduct-image
npm packages
None
Languages
English and German, 18 texts of its own
Studio
Adjust in code

Props

The ProductBadgesProps interface from components/ui/product-badges.tsx, with the comments from the code.

  • variantoptional
    ProductBadgesVariant

    on-cardsexplainedstickersfilter

  • eyebrowoptional
    string
  • titleoptional
    string
  • descriptionoptional
    string
  • productsoptional
    BadgeProduct[]
  • ringTextoptional
    string

    stickers: the lines on the ring sticker

  • currencyoptional
    string

Built-in texts

Product badges writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 18 texts
newEnglish NewGerman Neu
saleEnglish SaleGerman Sale
ecoEnglish SustainableGerman Nachhaltig
limitedEnglish LimitedGerman Limitiert
limitedOfEnglish {n} piecesGerman {n} Stück
bestsellerEnglish BestsellerGerman Bestseller
ratingEnglish {value} of 5 starsGerman {value} von 5 Sternen
reviewsEnglish {n} reviewsGerman {n} Bewertungen
offEnglish −{pct}German −{pct}
whatMeansEnglish What does "{label}" mean?German Was bedeutet „{label}“?
closeEnglish CloseGerman Schließen
filtersEnglish Show onlyGerman Nur zeigen
allEnglish Show allGerman Alle zeigen
resultsEnglish {n} productsGerman {n} Produkte
noneEnglish No product carries all of these. Remove a filter to see more.German Kein Produkt trägt alle diese Merkmale. Nimm einen Filter heraus, um mehr zu sehen.
handmadeEnglish Made by handGerman Von Hand gemacht
salePriceEnglish Sale priceGerman Angebotspreis
regularPriceEnglish Regular priceGerman Regulärer Preis

Accessibility

Read from the source of the component.

  • Product badges handles the keys Escape itself.
  • Sets aria-controls, aria-expanded, aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
  • Uses the role group.
  • Announces changes through a live region without moving focus.
  • Adds text for screen readers where icons and graphics stand alone.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 501 lines
components/ui/product-badges.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, formatPercent, round, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { ProductMedia, ShopMotion, formatNumber, formatPrice } from '@/components/ui/product-image';

/*
 * Badges on products: new, sale, sustainable, limited, bestseller and the rating.
 *
 *   on-cards   product cards with badges in fixed places: status top left, rating top right, sustainability at the foot
 *   explained  badges on a product page that open a short explanation with the proof behind it
 *   stickers   drawn stickers for campaign pictures: a sale burst, a turning ring, a ribbon, a stamp
 *   filter     the badges as filters above a grid, several at once, with counts
 *
 * <ProductBadge kind="sale" value={20} /> is exported for your own cards.
 */

export type ProductBadgesVariant = 'on-cards' | 'explained' | 'stickers' | 'filter';
export type BadgeKind = 'new' | 'sale' | 'eco' | 'limited' | 'bestseller' | 'rating' | 'custom';

export interface BadgeData {
  kind: BadgeKind;
  /** Own text. Without it the badge writes its own (New, −20 %, Limited …) */
  label?: string;
  /** sale: percent off. rating: stars. limited: pieces. */
  value?: number;
  /** explained: the short text behind the badge */
  detail?: string;
  icon?: IconName;
}

export interface BadgeProduct {
  id: string;
  name: string;
  href?: string;
  image?: React.ReactNode;
  price: number;
  compareAt?: number;
  subtitle?: string;
  badges: BadgeData[];
  /** rating count */
  reviews?: number;
}

export interface ProductBadgesProps {
  variant?: ProductBadgesVariant;
  eyebrow?: string;
  title?: string;
  description?: string;
  products?: BadgeProduct[];
  /** stickers: the lines on the ring sticker */
  ringText?: string;
  currency?: string;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    new: 'New',
    sale: 'Sale',
    eco: 'Sustainable',
    limited: 'Limited',
    limitedOf: '{n} pieces',
    bestseller: 'Bestseller',
    rating: '{value} of 5 stars',
    reviews: '{n} reviews',
    off: '−{pct}',
    whatMeans: 'What does "{label}" mean?',
    close: 'Close',
    filters: 'Show only',
    all: 'Show all',
    results: '{n} products',
    none: 'No product carries all of these. Remove a filter to see more.',
    handmade: 'Made by hand',
    salePrice: 'Sale price',
    regularPrice: 'Regular price',
  },
  de: {
    new: 'Neu',
    sale: 'Sale',
    eco: 'Nachhaltig',
    limited: 'Limitiert',
    limitedOf: '{n} Stück',
    bestseller: 'Bestseller',
    rating: '{value} von 5 Sternen',
    reviews: '{n} Bewertungen',
    off: '−{pct}',
    whatMeans: 'Was bedeutet „{label}“?',
    close: 'Schließen',
    filters: 'Nur zeigen',
    all: 'Alle zeigen',
    results: '{n} Produkte',
    none: 'Kein Produkt trägt alle diese Merkmale. Nimm einen Filter heraus, um mehr zu sehen.',
    handmade: 'Von Hand gemacht',
    salePrice: 'Angebotspreis',
    regularPrice: 'Regulärer Preis',
  },
};

type Texts = (typeof TEXTS)['en'];

const fill = (s: string, vars: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ''));
const currencyOf = (locale: Locale, currency?: string) => currency ?? (locale === 'de' ? 'EUR' : 'USD');
const KIND_ICON: Partial<Record<BadgeKind, IconName>> = { new: 'sparkle', eco: 'leaf', limited: 'hourglass', bestseller: 'trophy', rating: 'star' };

/* Each kind has its own calm color. Warm sale red and green stay readable in both modes through oklab mixes. */
const STYLE: Record<BadgeKind, string> = {
  new: 'bg-primary text-primary-foreground',
  sale: 'bg-[color-mix(in_oklch,var(--destructive)_88%,black)] text-white',
  eco: 'bg-[color-mix(in_oklab,var(--success)_16%,var(--card))] text-success-foreground ring-1 ring-inset ring-[color-mix(in_oklab,var(--success)_35%,transparent)]',
  limited: 'bg-foreground text-background',
  bestseller: 'bg-[color-mix(in_oklab,var(--warning)_22%,var(--card))] text-warning-foreground ring-1 ring-inset ring-[color-mix(in_oklab,var(--warning)_40%,transparent)]',
  rating: 'bg-background/92 text-foreground shadow-[var(--shadow-sm)] backdrop-blur',
  custom: 'bg-card text-foreground ring-1 ring-inset ring-border-strong',
};

export function badgeText(b: BadgeData, t: Texts, locale: Locale) {
  if (b.label) return b.label;
  if (b.kind === 'sale' && b.value) return fill(t.off, { pct: formatPercent(b.value, locale) });
  if (b.kind === 'rating') return formatNumber(locale, b.value ?? 0, 1);
  if (b.kind === 'custom') return '';
  return t[b.kind];
}

/** One badge. Small and quiet on cards, a little larger on product pages. */
export function ProductBadge({ kind, label, value, icon, size = 'sm', className, labels }: BadgeData & { size?: 'sm' | 'md'; className?: string; labels?: Partial<Texts> }) {
  const locale = useLocale();
  const t = useTexts(TEXTS, labels);
  const text = badgeText({ kind, label, value }, t, locale);
  const ic = icon ?? KIND_ICON[kind];
  return (
    <span className={cn('inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-md font-semibold tabular-nums', size === 'sm' ? 'h-6 px-2 text-[11px]' : 'h-7 px-2.5 text-[12px]', STYLE[kind], className)}>
      {ic && <Icon name={ic} size={size === 'sm' ? 12 : 13} className={cn(kind === 'rating' && 'fill-[oklch(0.8_0.15_80)] text-[oklch(0.72_0.15_75)]')} />}
      {text}
      {kind === 'rating' && <span className="sr-only"> {fill(t.rating, { value: formatNumber(locale, value ?? 0, 1) })}</span>}
    </span>
  );
}

function PriceLine({ p, currency, t }: { p: BadgeProduct; currency: string; t: Texts }) {
  const locale = useLocale();
  const reduced = p.compareAt && p.compareAt > p.price;
  return (
    <p className="flex flex-wrap items-baseline gap-x-2 text-[14px] tabular-nums">
      {reduced && <span className="sr-only">{t.salePrice}</span>}
      <span className={cn('font-medium', reduced ? 'text-[color-mix(in_oklch,var(--destructive)_85%,var(--foreground))]' : 'text-foreground')}>{formatPrice(locale, p.price, currency)}</span>
      {reduced && (
        <>
          <span className="sr-only">{t.regularPrice}</span>
          <s className="text-[13px] text-muted-foreground">{formatPrice(locale, p.compareAt!, currency)}</s>
        </>
      )}
    </p>
  );
}

function Head({ props }: { props: ProductBadgesProps }) {
  if (!props.title) return null;
  return (
    <div className="mb-8 max-w-[40rem] sm:mb-10">
      {props.eyebrow && <p className="mb-3 text-sm font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{props.eyebrow}</p>}
      <h2 className="text-balance text-h2 font-semibold text-foreground">{props.title}</h2>
      {props.description && <p className="mt-3 text-pretty text-[16px] text-muted-foreground">{props.description}</p>}
    </div>
  );
}

/** A card with badges in fixed places, so a grid stays calm however many there are */
function BadgeCard({ p, t, currency, index }: { p: BadgeProduct; t: Texts; currency: string; index: number }) {
  const status = p.badges.filter((b) => b.kind === 'new' || b.kind === 'sale' || b.kind === 'limited' || b.kind === 'bestseller' || b.kind === 'custom').slice(0, 2);
  const rating = p.badges.find((b) => b.kind === 'rating');
  const eco = p.badges.find((b) => b.kind === 'eco');
  return (
    <div className="kit-rise group/card" style={{ '--kit-delay': `${index * 70}ms` } as React.CSSProperties}>
      <div className="relative">
        <ProductMedia className="aspect-[4/5] rounded-xl [&>*]:transition-transform [&>*]:duration-700 [&>*]:ease-[var(--ease-kit)] group-hover/card:[&>*]:scale-[1.04] motion-reduce:[&>*]:transition-none">{p.image}</ProductMedia>
        {status.length > 0 && (
          <div className="absolute left-2.5 top-2.5 flex flex-col items-start gap-1.5">
            {status.map((b, i) => (
              <ProductBadge key={i} {...b} className="kit-rise" />
            ))}
          </div>
        )}
        {rating && <ProductBadge {...rating} className="absolute right-2.5 top-2.5" />}
        {eco && <ProductBadge {...eco} className="absolute bottom-2.5 left-2.5" />}
      </div>
      <a href={p.href ?? '#'} className="mt-3 block text-[14px] font-medium text-foreground hover:underline hover:underline-offset-4 relative after:absolute after:inset-x-0 after:-inset-y-3">
        {p.name}
      </a>
      {p.subtitle && <p className="text-[13px] text-muted-foreground">{p.subtitle}</p>}
      <div className="mt-1">
        <PriceLine p={p} currency={currency} t={t} />
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* on-cards                                                            */
/* ------------------------------------------------------------------ */

function OnCards(props: ProductBadgesProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', props.className)}>
      <div className="kit-container">
        <Head props={props} />
        <ul className="grid grid-cols-2 gap-x-3 gap-y-8 sm:gap-x-5 lg:grid-cols-4 lg:gap-x-6">
          {(props.products ?? []).map((p, i) => (
            <li key={p.id}>
              <BadgeCard p={p} t={t} currency={currency} index={i} />
            </li>
          ))}
        </ul>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* explained                                                           */
/* ------------------------------------------------------------------ */

function Explained(props: ProductBadgesProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const p = props.products?.[0];
  const [open, setOpen] = useState<number | null>(null);
  const wrap = useRef<HTMLDivElement>(null);
  const buttons = useRef<(HTMLButtonElement | null)[]>([]);
  const base = useId();
  useEffect(() => {
    if (open === null) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key !== 'Escape') return;
      buttons.current[open]?.focus();
      setOpen(null);
    };
    const onDown = (e: PointerEvent) => wrap.current && !wrap.current.contains(e.target as Node) && setOpen(null);
    window.addEventListener('keydown', onKey);
    document.addEventListener('pointerdown', onDown);
    return () => { window.removeEventListener('keydown', onKey); document.removeEventListener('pointerdown', onDown); };
  }, [open]);
  if (!p) return null;
  const rating = p.badges.find((b) => b.kind === 'rating');
  const others = p.badges.filter((b) => b.kind !== 'rating');
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <div className="grid grid-cols-1 items-start gap-8 md:grid-cols-2 lg:gap-16">
          <div className="relative">
            <ProductMedia className="aspect-square rounded-3xl max-md:aspect-[5/4]">{p.image}</ProductMedia>
            <div className="absolute left-4 top-4 flex flex-wrap gap-1.5">
              {others.slice(0, 2).map((b, i) => (
                <ProductBadge key={i} {...b} size="md" />
              ))}
            </div>
          </div>
          <div ref={wrap} className="min-w-0">
            {props.eyebrow && <p className="mb-2 text-sm font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{props.eyebrow}</p>}
            <h2 className="text-balance text-h2 font-semibold text-foreground">{p.name}</h2>
            {rating && (
              <p className="mt-3 flex items-center gap-2 text-[14px] text-muted-foreground">
                <ProductBadge {...rating} size="md" className="shadow-none ring-1 ring-inset ring-border" />
                {p.reviews ? fill(t.reviews, { n: formatNumber(locale, p.reviews) }) : null}
              </p>
            )}
            <div className="mt-4">
              <PriceLine p={p} currency={currency} t={t} />
            </div>
            {p.subtitle && <p className="mt-4 text-pretty text-[15px] text-muted-foreground">{p.subtitle}</p>}
            <ul className="mt-7 space-y-2 border-t border-border pt-6">
              {others.map((b, i) => {
                const label = badgeText(b, t, locale);
                const isOpen = open === i;
                return (
                  <li key={i} className="relative">
                    <button
                      ref={(el) => { buttons.current[i] = el; }}
                      type="button"
                      aria-expanded={isOpen}
                      aria-controls={`${base}-${i}`}
                      onClick={() => setOpen(isOpen ? null : i)}
                      className={cn('flex min-h-12 w-full items-center gap-3 rounded-xl border px-3 text-left transition-[border-color,background-color] duration-200', isOpen ? 'border-border-strong bg-surface' : 'border-transparent hover:bg-surface')}
                    >
                      <ProductBadge {...b} size="md" />
                      <span className="min-w-0 flex-1 truncate text-[13px] text-muted-foreground">{fill(t.whatMeans, { label })}</span>
                      <Icon name="chevron-down" size={15} className={cn('shrink-0 text-muted-foreground transition-transform duration-300 motion-reduce:transition-none', isOpen && 'rotate-180')} />
                    </button>
                    <div id={`${base}-${i}`} className={cn('grid transition-[grid-template-rows,opacity] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', isOpen ? 'grid-rows-[1fr] opacity-100' : 'invisible grid-rows-[0fr] opacity-0')}>
                      <div className="min-h-0 overflow-hidden">
                        <p className="px-3 pb-3 pt-2 text-pretty text-[14px] leading-relaxed text-foreground">{b.detail}</p>
                      </div>
                    </div>
                  </li>
                );
              })}
            </ul>
          </div>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* stickers                                                            */
/* ------------------------------------------------------------------ */

/** A sale burst with twelve points */
function Burst({ text, className }: { text: string; className?: string }) {
  const pts = Array.from({ length: 24 }, (_, i) => {
    const a = (i / 24) * Math.PI * 2 - Math.PI / 2;
    const r = i % 2 ? 41 : 50;
    return `${round(50 + Math.cos(a) * r)},${round(50 + Math.sin(a) * r)}`;
  }).join(' ');
  return (
    <div className={cn('relative grid size-24 place-items-center sm:size-28', className)}>
      <svg viewBox="0 0 100 100" className="absolute inset-0 size-full drop-shadow-[0_6px_10px_rgb(0_0_0/0.18)] motion-safe:animate-[kit-spin_24s_linear_infinite]" aria-hidden>
        <polygon points={pts} fill="color-mix(in oklch, var(--destructive) 88%, black)" />
      </svg>
      <span className="relative -rotate-12 text-center text-[22px] font-bold leading-none tracking-[-0.03em] text-white sm:text-[26px]">{text}</span>
    </div>
  );
}

/** A round sticker with text running around its edge */
function Ring({ text, center, className }: { text: string; center: React.ReactNode; className?: string }) {
  const id = 'r' + useId().replace(/[^a-zA-Z0-9]/g, '');
  return (
    <div className={cn('relative grid size-28 place-items-center rounded-full bg-foreground text-background shadow-[var(--shadow-lg)] sm:size-32', className)}>
      <svg viewBox="0 0 100 100" className="absolute inset-0 size-full motion-safe:animate-[kit-spin_18s_linear_infinite]" aria-hidden>
        <defs>
          <path id={id} d="M50 50m-38 0a38 38 0 1 1 76 0a38 38 0 1 1 -76 0" />
        </defs>
        <text fontSize="9.2" fontWeight="600" letterSpacing="2.2" fill="currentColor">
          <textPath href={`#${id}`}>{text}</textPath>
        </text>
      </svg>
      <span className="relative grid size-12 place-items-center rounded-full bg-background text-foreground">{center}</span>
    </div>
  );
}

function Ribbon({ text }: { text: string }) {
  return (
    <div data-clip-ok className="pointer-events-none absolute right-0 top-0 size-28 overflow-hidden" aria-hidden>
      <span className="absolute right-[-2.6rem] top-[1.4rem] w-[10rem] rotate-45 bg-primary py-1.5 text-center text-[12px] font-bold uppercase tracking-[0.14em] text-primary-foreground shadow-[var(--shadow-md)]">{text}</span>
    </div>
  );
}

function Stamp({ text }: { text: string }) {
  return (
    <span className="inline-flex -rotate-6 items-center gap-1.5 rounded-lg border-2 border-dashed border-[color-mix(in_oklab,var(--success)_70%,var(--foreground))] bg-background/85 px-3 py-1.5 text-[12px] font-bold uppercase tracking-[0.12em] text-success-foreground backdrop-blur">
      <Icon name="leaf" size={14} />
      {text}
    </span>
  );
}

function Stickers(props: ProductBadgesProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const ps = props.products ?? [];
  const sticker = (p: BadgeProduct) => {
    const sale = p.badges.find((b) => b.kind === 'sale');
    const limited = p.badges.find((b) => b.kind === 'limited');
    const eco = p.badges.find((b) => b.kind === 'eco');
    const nw = p.badges.find((b) => b.kind === 'new');
    return (
      <>
        {nw && <Ribbon text={badgeText(nw, t, locale)} />}
        {sale && (
          <div className="absolute -right-2 -top-3 sm:-right-4 sm:-top-4">
            <Burst text={badgeText(sale, t, locale)} />
          </div>
        )}
        {limited && (
          <div className="absolute -bottom-4 -left-2 sm:-left-4">
            <Ring text={props.ringText ?? `${t.limited.toUpperCase()} · ${fill(t.limitedOf, { n: limited.value ?? 0 }).toUpperCase()} · ${t.limited.toUpperCase()} · `} center={<Icon name="hourglass" size={18} />} />
          </div>
        )}
        {eco && (
          <div className="absolute bottom-4 right-4">
            <Stamp text={badgeText(eco, t, locale)} />
          </div>
        )}
        <span className="sr-only">{p.badges.map((b) => badgeText(b, t, locale)).filter(Boolean).join(', ')}</span>
      </>
    );
  };
  return (
    <section className={cn('overflow-hidden py-16 sm:py-20 lg:py-24', props.className)}>
      <div className="kit-container">
        <Head props={props} />
        <ul className="grid grid-cols-1 gap-x-8 gap-y-14 px-3 sm:grid-cols-2 sm:px-4 lg:gap-x-12">
          {ps.map((p, i) => (
            <li key={p.id} className="kit-rise" style={{ '--kit-delay': `${i * 90}ms` } as React.CSSProperties}>
              <div className="relative">
                <ProductMedia className="aspect-[5/4] rounded-2xl">{p.image}</ProductMedia>
                {sticker(p)}
              </div>
              <div className="mt-6 flex items-baseline justify-between gap-4">
                <a href={p.href ?? '#'} className="text-[16px] font-medium text-foreground hover:underline hover:underline-offset-4 relative after:absolute after:inset-x-0 after:-inset-y-3">
                  {p.name}
                </a>
                <PriceLine p={p} currency={currency} t={t} />
              </div>
            </li>
          ))}
        </ul>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* filter                                                              */
/* ------------------------------------------------------------------ */

function Filter(props: ProductBadgesProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const ps = props.products ?? [];
  const kinds = (['new', 'sale', 'eco', 'limited', 'bestseller'] as BadgeKind[]).filter((k) => ps.some((p) => p.badges.some((b) => b.kind === k)));
  const [on, setOn] = useState<BadgeKind[]>([]);
  const shown = ps.filter((p) => on.every((k) => p.badges.some((b) => b.kind === k)));
  const count = (k: BadgeKind) => ps.filter((p) => [...on.filter((x) => x !== k), k].every((x) => p.badges.some((b) => b.kind === x))).length;
  const key = on.join('-') || 'all';
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <Head props={props} />
        <div className="mb-8 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
          <div role="group" aria-label={t.filters} className="kit-no-scrollbar relative -mb-1 flex gap-2 overflow-x-auto pb-1 max-sm:kit-fade-end max-sm:pr-6">
            {kinds.map((k) => {
              const pressed = on.includes(k);
              const n = count(k);
              return (
                <button
                  key={k}
                  type="button"
                  aria-pressed={pressed}
                  disabled={!pressed && n === 0}
                  onClick={() => setOn((xs) => (pressed ? xs.filter((x) => x !== k) : [...xs, k]))}
                  className={cn('flex h-10 shrink-0 items-center gap-2 rounded-full border pl-1.5 pr-3.5 text-[13px] font-medium transition-[border-color,background-color] duration-200 disabled:opacity-40 max-sm:h-11', pressed ? 'border-foreground bg-surface-2' : 'border-border-strong bg-card hover:border-foreground/50')}
                >
                  <ProductBadge kind={k} label={k === 'sale' ? t.sale : undefined} />
                  <span className="tabular-nums text-muted-foreground">{n}</span>
                  {pressed && <Icon name="x" size={13} className="-mr-1 animate-[kitshop-pop_0.3s_var(--ease-kit)] text-foreground" />}
                </button>
              );
            })}
          </div>
          <div className="flex shrink-0 items-center gap-3">
            <p className="text-[13px] tabular-nums text-muted-foreground" aria-live="polite">
              {fill(t.results, { n: formatNumber(locale, shown.length) })}
            </p>
            {on.length > 0 && (
              <button type="button" onClick={() => setOn([])} className="inline-flex h-10 items-center rounded-md px-2 text-[13px] font-medium text-foreground underline decoration-border-strong underline-offset-4 hover:decoration-foreground max-sm:h-11">
                {t.all}
              </button>
            )}
          </div>
        </div>
        {shown.length ? (
          <ul key={key} className="grid grid-cols-2 gap-x-3 gap-y-8 sm:gap-x-5 lg:grid-cols-4 lg:gap-x-6">
            {shown.map((p, i) => (
              <li key={p.id} className="animate-[kitshop-zoom_0.45s_var(--ease-kit)_both]" style={{ animationDelay: `${Math.min(i, 7) * 50}ms` }}>
                <BadgeCard p={p} t={t} currency={currency} index={0} />
              </li>
            ))}
          </ul>
        ) : (
          <p className="rounded-xl border border-dashed border-border-strong p-8 text-center text-[14px] text-muted-foreground">{t.none}</p>
        )}
      </div>
    </section>
  );
}

/**
 * Product badges for new, sale, sustainable, limited, bestseller and the
 * rating: in fixed places on cards, explained on a product page, as drawn
 * stickers on campaign pictures, or as filters above a grid.
 */
export function ProductBadges(props: ProductBadgesProps) {
  const locale = useLocale();
  const t = useTexts(TEXTS, props.labels);
  const currency = currencyOf(locale, props.currency);
  const { variant = 'on-cards' } = props;
  if (variant === 'explained') return <Explained {...props} t={t} currency={currency} />;
  if (variant === 'stickers') return <Stickers {...props} t={t} currency={currency} />;
  if (variant === 'filter') return <Filter {...props} t={t} currency={currency} />;
  return <OnCards {...props} t={t} currency={currency} />;
}

Frequent questions

How do I install Product badges?

With aiw add @ai-website/product-badges. The command puts components/ui/product-badges.tsx into your project, the code is yours from then on. It brings along button, icon and product-image. It needs no further npm packages.

Which variants does Product badges have?

4 variants, On product cards, Explained, Stickers and As filters. You pick one with the variant prop, for example variant="explained".

Can I translate the texts of Product badges?

Yes. Product badges carries 18 texts of its own in English and German, such as “New”, “Sale” and “Sustainable”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Product badges?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Product badges with aiw_components and install it with aiw_add @ai-website/product-badges. Name the variant in your prompt, for example variant="on-cards", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard