Zum Inhalt springen
UI Kit
Accent

Media Kit

Für Creator, die mit Marken arbeiten. Reichweite je Kanal mit Wachstumslinien, das Publikum in Diagrammen, Pakete mit einem Preis, der den Kanälen folgt, frühere Partner und eine Kampagne in Zahlen.

Eine von 2 Components in Speaker und Media Kits

5 Variants

Variants

Reichweite

Die Summe zählt hoch, ein Balken zeigt den Anteil jedes Kanals. Kanalkarten mit Followern, Wachstum, einer Kennzahl und einer Linie, die sich selbst zeichnet.

Im Studio öffnen

Publikum

Wer zuschaut und liest. Alter als Balken, die wachsen, die Aufteilung als Ring, der auf die Legende reagiert, Top-Regionen und Interessen.

Im Studio öffnen

Pakete

Pakete mit dem, was jeder Kanal liefert. Einen Kanal abwählen und der Preis gleitet, ab zwei Kanälen gilt ein Paketpreis.

Im Studio öffnen

Partner

Frühere Partner als Logowand. Einen wählen, und daneben steht, was entstanden ist, das Ergebnis und ein Zitat, eine Linie markiert das gewählte Logo.

Im Studio öffnen

Kampagne

Eine Kampagne als kurze Geschichte. Die Aufgabe, die Inhalte als Bilder, auf dem Telefon zum Wischen, die Ergebnisse als Zahlen, die hochzählen, mit dem Wert davor.

Im Studio öffnen

Details

Was Media Kit mitbringt, aus Registry und Quellcode gelesen.

Bereich
Industries
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/media-kit.tsx
Bringt mit
utilskitbuttoniconmotionnumber-tickersection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 24 eigene Texte
In Sammlungen
Creator-Seite

Props

Die Schnittstelle MediaKitProps aus components/ui/media-kit.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    MediaKitVariant

    reachaudiencepackagespartnerscampaign

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • channelsoptional
    KitChannel[]
  • agesoptional
    { label: string; share: number }[]

    audience

  • splitoptional
    { label: string; share: number }[]
  • regionsoptional
    { label: string; share: number }[]
  • interestsoptional
    string[]
  • packagesoptional
    KitPackage[]
  • partnersoptional
    KitPartner[]
  • campaignoptional
    { brand: string; logo?: React.ReactNode; brief: string; content: { label: string; image?: React.ReactNode }[]; results: { label: string; before?: number; after: number; suffix?: string }[]; quote?: { text: string; name: string; role?: string }; }

    campaign

  • contactHrefoptional
    string

    Contact for bookings

  • pdfHrefoptional
    string
  • currencyoptional
    string

Eingebaute Texte

Diese Texte schreibt Media Kit selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 24 Texte anzeigen
totalEnglisch Total reachDeutsch Reichweite gesamt
followersEnglisch FollowersDeutsch Follower
growthEnglisch {v} in 90 daysDeutsch {v} in 90 Tagen
downloadEnglisch Download the kit as PDFDeutsch Media Kit als PDF laden
bookEnglisch Book a collaborationDeutsch Kooperation anfragen
ageEnglisch AgeDeutsch Alter
splitEnglisch AudienceDeutsch Publikum
regionsEnglisch Top regionsDeutsch Top-Regionen
interestsEnglisch InterestsDeutsch Interessen
channelsEnglisch ChannelsDeutsch Kanäle
pickEnglisch Pick the channelsDeutsch Kanäle wählen
total2Englisch Package priceDeutsch Paketpreis
fromEnglisch fromDeutsch ab
requestEnglisch Request this packageDeutsch Paket anfragen
deliverablesEnglisch You getDeutsch Du bekommst
noChannelEnglisch Pick at least one channel.Deutsch Wähle mindestens einen Kanal.
workedEnglisch Worked withDeutsch Zusammengearbeitet mit
madeEnglisch What we madeDeutsch Was wir gemacht haben
resultEnglisch ResultDeutsch Ergebnis
briefEnglisch The briefDeutsch Die Aufgabe
contentEnglisch The contentDeutsch Die Inhalte
resultsEnglisch ResultsDeutsch Ergebnisse
beforeEnglisch beforeDeutsch vorher
trendEnglisch Growth over 12 monthsDeutsch Wachstum über 12 Monate

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Media Kit reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende und Leertaste.
  • Setzt aria-controls, aria-label, aria-live und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen img, list, listitem, radio, tab, tablist und tabpanel.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 4 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 614 Zeilen
