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
Variants
Details
Was Media Kit mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Speaker und Media Kits
- 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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle MediaKitProps aus components/ui/media-kit.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalMediaKitVariantreachaudiencepackagespartnerscampaign
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodechannelsoptionalKitChannel[]agesoptional{ label: string; share: number }[]audience
splitoptional{ label: string; share: number }[]regionsoptional{ label: string; share: number }[]interestsoptionalstring[]packagesoptionalKitPackage[]partnersoptionalKitPartner[]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
contactHrefoptionalstringContact for bookings
pdfHrefoptionalstringcurrencyoptionalstring
Eingebaute Texte
Diese Texte schreibt Media Kit selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 24 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| total | Englisch Total reach | Deutsch Reichweite gesamt |
| followers | Englisch Followers | Deutsch Follower |
| growth | Englisch {v} in 90 days | Deutsch {v} in 90 Tagen |
| download | Englisch Download the kit as PDF | Deutsch Media Kit als PDF laden |
| book | Englisch Book a collaboration | Deutsch Kooperation anfragen |
| age | Englisch Age | Deutsch Alter |
| split | Englisch Audience | Deutsch Publikum |
| regions | Englisch Top regions | Deutsch Top-Regionen |
| interests | Englisch Interests | Deutsch Interessen |
| channels | Englisch Channels | Deutsch Kanäle |
| pick | Englisch Pick the channels | Deutsch Kanäle wählen |
| total2 | Englisch Package price | Deutsch Paketpreis |
| from | Englisch from | Deutsch ab |
| request | Englisch Request this package | Deutsch Paket anfragen |
| deliverables | Englisch You get | Deutsch Du bekommst |
| noChannel | Englisch Pick at least one channel. | Deutsch Wähle mindestens einen Kanal. |
| worked | Englisch Worked with | Deutsch Zusammengearbeitet mit |
| made | Englisch What we made | Deutsch Was wir gemacht haben |
| result | Englisch Result | Deutsch Ergebnis |
| brief | Englisch The brief | Deutsch Die Aufgabe |
| content | Englisch The content | Deutsch Die Inhalte |
| results | Englisch Results | Deutsch Ergebnisse |
| before | Englisch before | Deutsch vorher |
| trend | Englisch Growth over 12 months | Deutsch 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
'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.