Skip to content
UI Kit
Accent color

Awards section

Awards, prizes and seals with every mark drawn in the kit: laurels that draw themselves, round seals with text on the rim, a medal on a ribbon, awards by year and a compact strip for next to a button.

5 variantsOne of 3 components in Press and awards

Recognition, drawn in your style

An awards section shows that others have already judged your work. Every mark here is drawn in the kit instead of using foreign badges, so laurels, seals and medals take your colors and fonts and never look like pasted images.

Laurels sets a row of wreaths with rank, award and year inside, the branches draw themselves and the leaves appear one by one when the row comes into view. Seals are round seals with a toothed edge, the issuer on the upper rim and the year below, the rim turns a little under the pointer. Trophy case shows the latest prize as a medal on a ribbon with a light passing over it, next to a rating with stars and the other awards by year. By year has a year picker that moves with the arrow keys, the year set huge behind and the awards of that year as cards. Compact strip puts a rating with stars and small seals into one card for under a hero or next to the buy button.

Show the awards that matter to your buyers, a regional prize can beat a famous one for a local business. Always give the year, recognition ages. Place the compact strip close to the decision, under the hero or next to the price.

Variants

LaurelsNew in 1.2

A row of wreaths with rank, award and year inside. The branches draw themselves and the leaves appear one by one when the row comes into view.

SealsNew in 1.2

Round seals with a toothed edge, the issuer on the upper rim and the year below. The rim turns a little under the pointer.

Trophy caseNew in 1.2

The latest prize as a medal on a ribbon with a light passing over it, next to a rating with stars and the other awards listed by year.

By yearNew in 1.2

A year picker that moves with the arrow keys, the year set huge behind and the awards of that year as cards with a ribbon edge.

Compact stripNew in 1.2

A rating with stars and small seals in one card, for under a hero or next to the buy button. Two columns on phones, one row on desktops.

Details

What Awards brings along, read from the registry and the source.

Variants
5
Version
1.0.0 · since 1.2.0
File
components/ui/awards.tsx
Brings along
utilskitbuttoniconsection
npm packages
None
Languages
English and German, 9 texts of its own
Studio
Adjust in code

Props

The AwardsProps interface from components/ui/awards.tsx, with the comments from the code.

  • variantoptional
    AwardsVariant

    laurelssealstrophy-caseby-yearstrip

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • itemsrequired
    AwardItem[]
  • ratingoptional
    { value: number; count: number; source?: string }

    strip, trophy-case: an average rating

  • ctaoptional
    { label: string; href: string }

Built-in texts

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

Show all 9 texts
countEnglish {n} awardsGerman {n} Auszeichnungen
count1English 1 awardGerman 1 Auszeichnung
yearEnglish YearGerman Jahr
allEnglish All yearsGerman Alle Jahre
winnerEnglish WinnerGerman Gewinner
ratingEnglish {v} out of 5German {v} von 5
fromEnglish from {n} reviewsGerman aus {n} Bewertungen
moreEnglish Read about itGerman Mehr dazu
awardsEnglish AwardsGerman Auszeichnungen

Accessibility

Read from the source of the component.

  • Awards handles the keys Arrow left, Arrow right and Space itself.
  • Sets aria-controls, aria-label, aria-labelledby and aria-selected, so screen readers announce purpose and state.
  • Uses the roles tab, tablist and tabpanel.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • 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. 8 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, 497 lines
components/ui/awards.tsx
'use client';

import { useId, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Eyebrow, Section } from '@/components/ui/section';

/*
 * Awards, prizes and seals, every mark drawn here: laurels, round seals with
 * text on the rim, a medal on a ribbon. No logos of juries or magazines, the
 * name of the award stands as text. Five layouts:
 *
 *   laurels      a row of wreaths that draw themselves in, the award inside
 *   seals        round seals with the issuer on the rim, the rim turns on hover
 *   trophy-case  one medal set large, the others listed by year beside it
 *   by-year      a year picker and the awards of that year as ribbons
 *   strip        a compact line of seals and a rating, for next to a button
 */

export type AwardsVariant = 'laurels' | 'seals' | 'trophy-case' | 'by-year' | 'strip';

