Musician press kit
An electronic press kit for bands and musicians. Overview with numbers and press photos, releases with tracks that play, tour dates with ticket state, press quotes and downloads, and the rider with a stage plot.
One of 3 components in Artists and musicians
Variants
Details
What Musician press kit brings along, read from the registry and the source.
- Category
- Artists and musicians
- Area
- Industries
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/musician-epk.tsx
- Brings along
- utilskitbuttoniconmotionnumber-tickersection
- npm packages
- None
- Languages
- English and German, 25 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The MusicianEpkProps interface from components/ui/musician-epk.tsx, with the comments from the code.
variantoptionalMusicianEpkVariantoverviewreleasestourpressrider
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeartistoptional{ name: string; genre: string; from?: string; line?: string; bio?: string; photo?: React.ReactNode }statsoptional{ value: number; suffix?: string; label: string }[]photosoptional{ label: string; size?: string; href: string; image?: React.ReactNode }[]contactsoptional{ role: string; name: string; email: string }[]releasesoptionalEpkRelease[]showsoptionalEpkShow[]quotesoptionalEpkQuote[]downloadsoptional{ label: string; detail?: string; href: string; icon?: IconName }[]inputsoptionalEpkInput[]stageoptionalEpkStageItem[]needsoptionalstring[]riderHrefoptionalstring
Built-in texts
Musician press kit writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 25 texts
| Key | English | German |
|---|---|---|
| photos | English Press photos | German Pressefotos |
| download | English Download | German Herunterladen |
| contact | English Contact | German Kontakt |
| tracks | English Tracks | German Titel |
| play | English Play preview of {t} | German Hörprobe von {t} abspielen |
| pause | English Pause {t} | German {t} anhalten |
| listen | English Listen | German Anhören |
| released | English Released {d} | German Erschienen am {d} |
| all | English All | German Alle |
| countries | English Countries | German Länder |
| tickets | English Tickets | German Tickets |
| few | English Few left | German Nur noch wenige |
| soldOut | English Sold out | German Ausverkauft |
| remind | English Remind me | German Erinnere mich |
| reminded | English Reminder set | German Erinnerung gesetzt |
| press | English Downloads for press | German Downloads für die Presse |
| plot | English Stage plot | German Bühnenplan |
| inputs | English Input list | German Kanalliste |
| ch | English Ch | German Kanal |
| source | English Source | German Quelle |
| mic | English Mic | German Mikrofon |
| needs | English We need | German Wir brauchen |
| rider | English Download the rider | German Rider herunterladen |
| front | English Audience | German Publikum |
| min | English {n} min | German {n} Min. |
Accessibility
Read from the source of the component.
- Musician press kit handles the keys Arrow up, Arrow down, Arrow left, Arrow right, Home, End and Space itself.
- Sets aria-checked, aria-label and aria-pressed, so screen readers announce purpose and state.
- Uses the roles img, radio, radiogroup and tab.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 3 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, 672 lines
'use client';
import { useEffect, 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 { Parallax, Stagger, useScrollFade } from '@/components/ui/motion';
import { NumberTicker } from '@/components/ui/number-ticker';
import { Section, SectionHeader } from '@/components/ui/section';
/*
* An electronic press kit for musicians and bands: who they are, what
* they released, where they play, what the press says and what a venue
* needs on stage.
*
* overview name, genre, a line about the sound, numbers that count, press photos and contacts
* releases covers to pick from, the chosen release with its tracks and a preview that plays
* tour dates by country with ticket state, sold out ones crossed, a reminder for the others
* press quotes with the outlet, the outlets as a row and the downloads for press
* rider the stage plot drawn from positions, the input list and what the band needs
*/
export type MusicianEpkVariant = 'overview' | 'releases' | 'tour' | 'press' | 'rider';
export interface EpkTrack {
title: string;
/** m:ss */
length: string;
}
export interface EpkRelease {
id: string;
title: string;
/** e.g. "Album", "EP", "Single" */
kind: string;
/** YYYY-MM-DD */
date: string;
label?: string;
cover?: React.ReactNode;
tracks?: EpkTrack[];
href?: string;
}
export interface EpkShow {
id: string;
/** YYYY-MM-DD */
date: string;
city: string;
country: string;
venue: string;
state: 'tickets' | 'few' | 'sold-out';
href?: string;
}
export interface EpkQuote {
quote: string;
outlet: string;
logo?: React.ReactNode;
}
export interface EpkInput {
ch: number;
source: string;
mic: string;
note?: string;
}
export interface EpkStageItem {
label: string;
/** Position on the stage, 0 to 100 from left and from the back */
x: number;
y: number;
kind: 'vocal' | 'guitar' | 'bass' | 'drums' | 'keys' | 'monitor' | 'amp';
}
export interface MusicianEpkProps {
variant?: MusicianEpkVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
artist?: { name: string; genre: string; from?: string; line?: string; bio?: string; photo?: React.ReactNode };
stats?: { value: number; suffix?: string; label: string }[];
photos?: { label: string; size?: string; href: string; image?: React.ReactNode }[];
contacts?: { role: string; name: string; email: string }[];
releases?: EpkRelease[];
shows?: EpkShow[];
quotes?: EpkQuote[];
downloads?: { label: string; detail?: string; href: string; icon?: IconName }[];
inputs?: EpkInput[];
stage?: EpkStageItem[];
needs?: string[];
riderHref?: string;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
photos: 'Press photos',
download: 'Download',
contact: 'Contact',
tracks: 'Tracks',
play: 'Play preview of {t}',
pause: 'Pause {t}',
listen: 'Listen',
released: 'Released {d}',
all: 'All',
countries: 'Countries',
tickets: 'Tickets',
few: 'Few left',
soldOut: 'Sold out',
remind: 'Remind me',
reminded: 'Reminder set',
press: 'Downloads for press',
plot: 'Stage plot',
inputs: 'Input list',
ch: 'Ch',
source: 'Source',
mic: 'Mic',
needs: 'We need',
rider: 'Download the rider',
front: 'Audience',
min: '{n} min',
},
de: {
photos: 'Pressefotos',
download: 'Herunterladen',
contact: 'Kontakt',
tracks: 'Titel',
play: 'Hörprobe von {t} abspielen',
pause: '{t} anhalten',
listen: 'Anhören',
released: 'Erschienen am {d}',
all: 'Alle',
countries: 'Länder',
tickets: 'Tickets',
few: 'Nur noch wenige',
soldOut: 'Ausverkauft',
remind: 'Erinnere mich',
reminded: 'Erinnerung gesetzt',
press: 'Downloads für die Presse',
plot: 'Bühnenplan',
inputs: 'Kanalliste',
ch: 'Kanal',
source: 'Quelle',
mic: 'Mikrofon',
needs: 'Wir brauchen',
rider: 'Rider herunterladen',
front: 'Publikum',
min: '{n} Min.',
},
};
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';
const dfmt = (iso: string, locale: Locale, o: Intl.DateTimeFormatOptions) => {
const [y, m, d] = iso.split('-').map(Number);
return new Intl.DateTimeFormat(TAG[locale], { timeZone: 'UTC', ...o }).format(new Date(Date.UTC(y, m - 1, d)));
};
const quoted = (text: string, locale: Locale) => (locale === 'de' ? `„${text}“` : `“${text}”`);
const secs = (mmss: string) => {
const [m, s] = mmss.split(':').map(Number);
return m * 60 + (s || 0);
};
/** 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 kitepk-in{from{opacity:0;transform:translateY(8px)}}
@keyframes kitepk-swap{from{opacity:0;filter:blur(4px);transform:translateY(6px)}}
@keyframes kitepk-pop{0%{transform:scale(.5);opacity:0}60%{transform:scale(1.12);opacity:1}100%{transform:scale(1)}}
@keyframes kitepk-bar{0%,100%{transform:scaleY(.3)}50%{transform:scaleY(1)}}
@keyframes kitepk-spin{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion:reduce){[class*="kitepk-"],.kitepk-anim{animation:none!important;transition:none!important}}`;
function EpkMotion() {
return (
<style href="kitepk-motion" precedence="default">
{MOTION_CSS}
</style>
);
}
function Segmented({ options, value, onChange, label, className }: { options: { id: string; label: React.ReactNode }[]; value: string; onChange: (id: string) => void; label: string; className?: string }) {
const wrap = useRef<HTMLDivElement>(null);
const [box, setBox] = useState<{ x: number; w: number } | null>(null);
useEffect(() => {
const el = wrap.current;
if (!el) return;
const measure = () => {
const b = el.querySelector<HTMLElement>('[aria-checked="true"]');
if (b) setBox({ x: b.offsetLeft, w: b.offsetWidth });
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [value]);
const key = (e: React.KeyboardEvent) => {
const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
if (!d) return;
e.preventDefault();
const i = options.findIndex((o) => o.id === value);
const n = options[(i + d + options.length) % options.length];
onChange(n.id);
wrap.current?.querySelector<HTMLElement>(`[data-v="${n.id}"]`)?.focus();
};
return (
<div ref={wrap} role="radiogroup" aria-label={label} onKeyDown={key} className={cn('relative inline-flex max-w-full rounded-xl border border-border bg-muted p-1', className)}>
{box && <span aria-hidden className="absolute bottom-1 left-0 top-1 rounded-lg bg-card shadow-[var(--highlight),var(--shadow-sm)] ring-1 ring-border-strong transition-[transform,width] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: box.w, transform: `translateX(${box.x}px)` }} />}
{options.map((o) => {
const on = o.id === value;
return (
<button key={o.id} type="button" role="radio" data-v={o.id} aria-checked={on} tabIndex={on ? 0 : -1} onClick={() => onChange(o.id)} className={cn('relative z-[1] inline-flex min-h-10 shrink-0 items-center justify-center whitespace-nowrap rounded-lg px-4 text-[14px] font-medium transition-colors max-sm:min-h-11', focusRing, on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', on && !box && 'bg-card shadow-[var(--shadow-sm)]')}>
{o.label}
</button>
);
})}
</div>
);
}
/* ------------------------------------------------------------------ */
/* overview */
/* ------------------------------------------------------------------ */
function Overview({ artist, stats = [], photos = [], contacts = [], t }: MusicianEpkProps & { t: Texts }) {
if (!artist) return null;
return (
<div className="grid gap-6">
<div className="relative overflow-hidden rounded-2xl border border-border bg-bezel" data-clip-ok>
<div className="relative sm:aspect-[16/9] lg:aspect-[21/9]">
{/* On the phone the photo stands above the name, so the stage stays visible */}
<div className="relative aspect-[4/3] overflow-hidden sm:absolute sm:inset-0 sm:aspect-auto">
<Parallax speed={70} className={cn('absolute -inset-y-10 inset-x-0', fit)}>
{artist.photo}
</Parallax>
<div className="absolute inset-0 bg-[linear-gradient(to_top,rgb(0_0_0),rgb(0_0_0/0.1)_45%,transparent)] sm:bg-[linear-gradient(to_top,rgb(0_0_0/0.82),rgb(0_0_0/0.15)_60%,transparent)]" />
</div>
<div className="relative bg-black px-6 pb-7 pt-1 text-white sm:absolute sm:inset-x-0 sm:bottom-0 sm:bg-transparent sm:p-10">
<p className="kit-rise text-[14px] font-medium text-white/75">
{artist.genre}
{artist.from && ` · ${artist.from}`}
</p>
<h2 className="kit-rise mt-2 text-[44px] font-semibold uppercase leading-[0.95] tracking-[-0.03em] [--kit-delay:80ms] sm:text-[72px] lg:text-[96px]">{artist.name}</h2>
{artist.line && <p className="kit-rise mt-4 max-w-[34rem] text-pretty text-[16px] text-white/85 [--kit-delay:160ms] sm:text-[18px]">{artist.line}</p>}
</div>
</div>
</div>
{stats.length > 0 && (
<dl className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border lg:grid-cols-4">
{stats.slice(0, 4).map((s, i) => (
<div key={s.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">{s.label}</dt>
<dd className="text-[30px] font-semibold leading-none tracking-[-0.03em] text-foreground sm:text-[36px]">
<NumberTicker value={s.value} delay={i * 120} />
{s.suffix}
</dd>
</div>
))}
</dl>
)}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
{photos.length > 0 && (
<div className="kit-card p-5 sm:p-6">
<h3 className="text-[16px] font-semibold text-foreground">{t.photos}</h3>
<Stagger as="ul" step={70} className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
{photos.map((p, i) => (
<li key={p.label} className={cn(i === 0 && photos.length % 2 === 1 && 'max-sm:col-span-2 max-sm:[&_span.aspect-square]:aspect-[16/9]')}>
<a href={p.href} download className={cn('group block rounded-lg', focusRing)} aria-label={`${t.download}: ${p.label}`}>
<span className="relative block aspect-square overflow-hidden rounded-lg bg-muted" data-clip-ok>
<span className={cn('absolute inset-0 block grayscale transition-[transform,filter] duration-700 ease-[var(--ease-kit)] group-hover:scale-105 group-hover:grayscale-0', fit)}>{p.image}</span>
<span className="absolute bottom-2 right-2 flex size-9 items-center justify-center rounded-full bg-background/90 text-foreground backdrop-blur transition-transform duration-300 group-hover:-translate-y-0.5">
<Icon name="download" size={16} />
</span>
</span>
<span className="mt-2 block text-[13.5px] font-medium text-foreground">{p.label}</span>
{p.size && <span className="block text-[12.5px] text-muted-foreground">{p.size}</span>}
</a>
</li>
))}
</Stagger>
</div>
)}
<div className="kit-card flex flex-col p-5 sm:p-6">
{artist.bio && <p className="text-pretty text-[15px] leading-relaxed text-foreground/85">{artist.bio}</p>}
{contacts.length > 0 && (
<>
<h3 className={cn('text-[16px] font-semibold text-foreground', artist.bio && 'mt-6 border-t border-border pt-5')}>{t.contact}</h3>
<ul className="mt-3 grid gap-3">
{contacts.map((c) => (
<li key={c.role} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
<span className="min-w-0">
<span className="block text-[12.5px] text-muted-foreground">{c.role}</span>
<span className="block truncate text-[14.5px] font-medium text-foreground">{c.name}</span>
</span>
<a href={`mailto:${c.email}`} aria-label={`${c.role}, ${c.email}`} className={cn('flex size-11 items-center justify-center rounded-full border border-border text-foreground transition-colors hover:bg-accent', focusRing)}>
<Icon name="mail" size={17} />
</a>
</li>
))}
</ul>
</>
)}
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* releases */
/* ------------------------------------------------------------------ */
function Releases({ releases = [], t }: MusicianEpkProps & { t: Texts }) {
const locale = useLocale();
const [sel, setSel] = useState(releases[0]?.id ?? '');
const [playing, setPlaying] = useState<number | null>(null);
const [pos, setPos] = useState(0);
const r = releases.find((x) => x.id === sel) ?? releases[0];
const [row, fade] = useScrollFade<HTMLDivElement>();
useEffect(() => {
if (playing === null) return;
if (prefersReducedMotion()) return;
const id = window.setInterval(() => setPos((p) => p + 0.25), 250);
return () => window.clearInterval(id);
}, [playing]);
// A preview is 30 seconds long
useEffect(() => {
if (pos < 30) return;
setPlaying(null);
setPos(0);
}, [pos]);
const total = (r?.tracks ?? []).reduce((a, x) => a + secs(x.length), 0);
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)] lg:gap-14">
<div>
{r && (
<div key={r.id} className="relative mx-auto aspect-square w-full max-w-[460px] [animation:kitepk-swap_.5s_var(--ease-kit)]">
{/* a record slides out of the sleeve while a track plays */}
<span aria-hidden className={cn('absolute inset-[4%] rounded-full bg-[repeating-radial-gradient(circle,#18181b_0_3px,#232327_3px_5px)] shadow-[var(--shadow-lg),inset_0_0_0_1px_rgb(255_255_255/0.14)] transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', playing !== null ? 'translate-x-[32%]' : 'translate-x-0')}>
<span className={cn('absolute inset-[38%] rounded-full bg-primary', playing !== null && 'kitepk-anim [animation:kitepk-spin_2.4s_linear_infinite]')}>
<span className="absolute left-1/2 top-[18%] size-[12%] -translate-x-1/2 rounded-full bg-primary-foreground/70" />
</span>
</span>
<div className={cn('relative size-full overflow-hidden rounded-md shadow-[0_30px_60px_-24px_rgb(0_0_0/0.55)]', fit)} data-clip-ok>
{r.cover}
</div>
</div>
)}
<div ref={row} className={cn('kit-no-scrollbar -mx-6 mt-6 overflow-x-auto px-6 sm:mx-0 sm:px-0', fade)}>
<div role="radiogroup" aria-label={t.tracks} onKeyDown={arrowKeys} className="flex gap-3 sm:justify-center">
{releases.map((x) => {
const on = x.id === r?.id;
return (
<button key={x.id} type="button" role="radio" aria-checked={on} tabIndex={on ? 0 : -1} aria-label={`${x.title}, ${x.kind}`} onClick={() => { setSel(x.id); setPlaying(null); setPos(0); }} className={cn('size-16 shrink-0 overflow-hidden rounded-md border-2 transition-[border-color,transform,opacity] duration-300 sm:size-[72px]', focusRing, on ? 'border-primary' : 'border-transparent opacity-70 hover:-translate-y-0.5 hover:opacity-100', fit)}>
{x.cover}
</button>
);
})}
</div>
</div>
</div>
{r && (
<div key={r.id} className="[animation:kitepk-swap_.5s_var(--ease-kit)]">
<p className="text-[13px] font-medium text-muted-foreground">
{r.kind}
{r.label && ` · ${r.label}`}
</p>
<h3 className="mt-1.5 text-[32px] font-semibold leading-tight tracking-[-0.02em] text-foreground">{r.title}</h3>
<p className="mt-1 text-[14px] text-muted-foreground">
{fill(t.released, { d: dfmt(r.date, locale, { day: 'numeric', month: 'long', year: 'numeric' }) })}
{total > 0 && ` · ${fill(t.min, { n: Math.round(total / 60) })}`}
</p>
<ol className="mt-6 border-t border-border">
{(r.tracks ?? []).map((tr, i) => {
const on = playing === i;
return (
<li key={tr.title} className={cn('relative border-b border-border transition-colors', on && 'bg-[color-mix(in_oklab,var(--primary)_6%,transparent)]')}>
<button type="button" aria-pressed={on} aria-label={on ? fill(t.pause, { t: tr.title }) : fill(t.play, { t: tr.title })} onClick={() => { setPlaying(on ? null : i); setPos(0); }} className={cn('group grid min-h-[52px] w-full grid-cols-[2rem_minmax(0,1fr)_auto] items-center gap-3 px-2 text-left', focusRing, 'focus-visible:ring-inset focus-visible:ring-offset-0')}>
<span className="flex size-8 items-center justify-center text-[13.5px] tabular-nums text-muted-foreground">
{on ? (
<span className="flex h-3.5 items-end gap-[2px]" aria-hidden>
{[0, 1, 2].map((b) => (
<span key={b} className="kitepk-anim h-full w-[3px] origin-bottom rounded-full bg-primary [animation:kitepk-bar_.9s_ease-in-out_infinite]" style={{ animationDelay: `${b * 0.15}s` }} />
))}
</span>
) : (
<>
<span className="group-hover:hidden">{i + 1}</span>
<Icon name="play" size={15} className="hidden text-foreground group-hover:block" />
</>
)}
</span>
<span className={cn('truncate text-[15px]', on ? 'font-semibold text-foreground' : 'text-foreground/90')}>{tr.title}</span>
<span className="text-[13.5px] tabular-nums text-muted-foreground">{tr.length}</span>
</button>
{on && <span aria-hidden className="absolute bottom-[-1px] left-0 h-0.5 bg-primary transition-[width] duration-300 ease-linear" style={{ width: `${round((pos / 30) * 100, 2)}%` }} />}
</li>
);
})}
</ol>
<Button href={r.href ?? '#'} variant="outline" iconLeft="headphones" className="mt-6 max-sm:w-full">
{t.listen}
</Button>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* tour */
/* ------------------------------------------------------------------ */
function Tour({ shows = [], t }: MusicianEpkProps & { t: Texts }) {
const locale = useLocale();
const countries = [...new Set(shows.map((s) => s.country))];
const [c, setC] = useState('all');
const [remind, setRemind] = useState<string[]>([]);
const shown = shows.filter((s) => c === 'all' || s.country === c).sort((a, b) => (a.date < b.date ? -1 : 1));
const [row, fade] = useScrollFade<HTMLDivElement>();
return (
<div>
<div ref={row} className={cn('kit-no-scrollbar -mx-6 overflow-x-auto px-6 sm:mx-0 sm:px-0', fade)}>
<Segmented label={t.countries} value={c} onChange={setC} options={[{ id: 'all', label: t.all }, ...countries.map((x) => ({ id: x, label: x }))]} />
</div>
<ol key={c} className="mt-8 border-t border-border">
{shown.map((s, i) => {
const sold = s.state === 'sold-out';
const on = remind.includes(s.id);
return (
<li key={s.id} className="group grid grid-cols-[64px_minmax(0,1fr)] items-center gap-x-4 gap-y-3 border-b border-border py-4 [animation:kitepk-in_.45s_var(--ease-kit)_both] sm:grid-cols-[80px_minmax(0,1fr)_minmax(0,1fr)_15rem] sm:gap-x-6" style={{ animationDelay: `${i * 45}ms` }}>
<span className="flex flex-col leading-none">
<span className="text-[12px] font-semibold uppercase tracking-[0.06em] text-primary">{dfmt(s.date, locale, { month: 'short' }).replace('.', '')}</span>
<span className="mt-1 text-[28px] font-semibold tabular-nums tracking-[-0.02em] text-foreground">{dfmt(s.date, locale, { day: 'numeric' }).replace('.', '')}</span>
</span>
<span className="min-w-0">
<span className={cn('block text-[17px] font-semibold text-foreground transition-transform duration-300 group-hover:translate-x-1', sold && 'text-muted-foreground line-through decoration-1')}>{s.city}</span>
<span className="block text-[13.5px] text-muted-foreground">{dfmt(s.date, locale, { weekday: 'long' })}</span>
</span>
<span className="col-start-2 text-[14.5px] text-muted-foreground sm:col-start-auto">{s.venue}</span>
<span className="col-start-2 flex items-center gap-2 sm:col-start-auto sm:justify-end">
{sold ? (
<>
<span className="text-[12.5px] font-medium text-muted-foreground">{t.soldOut}</span>
<button type="button" aria-pressed={on} onClick={() => setRemind((r) => (on ? r.filter((x) => x !== s.id) : [...r, s.id]))} className={cn('inline-flex min-h-10 items-center gap-1.5 rounded-lg border px-3 text-[13px] font-medium transition-colors max-sm:min-h-11', focusRing, on ? 'border-transparent bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-foreground' : 'border-border text-muted-foreground hover:text-foreground')}>
<span key={String(on)} className={cn('inline-flex', on && '[animation:kitepk-pop_.4s_var(--ease-kit)]')}>
<Icon name={on ? 'check' : 'bell'} size={14} />
</span>
{on ? t.reminded : t.remind}
</button>
</>
) : (
<>
{s.state === 'few' && <span className="text-[12.5px] font-medium text-warning-foreground">{t.few}</span>}
<Button href={s.href ?? '#'} size="sm" variant={s.state === 'few' ? 'primary' : 'outline'} icon="arrow-up-right">
{t.tickets}
</Button>
</>
)}
</span>
</li>
);
})}
</ol>
</div>
);
}
/* ------------------------------------------------------------------ */
/* press */
/* ------------------------------------------------------------------ */
function Press({ quotes = [], downloads = [], t }: MusicianEpkProps & { t: Texts }) {
const locale = useLocale();
const [lead, ...rest] = quotes;
return (
<div className="grid gap-10">
{lead && (
<figure className="mx-auto max-w-[52rem] text-center">
<blockquote className="kit-rise text-balance text-[26px] font-medium leading-[1.3] tracking-[-0.015em] text-foreground sm:text-[36px]">{quoted(lead.quote, locale)}</blockquote>
<figcaption className="kit-rise mt-5 flex items-center justify-center gap-3 text-[14px] text-muted-foreground [--kit-delay:120ms]">
{lead.logo ? <span className="flex h-6 items-center text-foreground [&_svg]:h-5">{lead.logo}</span> : lead.outlet}
{lead.logo && <span className="sr-only">{lead.outlet}</span>}
</figcaption>
</figure>
)}
{rest.length > 0 && (
<Stagger as="ul" step={80} className="grid grid-cols-1 gap-4 md:grid-cols-3">
{rest.map((q) => (
<li key={q.outlet} className="flex flex-col justify-between rounded-xl border border-border bg-card p-5 transition-[transform,border-color] duration-300 hover:-translate-y-0.5 hover:border-border-strong">
<p className="text-pretty text-[15.5px] leading-relaxed text-foreground">{quoted(q.quote, locale)}</p>
<p className="mt-4 flex h-6 items-center text-[13.5px] font-medium text-muted-foreground [&_svg]:h-[18px]">
{q.logo ?? q.outlet}
{q.logo && <span className="sr-only">{q.outlet}</span>}
</p>
</li>
))}
</Stagger>
)}
{downloads.length > 0 && (
<div>
<h3 className="mb-3 text-[13px] font-medium text-muted-foreground">{t.press}</h3>
<ul className="grid grid-cols-1 gap-px overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-2 lg:grid-cols-4">
{downloads.map((d) => (
<li key={d.label} className="bg-card">
<a href={d.href} download className={cn('group flex h-full min-h-[72px] items-center gap-3 p-4 transition-colors hover:bg-accent', focusRing, 'focus-visible:ring-inset focus-visible:ring-offset-0')}>
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
<Icon name={d.icon ?? 'download'} size={18} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[14.5px] font-medium text-foreground">{d.label}</span>
{d.detail && <span className="block text-[12.5px] text-muted-foreground">{d.detail}</span>}
</span>
<Icon name="download" size={16} className="text-muted-foreground transition-transform duration-300 group-hover:translate-y-0.5" />
</a>
</li>
))}
</ul>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* rider */
/* ------------------------------------------------------------------ */
const STAGE_ICON: Record<EpkStageItem['kind'], IconName> = { vocal: 'mic', guitar: 'music', bass: 'music', drums: 'circle', keys: 'keyboard', monitor: 'volume', amp: 'server' };
function Rider({ inputs = [], stage = [], needs = [], riderHref, t }: MusicianEpkProps & { t: Texts }) {
const [ref, inView] = useInView<HTMLDivElement>();
const [hl, setHl] = useState<string | null>(null);
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-8">
<div className="kit-card p-5 sm:p-6">
<div className="flex items-center justify-between gap-3">
<h3 className="text-[16px] font-semibold text-foreground">{t.plot}</h3>
{riderHref && (
<Button href={riderHref} size="sm" variant="outline" iconLeft="download">
{t.rider}
</Button>
)}
</div>
<div ref={ref} className="relative mt-5 aspect-square overflow-hidden sm:aspect-[4/3] rounded-xl border border-border bg-[repeating-linear-gradient(90deg,color-mix(in_oklab,var(--foreground)_4%,transparent)_0_1px,transparent_1px_40px),repeating-linear-gradient(0deg,color-mix(in_oklab,var(--foreground)_4%,transparent)_0_1px,transparent_1px_40px)]" role="img" aria-label={`${t.plot}: ${stage.map((s) => s.label).join(', ')}`}>
{stage.map((s, i) => {
const on = hl === s.label;
return (
<span key={s.label} onPointerEnter={() => setHl(s.label)} onPointerLeave={() => setHl(null)} className={cn('absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-1 transition-[opacity,transform] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', on && 'z-[1]')} style={{ left: `${s.x}%`, top: `${s.y}%`, opacity: inView ? 1 : 0, transform: `translate(-50%, -50%) scale(${inView ? (on ? 1.12 : 1) : 0.6})`, transitionDelay: inView && !hl ? `${i * 70}ms` : '0ms' }}>
<span className={cn('flex items-center justify-center rounded-full border-2 transition-colors', s.kind === 'drums' ? 'size-14' : s.kind === 'monitor' ? 'h-6 w-10 rounded-md' : 'size-10', on ? 'border-primary bg-primary text-primary-foreground' : s.kind === 'monitor' ? 'border-border-strong bg-muted text-muted-foreground' : 'border-primary bg-card text-primary')}>
<Icon name={STAGE_ICON[s.kind]} size={s.kind === 'monitor' ? 13 : 17} />
</span>
{s.kind !== 'monitor' && <span className="whitespace-nowrap rounded bg-background/85 px-1.5 text-[11.5px] font-medium text-foreground">{s.label}</span>}
</span>
);
})}
<span className="absolute inset-x-0 bottom-0 flex h-8 items-center justify-center border-t border-dashed border-border-strong bg-[color-mix(in_oklab,var(--primary)_6%,transparent)] text-[11.5px] font-medium uppercase tracking-[0.12em] text-muted-foreground">{t.front}</span>
</div>
{needs.length > 0 && (
<>
<h3 className="mt-6 text-[16px] font-semibold text-foreground">{t.needs}</h3>
<ul className="mt-3 grid gap-2 sm:grid-cols-2">
{needs.map((n) => (
<li key={n} className="flex gap-2.5 text-[14px] text-foreground">
<Icon name="check" size={16} className="mt-0.5 text-primary" />
{n}
</li>
))}
</ul>
</>
)}
</div>
{inputs.length > 0 && (
<div className="kit-card overflow-hidden">
<h3 className="px-5 pt-5 text-[16px] font-semibold text-foreground sm:px-6 sm:pt-6">{t.inputs}</h3>
<div className="mt-4 overflow-x-auto kit-no-scrollbar">
<table className="w-full min-w-[340px] border-collapse text-left text-[14px]">
<thead>
<tr className="border-y border-border bg-surface text-[12.5px] text-muted-foreground">
<th scope="col" className="py-2.5 pl-5 pr-3 font-medium sm:pl-6">
{t.ch}
</th>
<th scope="col" className="px-3 py-2.5 font-medium">
{t.source}
</th>
<th scope="col" className="py-2.5 pl-3 pr-5 font-medium sm:pr-6">
{t.mic}
</th>
</tr>
</thead>
<tbody>
{inputs.map((x) => {
const on = hl !== null && x.source.toLowerCase().includes(hl.toLowerCase().split(' ')[0]);
return (
<tr key={x.ch} className={cn('border-b border-border transition-colors last:border-0', on && 'bg-[color-mix(in_oklab,var(--primary)_10%,transparent)]')}>
<td className="py-2.5 pl-5 pr-3 tabular-nums text-muted-foreground sm:pl-6">{String(x.ch).padStart(2, '0')}</td>
<td className="px-3 py-2.5 text-foreground">
{x.source}
{x.note && <span className="block text-[12.5px] text-muted-foreground">{x.note}</span>}
</td>
<td className="py-2.5 pl-3 pr-5 text-foreground/85 sm:pr-6">{x.mic}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* MusicianEpk */
/* ------------------------------------------------------------------ */
/**
* An electronic press kit for musicians.
* overview name, genre, numbers, press photos and contacts
* releases covers, tracks with a playing preview
* tour dates by country with ticket state
* press quotes, outlets and downloads
* rider stage plot, input list and needs
*/
export function MusicianEpk(props: MusicianEpkProps) {
const { variant = 'overview', eyebrow, title, description, labels, className } = props;
const t = useTexts(TEXTS, labels);
const body = (() => {
switch (variant) {
case 'releases':
return <Releases {...props} t={t} />;
case 'tour':
return <Tour {...props} t={t} />;
case 'press':
return <Press {...props} t={t} />;
case 'rider':
return <Rider {...props} t={t} />;
default:
return <Overview {...props} t={t} />;
}
})();
return (
<Section className={className} background={variant === 'press' ? 'surface' : 'default'}>
<EpkMotion />
{title && variant !== 'overview' ? <SectionHeader eyebrow={eyebrow} title={title} description={description} /> : null}
{body}
</Section>
);
}Frequent questions
How do I install Musician press kit?
With aiw add @ai-website/musician-epk. The command puts components/ui/musician-epk.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 Musician press kit have?
5 variants, Overview, Releases, Tour dates, Press and Tech rider. You pick one with the variant prop, for example variant="releases".
Can I translate the texts of Musician press kit?
Yes. Musician press kit carries 25 texts of its own in English and German, such as “Press photos”, “Download” and “Contact”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Musician press kit?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Musician press kit with aiw_components and install it with aiw_add @ai-website/musician-epk. Name the variant in your prompt, for example variant="overview", and the assistant writes your content straight into the props.
Can I adjust Musician press kit without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching MusicianEpk code to copy.