components/ui/media-kit.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Stagger, useScrollFade, Magnetic } from '@/components/ui/motion';
import { NumberTicker } from '@/components/ui/number-ticker';
import { Section, SectionHeader } from '@/components/ui/section';

/*
 * A media kit for creators who work with brands: reach per channel, who the
 * audience is, sponsorship packages, earlier partners and one campaign in
 * numbers. Channels are named by what they are (newsletter, video channel,
 * podcast), never by a platform.
 *
 *   reach      channels with followers, growth and engagement, a total that counts up, a growth line that draws
 *   audience   age as bars, a split as a ring, top regions and interests
 *   packages   sponsorship packages, pick channels and the price follows
 *   partners   earlier partners as a wall, one opens with what was made and how it went
 *   campaign   one campaign as a story, brief, content and results with a before and after
 */

export type MediaKitVariant = 'reach' | 'audience' | 'packages' | 'partners' | 'campaign';

export interface KitChannel {
  id: string;
  label: string;
  icon: IconName;
  followers: number;
  /** Growth in percent over the last 90 days */
  growth?: number;
  /** e.g. "Open rate" with "54 %" */
  metric?: { label: string; value: string };
  /** Monthly followers for the growth line, oldest first */
  trend?: number[];
}

export interface KitPackage {
  id: string;
  title: string;
  /** What is delivered per channel id */
  deliverables: Record<string, string>;
  /** Price per channel id */
  prices: Record<string, number>;
  note?: string;
  featured?: boolean;
}

export interface KitPartner {
  id: string;
  name: string;
  logo?: React.ReactNode;
  /** e.g. "Newsletter series, 3 issues" */
  work: string;
  result: string;
  quote?: string;
  year?: number;
}

export interface MediaKitProps {
  variant?: MediaKitVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  channels?: KitChannel[];
  /** audience */
  ages?: { label: string; share: number }[];
  split?: { label: string; share: number }[];
  regions?: { label: string; share: number }[];
  interests?: string[];
  packages?: KitPackage[];
  partners?: KitPartner[];
  /** campaign */
  campaign?: {
    brand: string;
    logo?: React.ReactNode;
    brief: string;
    content: { label: string; image?: React.ReactNode }[];
    results: { label: string; before?: number; after: number; suffix?: string }[];
    quote?: { text: string; name: string; role?: string };
  };
  /** Contact for bookings */
  contactHref?: string;
  pdfHref?: string;
  currency?: string;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    total: 'Total reach',
    followers: 'Followers',
    growth: '{v} in 90 days',
    download: 'Download the kit as PDF',
    book: 'Book a collaboration',
    age: 'Age',
    split: 'Audience',
    regions: 'Top regions',
    interests: 'Interests',
    channels: 'Channels',
    pick: 'Pick the channels',
    total2: 'Package price',
    from: 'from',
    request: 'Request this package',
    deliverables: 'You get',
    noChannel: 'Pick at least one channel.',
    worked: 'Worked with',
    made: 'What we made',
    result: 'Result',
    brief: 'The brief',
    content: 'The content',
    results: 'Results',
    before: 'before',
    trend: 'Growth over 12 months',
  },
  de: {
    total: 'Reichweite gesamt',
    followers: 'Follower',
    growth: '{v} in 90 Tagen',
    download: 'Media Kit als PDF laden',
    book: 'Kooperation anfragen',
    age: 'Alter',
    split: 'Publikum',
    regions: 'Top-Regionen',
    interests: 'Interessen',
    channels: 'Kanäle',
    pick: 'Kanäle wählen',
    total2: 'Paketpreis',
    from: 'ab',
    request: 'Paket anfragen',
    deliverables: 'Du bekommst',
    noChannel: 'Wähle mindestens einen Kanal.',
    worked: 'Zusammengearbeitet mit',
    made: 'Was wir gemacht haben',
    result: 'Ergebnis',
    brief: 'Die Aufgabe',
    content: 'Die Inhalte',
    results: 'Ergebnisse',
    before: 'vorher',
    trend: 'Wachstum über 12 Monate',
  },
};
type Texts = typeof TEXTS.en;