export interface AwardItem {
  title: string;
  /** Who gave it, written as text */
  issuer?: string;
  year?: string;
  /** Gold, Winner, 1st place */
  rank?: string;
  category?: string;
  description?: string;
  icon?: IconName;
  href?: string;
}

export interface AwardsProps {
  variant?: AwardsVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  items: AwardItem[];
  /** strip, trophy-case: an average rating */
  rating?: { value: number; count: number; source?: string };
  cta?: { label: string; href: string };
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: { count: '{n} awards', count1: '1 award', year: 'Year', all: 'All years', winner: 'Winner', rating: '{v} out of 5', from: 'from {n} reviews', more: 'Read about it', awards: 'Awards' },
  de: { count: '{n} Auszeichnungen', count1: '1 Auszeichnung', year: 'Jahr', all: 'Alle Jahre', winner: 'Gewinner', rating: '{v} von 5', from: 'aus {n} Bewertungen', more: 'Mehr dazu', awards: 'Auszeichnungen' },
};

type Texts = (typeof TEXTS)['en'];
type ViewProps = AwardsProps & { t: Texts };

const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const delay = (ms: number) => ({ '--kit-delay': `${ms}ms` }) as React.CSSProperties;

function Head({ eyebrow, title, description, center, className }: Pick<AwardsProps, 'eyebrow' | 'title' | 'description'> & { center?: boolean; className?: string }) {
  if (!title && !eyebrow) return null;
  return (
    <div className={cn('max-w-[46rem]', center && 'mx-auto max-w-[42rem] text-center', className)}>
      {eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
      {title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>}
      {description && (
        <p className={cn('kit-rise mt-5 max-w-[36rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')} style={delay(80)}>
          {description}
        </p>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Drawn marks                                                         */
/* ------------------------------------------------------------------ */

/** One branch of a laurel: leaves along an arc, mirrored for the other side */
function Branch({ flip, drawn, leaves = 9 }: { flip?: boolean; drawn: boolean; leaves?: number }) {
  // the left half of a wreath: from the bottom (252 deg) up the side to the top (112 deg)
  const cx = 60;
  const cy = 56;
  const r = 44;
  const from = 252;
  const to = 112;
  const at = (deg: number) => {
    const a = (deg * Math.PI) / 180;
    return [cx + r * Math.cos(a), cy - r * Math.sin(a)] as const;
  };
  const stem = Array.from({ length: 25 }, (_, i) => at(from - ((from - to) * i) / 24))
    .map(([x, y], i) => `${i ? 'L' : 'M'}${round(x)} ${round(y)}`)
    .join(' ');
  const items = Array.from({ length: leaves * 2 }, (_, k) => {
    const i = Math.floor(k / 2);
    const out = k % 2 === 0;
    const deg = from - 8 - ((from - to - 14) * i) / (leaves - 1);
    const a = (deg * Math.PI) / 180;
    // direction of travel along the stem (upward), as the rotation of an upright leaf
    const along = (Math.atan2(Math.sin(a), -Math.cos(a)) * 180) / Math.PI;
    const sc = 1 - i * 0.05;
    const off = (out ? 6 : -5) * sc;
    const [px, py] = at(deg);
    return { x: round(px + off * Math.cos(a)), y: round(py - off * Math.sin(a)), rot: round(along + (out ? -38 : 38)), s: round(sc, 3), i };
  });
  return (
    <g transform={flip ? 'translate(120 0) scale(-1 1)' : undefined}>
      <path d={stem} fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" pathLength={1} strokeDasharray="1" strokeDashoffset={drawn ? 0 : 1} className="transition-[stroke-dashoffset] duration-[1200ms] ease-[var(--ease-kit)] motion-reduce:transition-none" />
      {items.map((l, k) => (
        <ellipse
          key={k}
          cx={l.x}
          cy={l.y}
          rx={round(2.6 * l.s)}
          ry={round(6.4 * l.s)}
          transform={`rotate(${l.rot} ${l.x} ${l.y})`}
          fill="currentColor"
          className="transition-opacity duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none"
          style={{ opacity: drawn ? round(0.95 - l.i * 0.04, 2) : 0, transitionDelay: `${200 + l.i * 80}ms` }}
        />
      ))}
    </g>
  );
}

function Laurel({ drawn, className }: { drawn: boolean; className?: string }) {
  return (
    <svg viewBox="0 0 120 110" aria-hidden className={cn('text-[color-mix(in_oklab,var(--primary)_65%,var(--foreground))]', className)}>
      <Branch drawn={drawn} />
      <Branch drawn={drawn} flip />
    </svg>
  );
}

/** A round seal with text on the rim. The rim turns slowly on hover. */
function Seal({ top, bottom, center, icon, size = 168, className }: { top: string; bottom?: string; center?: string; icon?: IconName; size?: number; className?: string }) {
  const id = useId().replace(/:/g, '');
  const teeth = 36;
  const star = Array.from({ length: teeth * 2 }, (_, i) => {
    const a = (i * Math.PI) / teeth;
    const r = i % 2 ? 46.5 : 49;
    return `${round(50 + r * Math.cos(a))},${round(50 + r * Math.sin(a))}`;
  }).join(' ');
  // the rim text runs over the top on an arc of 240 degrees and shrinks to fit long issuer names
  const rimSize = Math.min(6.4, round(145 / (0.85 * Math.max(1, top.length)), 2));
  const long = top.length > 18;
  return (
    <span className={cn('group/seal relative inline-grid place-items-center', className)} style={{ width: size, height: size }}>
      <svg viewBox="0 0 100 100" aria-hidden data-clip-ok className="absolute inset-0 size-full">
        <defs>
          <path id={`${id}-t`} d="M 18.82 68 A 36 36 0 1 1 81.18 68" />
          <path id={`${id}-b`} d="M 50 50 m -39.5 0 a 39.5 39.5 0 0 0 79 0" />
        </defs>
        <polygon points={star} className="fill-[color-mix(in_oklab,var(--primary)_14%,var(--card))] stroke-[color-mix(in_oklab,var(--primary)_40%,var(--border))]" strokeWidth="0.6" />
        <g className="origin-center transition-transform duration-[2400ms] ease-[var(--ease-kit)] group-hover/seal:rotate-[24deg] motion-reduce:transition-none">
          <circle cx="50" cy="50" r="42" fill="none" className="stroke-[color-mix(in_oklab,var(--primary)_55%,var(--foreground))]" strokeWidth="0.5" />
          <circle cx="50" cy="50" r="31" fill="none" className="stroke-[color-mix(in_oklab,var(--primary)_55%,var(--foreground))]" strokeWidth="0.5" />
          <text fontSize={rimSize} fontWeight="600" letterSpacing={round(rimSize * 0.17, 2)} className="fill-foreground uppercase">
            <textPath href={`#${id}-t`} startOffset="50%" textAnchor="middle">
              {top}
            </textPath>
          </text>
          {bottom && (
            <text fontSize="5.6" fontWeight="500" letterSpacing="1" className="fill-muted-foreground uppercase">
              <textPath href={`#${id}-b`} startOffset="50%" textAnchor="middle" dominantBaseline="hanging">
                {bottom}
              </textPath>
            </text>
          )}
          {!long && [0, 180].map((a) => (
            <circle key={a} cx={round(50 + 36.5 * Math.cos(((a + 0) * Math.PI) / 180))} cy="50" r="0.9" className="fill-[color-mix(in_oklab,var(--primary)_60%,var(--foreground))]" />
          ))}
        </g>
      </svg>
      <span className="relative flex max-w-[52%] flex-col items-center text-center">
        {icon && <Icon name={icon} size={Math.round(size / 7.5)} className="text-[color-mix(in_oklab,var(--primary)_70%,var(--foreground))]" />}
        {center && <span className={cn('font-semibold leading-tight tracking-[-0.02em] text-foreground', icon ? 'mt-1' : '')} style={{ fontSize: Math.max(11, Math.round(size / 11)) }}>{center}</span>}
      </span>
    </span>
  );
}

/** A medal on a ribbon in the accent hue, at fixed lightness so the white text reads in both modes and with every accent */
function Medal({ label, sub, className }: { label: string; sub?: string; className?: string }) {
  const id = useId().replace(/:/g, '');
  return (
    <svg viewBox="0 0 200 260" aria-hidden data-clip-ok className={className}>
      <defs>
        <linearGradient id={`${id}-m`} x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stopColor="oklch(from var(--primary) 0.74 c h)" />
          <stop offset="0.5" stopColor="oklch(from var(--primary) 0.6 c h)" />
          <stop offset="1" stopColor="oklch(from var(--primary) 0.42 c h)" />
        </linearGradient>
        <linearGradient id={`${id}-shine`} x1="0" y1="0" x2="1" y2="0">
          <stop offset="0" stopColor="white" stopOpacity="0" />
          <stop offset="0.5" stopColor="white" stopOpacity="0.45" />
          <stop offset="1" stopColor="white" stopOpacity="0" />
        </linearGradient>
        <clipPath id={`${id}-c`}>
          <circle cx="100" cy="170" r="70" />
        </clipPath>
      </defs>
      {/* ribbon */}
      <path d="M62 0h34l20 110H82Z" fill="oklch(from var(--primary) 0.45 c h)" />
      <path d="M104 0h34l-20 110H84Z" fill="oklch(from var(--primary) 0.66 c h)" />
      <path d="M78 96h44v18H78Z" fill="color-mix(in oklab, var(--foreground) 30%, var(--card))" />
      {/* medal */}
      <circle cx="100" cy="170" r="72" fill="color-mix(in oklab, var(--primary) 40%, var(--foreground))" opacity="0.35" />
      <circle cx="100" cy="170" r="70" fill={`url(#${id}-m)`} />
      <circle cx="100" cy="170" r="56" fill="none" stroke="rgb(255 255 255 / 0.5)" strokeWidth="1.2" strokeDasharray="2 3" />
      <g clipPath={`url(#${id}-c)`}>
        <rect x="-60" y="90" width="50" height="170" fill={`url(#${id}-shine)`} transform="skewX(-18)" className="animate-[aw-shine_5s_var(--ease-kit)_1s_infinite] motion-reduce:hidden" />
      </g>
      <text x="100" y={sub ? 172 : 180} textAnchor="middle" fontSize="26" fontWeight="700" fill="white" letterSpacing="-0.5">
        {label}
      </text>
      {sub && (
        <text x="100" y="196" textAnchor="middle" fontSize="11" fontWeight="600" letterSpacing="2" fill="white" opacity="0.85" className="uppercase">
          {sub}
        </text>
      )}
      <style>{'@keyframes aw-shine{0%{transform:skewX(-18deg) translateX(0)}40%,100%{transform:skewX(-18deg) translateX(260px)}}'}</style>
    </svg>
  );
}

function Stars({ value, size = 15 }: { value: number; size?: number }) {
  return (
    <span className="flex gap-0.5" aria-hidden>
      {Array.from({ length: 5 }, (_, i) => {
        const f = Math.max(0, Math.min(1, value - i));
        return (
          <span key={i} className="relative" style={{ width: size, height: size }}>
            <Icon name="star" size={size} className="absolute inset-0 text-[color-mix(in_oklab,var(--foreground)_22%,transparent)]" />
            <span data-clip-ok className="absolute inset-0 overflow-hidden" style={{ width: `${round(f * 100)}%` }}>
              <Icon name="star" size={size} className="fill-current text-[color-mix(in_oklab,var(--warning)_85%,var(--foreground))]" />
            </span>
          </span>
        );
      })}
    </span>
  );
}

/* ------------------------------------------------------------------ */
/* laurels                                                             */
/* ------------------------------------------------------------------ */

function Laurels(p: ViewProps) {
  const [ref, inView] = useInView<HTMLUListElement>();
  const n = p.items.length;
  return (
    <Section className={p.className}>
      <Head {...p} center />
      <ul ref={ref} className={cn('mx-auto grid grid-cols-2 gap-x-4 gap-y-10 sm:gap-x-8', p.title && 'mt-14', n >= 4 ? 'lg:grid-cols-4' : n === 3 ? 'md:grid-cols-3' : '', 'max-w-[68rem]')}>
        {p.items.map((it, i) => {
          const inner = (
            <>
              <span className="relative grid aspect-[12/11] w-full max-w-[14rem] place-items-center">
                <Laurel drawn={inView} className="absolute inset-0 size-full" />
                <span className="relative flex max-w-[50%] flex-col items-center pt-2 text-center">
                  <span className="text-balance text-[13px] font-semibold leading-tight tracking-[-0.01em] text-foreground sm:text-[15px] lg:text-[16px]">{it.title}</span>
                  {it.year && <span className="mt-1.5 text-[12.5px] tabular-nums text-muted-foreground">{it.year}</span>}
                </span>
              </span>
              {it.rank && <span className="mt-2 text-center text-[11px] font-semibold uppercase tracking-[0.14em] text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{it.rank}</span>}
              {it.issuer && <span className="mt-1 text-center text-[13px] text-muted-foreground">{it.issuer}</span>}
            </>
          );
          return (
            <li key={i} className={cn('flex flex-col items-center transition-[opacity,transform] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', inView ? 'opacity-100' : 'translate-y-3 opacity-0')} style={{ transitionDelay: `${i * 110}ms` }}>
              {it.href ? (
                <a href={it.href} className="group/l flex w-full cursor-pointer flex-col items-center rounded-2xl p-2 transition-transform duration-300 ease-[var(--ease-kit)] hover:-translate-y-1 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary motion-reduce:hover:translate-y-0">
                  {inner}
                </a>
              ) : (
                <div className="flex w-full flex-col items-center p-2">{inner}</div>
              )}
            </li>
          );
        })}
      </ul>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* seals                                                               */
/* ------------------------------------------------------------------ */

function Seals(p: ViewProps) {
  return (
    <Section background="dots" className={p.className}>
      <Head {...p} center />
      <ul className={cn('mx-auto grid max-w-[64rem] grid-cols-2 gap-6 sm:gap-10 lg:grid-cols-4', p.title && 'mt-14')}>
        {p.items.map((it, i) => (
          <li key={i} className="kit-rise flex flex-col items-center text-center" style={delay(i * 90)}>
            <Seal top={it.issuer ?? it.title} bottom={it.year} center={it.rank ?? it.title} icon={it.icon ?? 'award'} size={164} className="max-sm:!size-[8.5rem] transition-transform duration-500 ease-[var(--ease-kit)] hover:-rotate-3 motion-reduce:transition-none" />
            <p className="mt-4 text-balance text-[14.5px] font-semibold text-foreground">{it.title}</p>
            {it.category && <p className="mt-1 text-[13px] text-muted-foreground">{it.category}</p>}
          </li>
        ))}
      </ul>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* trophy-case                                                         */
/* ------------------------------------------------------------------ */

function TrophyCase(p: ViewProps) {
  const { t } = p;
  const loc = useLocale() === 'de' ? 'de-DE' : 'en-US';
  const [top, ...rest] = p.items;
  if (!top) return null;
  return (
    <Section className={p.className}>
      <div className="grid grid-cols-[minmax(0,1fr)] items-center gap-12 lg:grid-cols-[minmax(0,26rem)_minmax(0,1fr)] lg:gap-20">
        <div className="kit-rise relative mx-auto w-full max-w-[22rem]">
          <div aria-hidden className="absolute inset-x-6 bottom-0 top-1/3 rounded-full bg-[radial-gradient(closest-side,var(--glow),transparent)] blur-2xl" />
          <Medal label={top.year ?? ''} sub={top.rank ?? t.winner} className="relative mx-auto w-[62%] animate-kit-float drop-shadow-[0_20px_30px_rgba(0,0,0,0.18)] motion-reduce:animate-none" />
          <div className="relative mt-6 text-center">
            <p className="text-balance text-[20px] font-semibold leading-snug text-foreground">{top.title}</p>
            {top.issuer && <p className="mt-1 text-[14px] text-muted-foreground">{top.issuer}</p>}
            {top.description && <p className="mx-auto mt-3 max-w-[22rem] text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{top.description}</p>}
            {top.href && (
              <a href={top.href} className="mt-3 inline-flex min-h-11 cursor-pointer items-center gap-1.5 text-[14px] font-medium text-foreground underline-offset-4 hover:underline">
                {t.more}
                <Icon name="arrow-right" size={15} />
              </a>
            )}
          </div>
        </div>
        <div>
          <Head {...p} />
          {p.rating && (
            <p className="kit-rise mt-6 flex flex-wrap items-center gap-3 text-[14px] text-muted-foreground" style={delay(120)}>
              <Stars value={p.rating.value} />
              <span>
                <strong className="font-semibold text-foreground">{fill(t.rating, { v: new Intl.NumberFormat(loc, { minimumFractionDigits: 1 }).format(p.rating.value) })}</strong> {fill(t.from, { n: new Intl.NumberFormat(loc).format(p.rating.count) })}
              </span>
            </p>
          )}
          <ul className="mt-10 border-t border-border">
            {rest.map((it, i) => (
              <li key={i} className="kit-rise grid grid-cols-[3.5rem_minmax(0,1fr)] items-center gap-4 border-b border-border py-4 sm:grid-cols-[4.5rem_minmax(0,1fr)_auto]" style={delay(160 + i * 60)}>
                <span className="text-[15px] font-semibold tabular-nums text-muted-foreground">{it.year}</span>
                <span className="min-w-0">
                  <span className="block text-pretty text-[15px] font-semibold text-foreground">{it.title}</span>
                  {it.issuer && <span className="block text-[13.5px] text-muted-foreground">{it.issuer}</span>}
                </span>
                {it.rank && <span className="col-start-2 inline-flex h-7 w-fit items-center gap-1.5 rounded-full border border-border px-3 text-[12.5px] font-medium text-foreground sm:col-start-3"><Icon name="award" size={14} className="text-primary" />{it.rank}</span>}
              </li>
            ))}
          </ul>
          {p.cta && (
            <Button href={p.cta.href} variant="outline" icon="arrow-right" className="mt-8 max-sm:w-full">
              {p.cta.label}
            </Button>
          )}
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* by-year                                                             */
/* ------------------------------------------------------------------ */

function ByYear(p: ViewProps) {
  const { t } = p;
  const id = useId();
  const years = [...new Set(p.items.map((i) => i.year ?? ''))].filter(Boolean).sort((a, b) => b.localeCompare(a));
  const [year, setYear] = useState(years[0] ?? '');
  const list = p.items.filter((i) => i.year === year);
  return (
    <Section background="surface" className={p.className}>
      <div className="grid grid-cols-[minmax(0,1fr)] gap-10 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
        <Head {...p} />
        <div
          role="tablist"
          aria-label={t.year}
          className="kit-rise flex gap-1 overflow-x-auto rounded-xl border border-border bg-card p-1 kit-no-scrollbar max-lg:w-fit max-lg:max-w-full"
          onKeyDown={(e) => {
            const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
            if (!d) return;
            e.preventDefault();
            const next = (years.indexOf(year) + d + years.length) % years.length;
            setYear(years[next]);
            (e.currentTarget.querySelectorAll('[role="tab"]')[next] as HTMLElement | undefined)?.focus();
          }}
        >
          {years.map((y) => (
            <button
              key={y}
              type="button"
              role="tab"
              id={`${id}-${y}`}
              aria-selected={year === y}
              aria-controls={`${id}-p`}
              tabIndex={year === y ? 0 : -1}
              onClick={() => setYear(y)}
              className={cn('h-11 shrink-0 cursor-pointer rounded-lg px-4 text-[14px] font-semibold tabular-nums transition-[background-color,color] duration-300 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary sm:h-10', year === y ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-surface hover:text-foreground')}
            >
              {y}
            </button>
          ))}
        </div>
      </div>
      <div id={`${id}-p`} role="tabpanel" aria-labelledby={`${id}-${year}`} className="mt-12 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,14rem)_minmax(0,1fr)] lg:gap-10">
        {/* the chosen year, set large, with the count below */}
        <div key={year} className="kit-rise flex items-baseline gap-4 lg:block">
          <p className="text-[clamp(3.5rem,2.8rem+3vw,6rem)] font-semibold leading-none tracking-[-0.05em] text-foreground tabular-nums">{year}</p>
          <p className="text-[14px] text-muted-foreground lg:mt-3">{fill(list.length === 1 ? t.count1 : t.count, { n: list.length })}</p>
        </div>
        <ul className="relative grid grid-cols-[minmax(0,1fr)] gap-3 md:grid-cols-2">
          {list.map((it, i) => (
            <li key={`${year}-${i}`} className="kit-rise relative flex gap-4 overflow-hidden rounded-2xl border border-border bg-card p-5 pl-6 shadow-[var(--highlight),var(--shadow-sm)]" style={delay(i * 80)}>
              {/* a ribbon tail on the left edge */}
              <span aria-hidden className="absolute inset-y-0 left-0 w-1.5 bg-primary" />
              <span className="grid size-11 shrink-0 place-items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
                <Icon name={it.icon ?? 'trophy'} size={20} />
              </span>
              <div className="min-w-0">
                {it.rank && <p className="text-[12px] font-semibold uppercase tracking-[0.12em] text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{it.rank}</p>}
                <p className="mt-0.5 text-pretty text-[16px] font-semibold leading-snug text-foreground">{it.title}</p>
                <p className="mt-1 text-[13.5px] text-muted-foreground">
                  {[it.issuer, it.category].filter(Boolean).join(' · ')}
                </p>
                {it.description && <p className="mt-2 text-pretty text-[14px] leading-relaxed text-muted-foreground">{it.description}</p>}
              </div>
            </li>
          ))}
        </ul>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* strip                                                               */
/* ------------------------------------------------------------------ */

function Strip(p: ViewProps) {
  const { t } = p;
  const locale = useLocale();
  return (
    <div className={cn('py-10', p.className)}>
      <div className="kit-container">
        <div className="flex flex-col items-center gap-6 rounded-2xl border border-border bg-card px-5 py-6 shadow-[var(--highlight),var(--shadow-sm)] sm:px-8 xl:flex-row xl:justify-between">
          {p.rating && (
            <div className="kit-rise flex shrink-0 items-center gap-4 xl:border-r xl:border-border xl:pr-8">
              <span className="text-[40px] font-semibold leading-none tabular-nums tracking-[-0.04em] text-foreground">{new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { minimumFractionDigits: 1 }).format(p.rating.value)}</span>
              <span>
                <Stars value={p.rating.value} size={16} />
                <span className="mt-1 block text-[13px] text-muted-foreground">
                  {fill(t.from, { n: new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(p.rating.count) })}
                  {p.rating.source && ` · ${p.rating.source}`}
                </span>
                <span className="sr-only">{fill(t.rating, { v: new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { minimumFractionDigits: 1 }).format(p.rating.value) })}</span>
              </span>
            </div>
          )}
          <ul aria-label={t.awards} className="grid w-full grid-cols-2 gap-x-4 gap-y-5 sm:max-w-[40rem] sm:gap-x-8 xl:flex xl:w-auto xl:max-w-none xl:flex-nowrap xl:gap-8">
            {p.items.map((it, i) => (
              <li key={i} className="kit-rise flex items-center gap-3" style={delay(80 + i * 70)}>
                <Seal top={it.issuer ?? ''} center={it.year} size={56} className="shrink-0 [&_text]:hidden" icon={undefined} />
                <span className="min-w-0">
                  <span className="block text-pretty text-[13.5px] font-semibold leading-tight text-foreground">{it.title}</span>
                  {it.issuer && <span className="mt-0.5 block text-[12px] text-muted-foreground">{it.issuer}</span>}
                </span>
              </li>
            ))}
          </ul>
        </div>
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */

const VIEWS: Record<AwardsVariant, (p: ViewProps) => React.ReactNode> = {
  laurels: Laurels,
  seals: Seals,
  'trophy-case': TrophyCase,
  'by-year': ByYear,
  strip: Strip,
};

export function Awards({ variant = 'laurels', labels, ...rest }: AwardsProps) {
  const t = useTexts(TEXTS, labels);
  const View = VIEWS[variant] ?? Laurels;
  return <View {...rest} variant={variant} t={t} />;
}

Frequent questions

How do I install Awards?

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

Which variants does Awards have?

5 variants, Laurels, Seals, Trophy case, By year and Compact strip. You pick one with the variant prop, for example variant="seals".

Can I translate the texts of Awards?

Yes. Awards carries 9 texts of its own in English and German, such as “1 award”, “Year” and “All years”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Awards?

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

Often searched

Jump to

Copied to the clipboard