Media kit
For creators who work with brands. Reach per channel with growth lines, the audience in charts, sponsorship packages with a price that follows the channels, earlier partners and one campaign in numbers.
One of 2 components in Speakers and media kits
Variants
Details
What Media kit brings along, read from the registry and the source.
- Category
- Speakers and media kits
- Area
- Industries
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/media-kit.tsx
- Brings along
- utilskitbuttoniconmotionnumber-tickersection
- npm packages
- None
- Languages
- English and German, 24 texts of its own
- Studio
- All 5 variants editable
- In collections
- Creator page
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The MediaKitProps interface from components/ui/media-kit.tsx, with the comments from the code.
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
Built-in texts
Media kit writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 24 texts
| Key | English | German |
|---|---|---|
| total | English Total reach | German Reichweite gesamt |
| followers | English Followers | German Follower |
| growth | English {v} in 90 days | German {v} in 90 Tagen |
| download | English Download the kit as PDF | German Media Kit als PDF laden |
| book | English Book a collaboration | German Kooperation anfragen |
| age | English Age | German Alter |
| split | English Audience | German Publikum |
| regions | English Top regions | German Top-Regionen |
| interests | English Interests | German Interessen |
| channels | English Channels | German Kanäle |
| pick | English Pick the channels | German Kanäle wählen |
| total2 | English Package price | German Paketpreis |
| from | English from | German ab |
| request | English Request this package | German Paket anfragen |
| deliverables | English You get | German Du bekommst |
| noChannel | English Pick at least one channel. | German Wähle mindestens einen Kanal. |
| worked | English Worked with | German Zusammengearbeitet mit |
| made | English What we made | German Was wir gemacht haben |
| result | English Result | German Ergebnis |
| brief | English The brief | German Die Aufgabe |
| content | English The content | German Die Inhalte |
| results | English Results | German Ergebnisse |
| before | English before | German vorher |
| trend | English Growth over 12 months | German Wachstum über 12 Monate |
Accessibility
Read from the source of the component.
- Media kit handles the keys Arrow up, Arrow down, Arrow left, Arrow right, Home, End and Space itself.
- Sets aria-controls, aria-label, aria-live and aria-selected, so screen readers announce purpose and state.
- Uses the roles img, list, listitem, radio, tab, tablist and tabpanel.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- 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
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 4 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, 614 lines
'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>
);
}Frequent questions
How do I install Media kit?
With aiw add @ai-website/media-kit. The command puts components/ui/media-kit.tsx into your project, the code is yours from then on. It brings along button, icon, motion, number-ticker and section. It needs no further npm packages.
Which variants does Media kit have?
5 variants, Reach, Audience, Packages, Partners and Campaign. You pick one with the variant prop, for example variant="audience".
Can I translate the texts of Media kit?
Yes. Media kit carries 24 texts of its own in English and German, such as “Total reach”, “Followers” and “Download the kit as PDF”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Media kit?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Media kit with aiw_components and install it with aiw_add @ai-website/media-kit. Name the variant in your prompt, for example variant="reach", and the assistant writes your content straight into the props.
Can I adjust Media kit without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching MediaKit code to copy.