Skip to content
UI Kit
Accent color

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

5 variants

Variants

Reach

The total that counts up with a bar of each channel’s share. Channel cards with followers, growth, one key number and a line that draws itself.

Open in Studio

Audience

Who watches and reads. Age as bars that grow, the split as a ring that reacts to the legend, top regions and interests.

Open in Studio

Packages

Sponsorship packages with what each channel delivers. Untick a channel and the price glides, two or more channels get a bundle price.

Open in Studio

Partners

Earlier partners as a wall of logos. Pick one and the panel beside it shows what was made, the result and a quote, a line marks the chosen logo.

Open in Studio

Campaign

One campaign as a short story. The brief, the content as pictures that swipe on phones, the results as numbers that count up with the value before.

Open in Studio

Details

What Media kit brings along, read from the registry and the source.

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
In collections
Creator page

Props

The MediaKitProps interface from components/ui/media-kit.tsx, with the comments from the code.

  • 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

Built-in texts

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

Show all 24 texts
totalEnglish Total reachGerman Reichweite gesamt
followersEnglish FollowersGerman Follower
growthEnglish {v} in 90 daysGerman {v} in 90 Tagen
downloadEnglish Download the kit as PDFGerman Media Kit als PDF laden
bookEnglish Book a collaborationGerman Kooperation anfragen
ageEnglish AgeGerman Alter
splitEnglish AudienceGerman Publikum
regionsEnglish Top regionsGerman Top-Regionen
interestsEnglish InterestsGerman Interessen
channelsEnglish ChannelsGerman Kanäle
pickEnglish Pick the channelsGerman Kanäle wählen
total2English Package priceGerman Paketpreis
fromEnglish fromGerman ab
requestEnglish Request this packageGerman Paket anfragen
deliverablesEnglish You getGerman Du bekommst
noChannelEnglish Pick at least one channel.German Wähle mindestens einen Kanal.
workedEnglish Worked withGerman Zusammengearbeitet mit
madeEnglish What we madeGerman Was wir gemacht haben
resultEnglish ResultGerman Ergebnis
briefEnglish The briefGerman Die Aufgabe
contentEnglish The contentGerman Die Inhalte
resultsEnglish ResultsGerman Ergebnisse
beforeEnglish beforeGerman vorher
trendEnglish Growth over 12 monthsGerman 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
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>
  );
}

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.

Often searched

Jump to

Copied to the clipboard