/* ------------------------------------------------------------------ */
/* Helpers                                                             */
/* ------------------------------------------------------------------ */

const TAG: Record<Locale, string> = { en: 'en-US', de: 'de-DE' };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const focusRing = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background';
const fit = '[&>*]:size-full [&_img]:object-cover';
function money(value: number, currency: string, locale: Locale) {
  return new Intl.NumberFormat(TAG[locale], { style: 'currency', currency, maximumFractionDigits: 0 }).format(value);
}
const compact = (n: number, locale: Locale) => new Intl.NumberFormat(TAG[locale], { notation: 'compact', maximumFractionDigits: 1 }).format(n);
const pctFmt = (n: number, locale: Locale, sign = false) => `${sign && n > 0 ? '+' : ''}${new Intl.NumberFormat(TAG[locale], { maximumFractionDigits: 1 }).format(n)}${locale === 'de' ? ' %' : '%'}`;


/** Arrow keys, Home and End move the choice inside a radio group or a tab list (APG) */
function arrowKeys(e: React.KeyboardEvent<HTMLElement>) {
  const keys = ['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', 'Home', 'End'];
  if (!keys.includes(e.key)) return;
  const items = [...e.currentTarget.querySelectorAll<HTMLButtonElement>('[role="radio"]:not(:disabled), [role="tab"]:not(:disabled)')];
  const i = items.indexOf(document.activeElement as HTMLButtonElement);
  if (i < 0) return;
  e.preventDefault();
  const step = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : -1;
  const n = e.key === 'Home' ? 0 : e.key === 'End' ? items.length - 1 : (i + step + items.length) % items.length;
  items[n].focus();
  items[n].click();
}

const MOTION_CSS = `
@keyframes kitmk-in{from{opacity:0;transform:translateY(10px)}}
@keyframes kitmk-swap{from{opacity:0;filter:blur(4px);transform:translateY(6px)}}
@keyframes kitmk-pop{0%{transform:scale(.5);opacity:0}60%{transform:scale(1.12);opacity:1}100%{transform:scale(1)}}
@keyframes kitmk-draw{from{stroke-dashoffset:var(--len)}}
@media (prefers-reduced-motion:reduce){[class*="kitmk-"],.kitmk-anim{animation:none!important;transition:none!important}}`;

function MkMotion() {
  return (
    <style href="kitmk-motion" precedence="default">
      {MOTION_CSS}
    </style>
  );
}

function useTween(value: number, ms = 450) {
  const [shown, setShown] = useState(value);
  const from = useRef(value);
  useEffect(() => {
    const start = from.current;
    if (start === value) return;
    if (prefersReducedMotion()) {
      from.current = value;
      setShown(value);
      return;
    }
    let raf = 0;
    const t0 = performance.now();
    const tick = (now: number) => {
      const k = Math.min(1, (now - t0) / ms);
      const v = k >= 1 ? value : start + (value - start) * (1 - Math.pow(1 - k, 3));
      from.current = v;
      setShown(v);
      if (k < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [value, ms]);
  return shown;
}

/** A small line that draws itself when it scrolls in */
function Trend({ values, className, label }: { values: number[]; className?: string; label?: string }) {
  const [ref, inView] = useInView<SVGSVGElement>();
  const min = Math.min(...values);
  const max = Math.max(...values);
  const W = 120;
  const H = 36;
  const pts = values.map((v, i) => [round((i / Math.max(1, values.length - 1)) * W, 2), round(H - 3 - ((v - min) / Math.max(1, max - min)) * (H - 6), 2)]);
  const d = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x} ${y}`).join(' ');
  return (
    <svg ref={ref} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className={cn('h-10 w-full overflow-visible transition-[clip-path] duration-[1200ms] ease-[var(--ease-kit)] motion-reduce:transition-none', className)} style={{ clipPath: inView ? 'inset(-4px -4px -4px -4px)' : 'inset(-4px 100% -4px -4px)' }} role="img" aria-label={label}>
      <path d={`${d} L${W} ${H} L0 ${H}Z`} fill="color-mix(in oklab, var(--primary) 14%, transparent)" />
      <path d={d} fill="none" stroke="var(--primary)" strokeWidth="2" vectorEffect="non-scaling-stroke" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

/* ------------------------------------------------------------------ */
/* reach                                                               */
/* ------------------------------------------------------------------ */

function Reach({ channels = [], pdfHref, contactHref, t }: MediaKitProps & { t: Texts }) {
  const locale = useLocale();
  const total = channels.reduce((a, c) => a + c.followers, 0);
  return (
    <div className="grid gap-6">
      <div className="kit-card flex flex-col gap-6 p-6 sm:p-8 md:flex-row md:items-end md:justify-between">
        <div>
          <p className="text-[13px] font-medium text-muted-foreground">{t.total}</p>
          <p className="mt-2 text-[56px] font-semibold leading-none tracking-[-0.04em] text-foreground sm:text-[72px]">
            <NumberTicker value={total} />
          </p>
          {/* the share of each channel as one bar */}
          <div className="mt-5 flex h-2.5 w-full max-w-[460px] gap-0.5 overflow-hidden rounded-full" aria-hidden>
            {channels.map((c, i) => (
              <span key={c.id} className="h-full origin-left rounded-full [animation:kitmk-in_.6s_var(--ease-kit)_both]" style={{ width: `${round((c.followers / Math.max(1, total)) * 100, 2)}%`, background: `color-mix(in oklab, var(--primary) ${round(95 - i * (60 / Math.max(1, channels.length - 1)))}%, var(--muted))`, animationDelay: `${200 + i * 120}ms` }} />
            ))}
          </div>
        </div>
        <div className="flex flex-wrap gap-3 max-sm:flex-col">
          {contactHref && (
            <Magnetic className="max-sm:flex max-sm:w-full">
              <Button href={contactHref} icon="arrow-right" className="max-sm:w-full">
                {t.book}
              </Button>
            </Magnetic>
          )}
          {pdfHref && (
            <Button href={pdfHref} variant="outline" iconLeft="download" className="max-sm:w-full">
              {t.download}
            </Button>
          )}
        </div>
      </div>
      <Stagger as="ul" step={80} className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
        {channels.map((c, i) => (
          <li key={c.id} className="kit-card flex flex-col p-5 transition-transform duration-300 hover:-translate-y-0.5">
            <div className="flex items-center gap-3">
              <span className="flex size-10 items-center justify-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-primary">
                <Icon name={c.icon} size={19} />
              </span>
              <span className="text-[15px] font-semibold text-foreground">{c.label}</span>
              <span aria-hidden className="ml-auto size-2.5 rounded-full" style={{ background: `color-mix(in oklab, var(--primary) ${round(95 - i * (60 / Math.max(1, channels.length - 1)))}%, var(--muted))` }} />
            </div>
            <p className="mt-5 text-[32px] font-semibold leading-none tracking-[-0.03em] tabular-nums text-foreground">{compact(c.followers, locale)}</p>
            <p className="mt-1.5 flex flex-wrap items-center gap-x-2 text-[13px] text-muted-foreground">
              {t.followers}
              {c.growth !== undefined && <span className="font-medium text-success-foreground">{fill(t.growth, { v: pctFmt(c.growth, locale, true) })}</span>}
            </p>
            {c.trend && <Trend values={c.trend} className="mt-4" label={t.trend} />}
            {c.metric && (
              <p className="mt-auto flex items-baseline justify-between gap-3 border-t border-border pt-3 text-[13px] max-sm:mt-4 sm:mt-4">
                <span className="text-muted-foreground">{c.metric.label}</span>
                <span className="font-semibold tabular-nums text-foreground">{c.metric.value}</span>
              </p>
            )}
          </li>
        ))}
      </Stagger>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* audience                                                            */
/* ------------------------------------------------------------------ */

function Audience({ ages = [], split = [], regions = [], interests = [], t }: MediaKitProps & { t: Texts }) {
  const locale = useLocale();
  const [ref, inView] = useInView<HTMLDivElement>();
  const [hover, setHover] = useState<number | null>(null);
  const maxAge = Math.max(1, ...ages.map((a) => a.share));
  const R = 52;
  const C = 2 * Math.PI * R;
  let acc = 0;
  const arcs = split.map((s, i) => {
    const len = (s.share / 100) * C;
    const a = { len: round(len, 2), off: round(-acc, 2), i };
    acc += len;
    return a;
  });
  const shown = hover ?? 0;
  return (
    <div ref={ref} className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1fr)]">
      <div className="kit-card p-6 md:col-span-2 lg:col-span-1">
        <h3 className="text-[15px] font-semibold text-foreground">{t.age}</h3>
        <div className="mt-5 flex items-end gap-3" role="list">
          {ages.map((a, i) => (
            <div key={a.label} role="listitem" className="flex flex-1 flex-col items-center gap-2">
              <span className="flex h-[220px] w-full flex-col items-center justify-end gap-2">
              <span className="text-[12.5px] font-semibold tabular-nums text-foreground">{pctFmt(a.share, locale)}</span>
              <span className="w-full shrink-0 origin-bottom rounded-t-md transition-transform duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ height: `${round((a.share / maxAge) * 180, 1)}px`, transform: `scaleY(${inView ? 1 : 0})`, transitionDelay: inView ? `${i * 90}ms` : '0ms', background: a.share === maxAge ? 'var(--primary)' : 'color-mix(in oklab, var(--primary) 35%, var(--muted))' }} />
              </span>
              <span className="text-[12px] tabular-nums text-muted-foreground">{a.label}</span>
            </div>
          ))}
        </div>
      </div>
      <div className="kit-card p-6">
        <h3 className="text-[15px] font-semibold text-foreground">{t.split}</h3>
        <div className="mt-4 flex items-center gap-5">
          <div className="relative size-[132px] shrink-0">
            <svg viewBox="0 0 132 132" className="size-full -rotate-90" aria-hidden>
              <circle cx="66" cy="66" r={R} fill="none" stroke="var(--track)" strokeWidth="14" />
              {arcs.map((a) => (
                <circle key={a.i} cx="66" cy="66" r={R} fill="none" stroke={`color-mix(in oklab, var(--primary) ${round(95 - a.i * 40)}%, color-mix(in oklab, var(--foreground) 22%, var(--card)))`} strokeWidth={hover === a.i ? 18 : 14} strokeDasharray={`${inView ? Math.max(0, a.len - 2) : 0} ${round(C, 2)}`} strokeDashoffset={a.off} className="transition-[stroke-dasharray,stroke-width] duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none" />
              ))}
            </svg>
            <span className="absolute inset-0 flex flex-col items-center justify-center">
              <span key={shown} className="text-[22px] font-semibold tabular-nums text-foreground [animation:kitmk-swap_.3s_var(--ease-kit)]">
                {pctFmt(split[shown]?.share ?? 0, locale)}
              </span>
              <span className="max-w-[80px] truncate text-[11.5px] text-muted-foreground">{split[shown]?.label}</span>
            </span>
          </div>
          <ul className="grid gap-1">
            {split.map((s, i) => (
              <li key={s.label}>
                <button type="button" onPointerEnter={() => setHover(i)} onPointerLeave={() => setHover(null)} onFocus={() => setHover(i)} onBlur={() => setHover(null)} className={cn('flex min-h-11 sm:min-h-9 items-center gap-2 rounded-md text-[13.5px] text-foreground', focusRing)}>
                  <span className="size-2.5 rounded-full" style={{ background: `color-mix(in oklab, var(--primary) ${round(95 - i * 40)}%, color-mix(in oklab, var(--foreground) 22%, var(--card)))` }} />
                  {s.label}
                  <span className="tabular-nums text-muted-foreground">{pctFmt(s.share, locale)}</span>
                </button>
              </li>
            ))}
          </ul>
        </div>
        {interests.length > 0 && (
          <>
            <h3 className="mt-6 text-[15px] font-semibold text-foreground">{t.interests}</h3>
            <ul className="mt-3 flex flex-wrap gap-1.5">
              {interests.map((x) => (
                <li key={x} className="rounded-full border border-border px-2.5 py-1 text-[12.5px] text-foreground">
                  {x}
                </li>
              ))}
            </ul>
          </>
        )}
      </div>
      <div className="kit-card p-6">
        <h3 className="text-[15px] font-semibold text-foreground">{t.regions}</h3>
        <ul className="mt-4 grid gap-3">
          {regions.map((r, i) => (
            <li key={r.label}>
              <div className="flex justify-between text-[13.5px]">
                <span className="text-foreground">{r.label}</span>
                <span className="tabular-nums text-muted-foreground">{pctFmt(r.share, locale)}</span>
              </div>
              <div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]" aria-hidden>
                <div className="h-full origin-left rounded-full bg-primary transition-transform duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: `${r.share}%`, transform: `scaleX(${inView ? 1 : 0})`, transitionDelay: inView ? `${200 + i * 90}ms` : '0ms' }} />
              </div>
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* packages                                                            */
/* ------------------------------------------------------------------ */

function PackageCard({ p, channels, currency, t, i }: { p: KitPackage; channels: KitChannel[]; currency: string; t: Texts; i: number }) {
  const locale = useLocale();
  const avail = channels.filter((c) => p.deliverables[c.id]);
  const [picked, setPicked] = useState<string[]>(avail.map((c) => c.id));
  const total = picked.reduce((a, id) => a + (p.prices[id] ?? 0), 0);
  // Two channels or more: 10 percent off
  const price = picked.length > 1 ? Math.round(total * 0.9) : total;
  const v = useTween(price);
  return (
    <article className={cn('flex h-full flex-col rounded-2xl border bg-card p-6 transition-transform duration-300 [animation:kitmk-in_.5s_var(--ease-kit)_both] hover:-translate-y-0.5 sm:p-7', p.featured ? 'border-primary shadow-[var(--highlight),var(--shadow-lg)]' : 'border-border shadow-[var(--highlight),var(--shadow-sm)]')} style={{ animationDelay: `${i * 90}ms` }}>
      <h3 className="text-[20px] font-semibold tracking-[-0.01em] text-foreground">{p.title}</h3>
      {p.note && <p className="mt-1.5 text-[14px] text-muted-foreground">{p.note}</p>}
      <fieldset className="mt-5">
        <legend className="mb-2.5 text-[13px] font-medium text-muted-foreground">{t.pick}</legend>
        <ul className="grid gap-2">
          {avail.map((c) => {
            const on = picked.includes(c.id);
            return (
              <li key={c.id}>
                <label className={cn('flex min-h-12 cursor-pointer items-center gap-3 rounded-lg border px-3 transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring', on ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))]' : 'border-border hover:border-border-strong')}>
                  <input type="checkbox" checked={on} onChange={() => setPicked((v) => (on ? v.filter((x) => x !== c.id) : [...v, c.id]))} className="sr-only" />
                  <span aria-hidden className={cn('flex size-5 shrink-0 items-center justify-center rounded-md border-2 transition-colors', on ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong')}>{on && <Icon name="check" size={12} strokeWidth={2.6} className="[animation:kitmk-pop_.3s_var(--ease-kit)]" />}</span>
                  <Icon name={c.icon} size={16} className="text-muted-foreground" />
                  <span className="min-w-0 flex-1">
                    <span className="block text-[13.5px] font-medium text-foreground">{c.label}</span>
                    <span className="block text-[12.5px] text-muted-foreground">{p.deliverables[c.id]}</span>
                  </span>
                </label>
              </li>
            );
          })}
        </ul>
      </fieldset>
      <div className="mt-auto pt-6">
        <div className="flex items-end justify-between gap-3 border-t border-border pt-5">
          <span className="text-[13px] text-muted-foreground">{t.total2}</span>
          <span className="text-[28px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground" aria-live="polite">
            {picked.length ? money(Math.round(v), currency, locale) : '·'}
          </span>
        </div>
        {!picked.length && <p className="mt-2 text-right text-[12.5px] text-destructive">{t.noChannel}</p>}
        <Button href="#" variant={p.featured ? 'primary' : 'outline'} icon="arrow-right" className="mt-4 w-full">
          {t.request}
        </Button>
      </div>
    </article>
  );
}

function Packages({ packages = [], channels = [], currency = 'EUR', t }: MediaKitProps & { t: Texts }) {
  return (
    <ul className={cn('grid grid-cols-1 gap-5 md:grid-cols-2', packages.length >= 3 && 'lg:grid-cols-3')}>
      {packages.map((p, i) => (
        <li key={p.id} className={cn(packages.length === 3 && i === 2 && 'md:col-span-2 lg:col-span-1')}>
          <PackageCard p={p} channels={channels} currency={currency} t={t} i={i} />
        </li>
      ))}
    </ul>
  );
}

/* ------------------------------------------------------------------ */
/* partners                                                            */
/* ------------------------------------------------------------------ */

function Partners({ partners = [], t }: MediaKitProps & { t: Texts }) {
  const [sel, setSel] = useState(partners[0]?.id ?? '');
  const p = partners.find((x) => x.id === sel);
  const base = useId();
  return (
    <div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-10">
      <div>
        <p className="mb-3 text-[13px] font-medium text-muted-foreground">{t.worked}</p>
        <div role="tablist" aria-label={t.worked} onKeyDown={arrowKeys} className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-3">
          {partners.map((x, i) => {
            const on = x.id === sel;
            return (
              <button key={x.id} id={`${base}-t${i}`} type="button" role="tab" aria-selected={on} tabIndex={on ? 0 : -1} aria-controls={`${base}-p`} onClick={() => setSel(x.id)} className={cn('relative flex h-24 items-center justify-center bg-card px-4 transition-colors', focusRing, 'focus-visible:ring-inset focus-visible:ring-offset-0', on ? 'bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))] text-foreground' : 'text-muted-foreground hover:text-foreground')}>
                <span className={cn('flex h-7 items-center transition-[filter,opacity] [&_svg]:h-6', !on && 'opacity-70 grayscale')}>{x.logo ?? <span className="text-[15px] font-semibold">{x.name}</span>}</span>
                {x.logo && <span className="sr-only">{x.name}</span>}
                <span aria-hidden className={cn('absolute inset-x-4 bottom-0 h-0.5 origin-center bg-primary transition-transform duration-500 ease-[var(--ease-kit)]', on ? 'scale-x-100' : 'scale-x-0')} />
              </button>
            );
          })}
          {partners.length % 3 !== 0 && Array.from({ length: 3 - (partners.length % 3) }, (_, i) => <span key={i} aria-hidden className="hidden bg-card sm:block" />)}
        </div>
      </div>
      {p && (
        <div key={p.id} id={`${base}-p`} role="tabpanel" className="kit-card flex flex-col p-6 [animation:kitmk-swap_.45s_var(--ease-kit)] sm:p-8">
          <div className="flex items-center justify-between gap-3">
            <p className="text-[18px] font-semibold text-foreground">{p.name}</p>
            {p.year && <span className="text-[13px] tabular-nums text-muted-foreground">{p.year}</span>}
          </div>
          <dl className="mt-5 grid gap-4">
            <div>
              <dt className="text-[13px] font-medium text-muted-foreground">{t.made}</dt>
              <dd className="mt-1 text-[15px] text-foreground">{p.work}</dd>
            </div>
            <div>
              <dt className="text-[13px] font-medium text-muted-foreground">{t.result}</dt>
              <dd className="mt-1 flex items-start gap-2 text-[15px] font-medium text-foreground">
                <Icon name="trending-up" size={17} className="mt-0.5 text-success" />
                {p.result}
              </dd>
            </div>
          </dl>
          {p.quote && <blockquote className="mt-6 border-l-2 border-primary pl-4 text-pretty text-[15px] italic leading-relaxed text-foreground">{p.quote}</blockquote>}
        </div>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* campaign                                                            */
/* ------------------------------------------------------------------ */

function Campaign({ campaign, t }: MediaKitProps & { t: Texts }) {
  const locale = useLocale();
  const [ref, inView] = useInView<HTMLDListElement>();
  const [row, fade] = useScrollFade<HTMLUListElement>();
  if (!campaign) return null;
  return (
    <div className="grid gap-6">
      <div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
        <div className="kit-card p-6 sm:p-8">
          {campaign.logo ? <div className="flex h-8 items-center text-foreground [&_svg]:h-7">{campaign.logo}</div> : <p className="text-[18px] font-semibold text-foreground">{campaign.brand}</p>}
          <p className="mt-6 text-[13px] font-medium text-muted-foreground">{t.brief}</p>
          <p className="mt-1.5 text-pretty text-[16px] leading-relaxed text-foreground">{campaign.brief}</p>
          {campaign.quote && (
            <figure className="mt-6 border-t border-border pt-5">
              <blockquote className="text-pretty text-[15px] italic leading-relaxed text-foreground">{campaign.quote.text}</blockquote>
              <figcaption className="mt-2 text-[13px] text-muted-foreground">
                {campaign.quote.name}
                {campaign.quote.role && `, ${campaign.quote.role}`}
              </figcaption>
            </figure>
          )}
        </div>
        <div>
          <p className="mb-3 text-[13px] font-medium text-muted-foreground">{t.content}</p>
          <ul ref={row} className={cn('kit-no-scrollbar -mx-6 flex snap-x scroll-px-6 gap-3 overflow-x-auto px-6 sm:mx-0 sm:grid sm:grid-cols-3 sm:overflow-visible sm:px-0', fade)}>
            {campaign.content.map((c, i) => (
              <li key={c.label} className="w-[64%] shrink-0 snap-start sm:w-auto [animation:kitmk-in_.5s_var(--ease-kit)_both]" style={{ animationDelay: `${i * 90}ms` }}>
                <div className="group relative aspect-[4/5] overflow-hidden rounded-xl bg-muted" data-clip-ok>
                  <div className={cn('absolute inset-0 transition-transform duration-700 ease-[var(--ease-kit)] group-hover:scale-[1.04]', fit)}>{c.image}</div>
                </div>
                <p className="mt-2 text-[13.5px] font-medium text-foreground">{c.label}</p>
              </li>
            ))}
          </ul>
        </div>
      </div>
      <dl ref={ref} className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border lg:grid-cols-4">
        {campaign.results.map((r, i) => (
          <div key={r.label} className="flex flex-col-reverse justify-end bg-card p-5 sm:p-6">
            <dt className="mt-2 text-[13.5px] leading-snug text-muted-foreground">{r.label}</dt>
            <dd>
              <span className="text-[30px] font-semibold leading-none tracking-[-0.03em] text-foreground sm:text-[36px]">
                <NumberTicker value={r.after} delay={i * 120} />
                {r.suffix}
              </span>
              {r.before !== undefined && (
                <span className={cn('mt-2 flex items-center gap-1.5 text-[12.5px] tabular-nums text-muted-foreground transition-opacity duration-700', inView ? 'opacity-100' : 'opacity-0')} style={{ transitionDelay: `${800 + i * 120}ms` }}>
                  <Icon name="arrow-up-right" size={13} className="text-success" />
                  {new Intl.NumberFormat(TAG[locale]).format(r.before)}
                  {r.suffix} {t.before}
                </span>
              )}
            </dd>
          </div>
        ))}
      </dl>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* MediaKit                                                            */
/* ------------------------------------------------------------------ */

/**
 * A media kit for creators who work with brands.
 *   reach     channels with followers, growth and a drawn line, a total that counts
 *   audience  age, split, regions and interests
 *   packages  sponsorship packages, pick channels and the price follows
 *   partners  earlier partners, one opens with work and result
 *   campaign  one campaign with brief, content and results
 */
export function MediaKit(props: MediaKitProps) {
  const { variant = 'reach', eyebrow, title, description, labels, className } = props;
  const t = useTexts(TEXTS, labels);
  const body = (() => {
    switch (variant) {
      case 'audience':
        return <Audience {...props} t={t} />;
      case 'packages':
        return <Packages {...props} t={t} />;
      case 'partners':
        return <Partners {...props} t={t} />;
      case 'campaign':
        return <Campaign {...props} t={t} />;
      default:
        return <Reach {...props} t={t} />;
    }
  })();
  return (
    <Section className={className} background={variant === 'reach' ? 'glow' : variant === 'packages' ? 'surface' : 'default'}>
      <MkMotion />
      {title ? <SectionHeader eyebrow={eyebrow} title={title} description={description} /> : null}
      {body}
    </Section>
  );
}

Häufige Fragen

Wie installiere ich Media Kit?

Mit aiw add @ai-website/media-kit. Der Befehl legt die Datei components/ui/media-kit.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, motion, number-ticker und section. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Media Kit?

5 Varianten, Reichweite, Publikum, Pakete, Partner und Kampagne. Du wählst sie mit der Prop variant, zum Beispiel variant="audience".

Kann ich die Texte von Media Kit übersetzen?

Ja. Media Kit bringt 24 eigene Texte auf Deutsch und Englisch mit, etwa „Reichweite gesamt“, „Follower“ und „Media Kit als PDF laden“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Media Kit einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Media Kit mit aiw_components und installieren sie mit aiw_add @ai-website/media-kit. Nenn im Prompt die Variante, etwa variant="reach", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Media Kit ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden MediaKit-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert