Press release
Press releases in the form editors expect. The release with dateline, quote, boilerplate and a side column for contact and downloads, the newsroom with filters, a band of the latest releases and a release under embargo.
One of 2 components in Press and awards
Variants
Details
What Press release brings along, read from the registry and the source.
- Category
- Press and awards
- Area
- Marketing
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/press-release.tsx
- npm packages
- None
- Languages
- English and German, 31 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The PressReleaseProps interface from components/ui/press-release.tsx, with the comments from the code.
variantoptionalPressReleaseVariantreleasenewsroomlatestembargo
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodecompanyoptionalstringThe company name, used in the boilerplate heading
releaseoptionalPressReleaseItemreleasesoptionalPressReleaseItem[]boilerplateoptionalstringAbout the company, the paragraph at the end of every release
contactoptionalPressContactdownloadsoptional{ label: string; meta?: string; href: string }[]embargoUntiloptionalstringembargo: ISO date and time the embargo lifts, e.g. 2026-11-03T09:00:00+01:00
onRequestoptional(data: { name: string; outlet: string; email: string }) => voidallHrefoptionalstringlatest: address of all releases, shows the link in the head
Built-in texts
Press release writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 31 texts
| Key | English | German |
|---|---|---|
| release | English Press release | German Pressemitteilung |
| about | English About {c} | German Über {c} |
| contact | English Press contact | German Pressekontakt |
| downloads | English Downloads | German Downloads |
| copyLink | English Copy link | German Link kopieren |
| linkCopied | English Link copied | German Link kopiert |
| copyText | English Copy boilerplate | German Unternehmenstext kopieren |
| textCopied | English Boilerplate copied | German Unternehmenstext kopiert |
| all | English All | German Alle |
| year | English Year | German Jahr |
| allYears | English All years | German Alle Jahre |
| topic | English Topic | German Thema |
| read | English Read the release | German Mitteilung lesen |
| allReleases | English All press releases | German Alle Pressemitteilungen |
| count | English {n} releases | German {n} Mitteilungen |
| count1 | English 1 release | German 1 Mitteilung |
| end | English End of release | German Ende der Mitteilung |
| embargo | English Under embargo | German Sperrfrist |
| lifts | English Embargo lifts on {d} | German Sperrfrist endet am {d} Uhr |
| days | English days | German Tage |
| hours | English hours | German Std. |
| minutes | English min | German Min. |
| lifted | English The embargo has lifted. | German Die Sperrfrist ist abgelaufen. |
| request | English Request the release | German Mitteilung anfordern |
| requestText | English Accredited journalists get the full release, pictures and a contact for interviews before the embargo lifts. | German Akkreditierte Journalistinnen und Journalisten erhalten die ganze Mitteilung, Bilder und einen Kontakt für Interviews vor Ablauf der Sperrfrist. |
| name | English Name | German Name |
| outlet | English Publication | German Medium |
| English Work email | German Berufliche E-Mail | |
| send | English Request access | German Zugang anfordern |
| sent | English Thank you. We will send the release to {e} within the hour. | German Danke. Wir schicken die Mitteilung innerhalb einer Stunde an {e}. |
| terms | English By requesting you agree not to publish before the embargo lifts. | German Mit der Anfrage sicherst du zu, nicht vor Ablauf der Sperrfrist zu veröffentlichen. |
Accessibility
Read from the source of the component.
- Sets aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
- Uses the roles group, separator and status.
- 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.
- 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).
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 547 lines
'use client';
import { useEffect, useId, useMemo, useState } from 'react';
import { cn, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { Magnetic, Stagger, useScrollFade } from '@/components/ui/motion';
import { Reveal } from '@/components/ui/reveal';
import { Eyebrow } from '@/components/ui/section';
/*
* Press releases of a company, in the form editors expect: dateline,
* headline, the facts first, a quote, the boilerplate and a contact.
*
* release the release itself, with a side column for contact, downloads
* and copy buttons for link and boilerplate
* newsroom all releases, filtered by topic and year, the newest large
* latest three latest releases as a band for the home or about page
* embargo a release under embargo: time left until it lifts and a
* request form for accredited journalists
*/
export type PressReleaseVariant = 'release' | 'newsroom' | 'latest' | 'embargo';
export interface PressReleaseItem {
title: string;
/** The subheading under the title */
subtitle?: string;
/** ISO date, e.g. 2026-10-21 */
date: string;
/** City of the dateline, e.g. Bremen */
place?: string;
topic?: string;
/** Paragraphs */
body?: string[];
quote?: { text: string; name: string; role?: string };
href?: string;
}
export interface PressContact {
name: string;
role?: string;
email?: string;
phone?: string;
avatar?: React.ReactNode;
}
export interface PressReleaseProps {
variant?: PressReleaseVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
/** The company name, used in the boilerplate heading */
company?: string;
release?: PressReleaseItem;
releases?: PressReleaseItem[];
/** About the company, the paragraph at the end of every release */
boilerplate?: string;
contact?: PressContact;
downloads?: { label: string; meta?: string; href: string }[];
/** embargo: ISO date and time the embargo lifts, e.g. 2026-11-03T09:00:00+01:00 */
embargoUntil?: string;
onRequest?: (data: { name: string; outlet: string; email: string }) => void;
/** latest: address of all releases, shows the link in the head */
allHref?: string;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
release: 'Press release',
about: 'About {c}',
contact: 'Press contact',
downloads: 'Downloads',
copyLink: 'Copy link',
linkCopied: 'Link copied',
copyText: 'Copy boilerplate',
textCopied: 'Boilerplate copied',
all: 'All',
year: 'Year',
allYears: 'All years',
topic: 'Topic',
read: 'Read the release',
allReleases: 'All press releases',
count: '{n} releases',
count1: '1 release',
end: 'End of release',
embargo: 'Under embargo',
lifts: 'Embargo lifts on {d}',
days: 'days',
hours: 'hours',
minutes: 'min',
lifted: 'The embargo has lifted.',
request: 'Request the release',
requestText: 'Accredited journalists get the full release, pictures and a contact for interviews before the embargo lifts.',
name: 'Name',
outlet: 'Publication',
email: 'Work email',
send: 'Request access',
sent: 'Thank you. We will send the release to {e} within the hour.',
terms: 'By requesting you agree not to publish before the embargo lifts.',
},
de: {
release: 'Pressemitteilung',
about: 'Über {c}',
contact: 'Pressekontakt',
downloads: 'Downloads',
copyLink: 'Link kopieren',
linkCopied: 'Link kopiert',
copyText: 'Unternehmenstext kopieren',
textCopied: 'Unternehmenstext kopiert',
all: 'Alle',
year: 'Jahr',
allYears: 'Alle Jahre',
topic: 'Thema',
read: 'Mitteilung lesen',
allReleases: 'Alle Pressemitteilungen',
count: '{n} Mitteilungen',
count1: '1 Mitteilung',
end: 'Ende der Mitteilung',
embargo: 'Sperrfrist',
lifts: 'Sperrfrist endet am {d} Uhr',
days: 'Tage',
hours: 'Std.',
minutes: 'Min.',
lifted: 'Die Sperrfrist ist abgelaufen.',
request: 'Mitteilung anfordern',
requestText: 'Akkreditierte Journalistinnen und Journalisten erhalten die ganze Mitteilung, Bilder und einen Kontakt für Interviews vor Ablauf der Sperrfrist.',
name: 'Name',
outlet: 'Medium',
email: 'Berufliche E-Mail',
send: 'Zugang anfordern',
sent: 'Danke. Wir schicken die Mitteilung innerhalb einer Stunde an {e}.',
terms: 'Mit der Anfrage sicherst du zu, nicht vor Ablauf der Sperrfrist zu veröffentlichen.',
},
};
type Texts = (typeof TEXTS)['en'];
/* ------------------------------------------------------------------ */
/* Shared pieces */
/* ------------------------------------------------------------------ */
function useDate() {
const de = useLocale() === 'de';
const loc = de ? 'de-DE' : 'en-US';
const long = new Intl.DateTimeFormat(loc, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' });
// English times without a leading zero (9:00 AM), German with (09:00 Uhr)
const withTime = new Intl.DateTimeFormat(loc, { day: 'numeric', month: 'long', year: 'numeric', hour: de ? '2-digit' : 'numeric', minute: '2-digit', timeZone: 'Europe/Berlin' });
return { long: (iso: string) => long.format(new Date(`${iso.slice(0, 10)}T12:00:00Z`)), withTime: (iso: string) => withTime.format(new Date(iso)), year: (iso: string) => Number(iso.slice(0, 4)) };
}
function CopyButton({ value, label, done, icon = 'copy' }: { value: string; label: string; done: string; icon?: 'copy' | 'link' }) {
const [ok, setOk] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(value);
setOk(true);
window.setTimeout(() => setOk(false), 1800);
} catch {
/* clipboard blocked */
}
};
return (
<Button size="sm" variant="outline" iconLeft={ok ? 'check' : icon} onClick={copy} aria-live="polite" className={cn('w-full justify-start max-sm:h-11', ok && 'border-[color-mix(in_oklab,var(--success)_45%,var(--border))] text-success-foreground')}>
{ok ? done : label}
</Button>
);
}
function ContactCard({ c, t }: { c?: PressContact; t: Texts }) {
if (!c) return null;
return (
<div>
<p className="text-[12.5px] font-semibold uppercase tracking-[0.07em] text-muted-foreground">{t.contact}</p>
<div className="mt-3 flex items-center gap-3">
{c.avatar && <span data-clip-ok className="size-11 shrink-0 overflow-hidden rounded-full border border-border [&>*]:size-full">{c.avatar}</span>}
<div className="min-w-0 text-[14px] leading-snug">
<p className="font-semibold text-foreground">{c.name}</p>
{c.role && <p className="text-muted-foreground">{c.role}</p>}
</div>
</div>
<div className="mt-3 space-y-1">
{c.email && (
<a href={`mailto:${c.email}`} className="flex min-h-11 items-center gap-2.5 text-[14px] text-foreground transition-colors hover:text-primary">
<Icon name="mail" size={16} className="text-muted-foreground" />
<span className="truncate">{c.email}</span>
</a>
)}
{c.phone && (
<a href={`tel:${c.phone.replace(/\s/g, '')}`} className="flex min-h-11 items-center gap-2.5 text-[14px] tabular-nums text-foreground transition-colors hover:text-primary">
<Icon name="phone" size={16} className="text-muted-foreground" />
{c.phone}
</a>
)}
</div>
</div>
);
}
function Head({ eyebrow, title, description, aside }: { eyebrow?: React.ReactNode; title?: React.ReactNode; description?: React.ReactNode; aside?: React.ReactNode }) {
if (!title && !eyebrow) return null;
return (
<div className="mb-10 flex flex-col gap-6 sm:mb-12 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-[42rem]">
{eyebrow && <Eyebrow className="kit-rise">{eyebrow}</Eyebrow>}
{title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>{title}</h2>}
{description && <p className="kit-rise mt-4 text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{description}</p>}
</div>
{aside}
</div>
);
}
/* ------------------------------------------------------------------ */
/* release */
/* ------------------------------------------------------------------ */
function Release(p: PressReleaseProps & { t: Texts }) {
const { t } = p;
const d = useDate();
const r = p.release;
const [href, setHref] = useState('');
useEffect(() => setHref(window.location.href), []);
if (!r) return null;
const body = r.body ?? [];
return (
<article className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
<div className="kit-container">
<div className="grid grid-cols-[minmax(0,1fr)] gap-12 lg:grid-cols-[minmax(0,37rem)_minmax(0,17rem)] lg:justify-center lg:gap-20">
<div className="min-w-0">
<p className="kit-rise flex flex-wrap items-center gap-x-3 gap-y-1 text-[13px] font-medium">
<span className="rounded-md bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] px-2 py-0.5 text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{t.release}</span>
{r.topic && <span className="text-muted-foreground">{r.topic}</span>}
</p>
<h1 className="kit-rise mt-5 text-balance text-[34px] font-semibold leading-[1.1] tracking-[-0.022em] text-foreground sm:text-[44px]" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>
{r.title}
</h1>
{r.subtitle && <p className="kit-rise mt-5 text-pretty text-[19px] leading-[1.55] text-muted-foreground sm:text-[20px]" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{r.subtitle}</p>}
<div className="mt-10 border-t border-border pt-8">
{body.map((para, i) => (
<Reveal key={i} effect="fade" className={cn(i > 0 && 'mt-5')}>
<p className="max-w-[37rem] text-pretty text-[17px] leading-[1.75] text-[color-mix(in_oklab,var(--foreground)_88%,var(--background))]">
{i === 0 && (
<span className="font-semibold text-foreground">
{[r.place, d.long(r.date)].filter(Boolean).join(', ')}.{' '}
</span>
)}
{para}
</p>
{i === 1 && r.quote && (
<figure className="my-10 border-l-2 border-primary pl-6">
<blockquote className="text-pretty text-[21px] font-medium leading-[1.45] tracking-[-0.01em] text-foreground sm:text-[23px]">{r.quote.text}</blockquote>
<figcaption className="mt-3 text-[14px] text-muted-foreground">
<span className="font-semibold text-foreground">{r.quote.name}</span>
{r.quote.role && `, ${r.quote.role}`}
</figcaption>
</figure>
)}
</Reveal>
))}
<p className="mt-8 text-center text-[15px] font-semibold tracking-[0.4em] text-muted-foreground" role="separator" aria-label={t.end}>
###
</p>
</div>
{p.boilerplate && (
<Reveal effect="rise" className="mt-10 rounded-2xl border border-border bg-surface p-5 sm:p-6">
<h2 className="text-[15px] font-semibold text-foreground">{t.about.replace('{c}', p.company ?? '')}</h2>
<p className="mt-2 max-w-[34rem] text-pretty text-[15px] leading-[1.65] text-muted-foreground">{p.boilerplate}</p>
</Reveal>
)}
</div>
<aside className="lg:pt-2">
<div className="space-y-8 lg:sticky lg:top-10">
<ContactCard c={p.contact} t={t} />
{p.downloads && p.downloads.length > 0 && (
<div>
<p className="text-[12.5px] font-semibold uppercase tracking-[0.07em] text-muted-foreground">{t.downloads}</p>
<Stagger as="ul" step={60} className="mt-3 divide-y divide-border border-y border-border">
{p.downloads.map((x) => (
<li key={x.label}>
<a href={x.href} download className="group/d flex min-h-14 items-center gap-3 py-2.5">
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-muted text-foreground transition-colors group-hover/d:bg-primary group-hover/d:text-primary-foreground">
<Icon name="download" size={16} />
</span>
<span className="min-w-0 text-[14px] leading-snug">
<span className="block font-medium text-foreground">{x.label}</span>
{x.meta && <span className="text-[12.5px] text-muted-foreground">{x.meta}</span>}
</span>
</a>
</li>
))}
</Stagger>
</div>
)}
<div className="space-y-2">
<CopyButton value={href} label={t.copyLink} done={t.linkCopied} icon="link" />
{p.boilerplate && <CopyButton value={p.boilerplate} label={t.copyText} done={t.textCopied} />}
</div>
</div>
</aside>
</div>
</div>
</article>
);
}
/* ------------------------------------------------------------------ */
/* newsroom */
/* ------------------------------------------------------------------ */
function Newsroom(p: PressReleaseProps & { t: Texts }) {
const { t } = p;
const yid = useId();
const d = useDate();
const all = p.releases ?? [];
const topics = useMemo(() => [...new Set(all.map((r) => r.topic).filter(Boolean) as string[])], [all]);
const years = useMemo(() => [...new Set(all.map((r) => d.year(r.date)))], [all]); // eslint-disable-line react-hooks/exhaustive-deps
const [topic, setTopic] = useState<string | null>(null);
const [year, setYear] = useState<number | null>(null);
const [rowRef, fade] = useScrollFade<HTMLDivElement>();
const shown = all.filter((r) => (!topic || r.topic === topic) && (!year || d.year(r.date) === year));
const [lead, ...rest] = shown;
const chip = (on: boolean) => cn('min-h-10 whitespace-nowrap rounded-lg border px-3.5 text-[14px] font-medium transition-colors max-sm:min-h-11', on ? 'border-transparent bg-foreground text-background' : 'border-border text-foreground hover:bg-accent');
return (
<section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
<div className="kit-container">
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<div className="mb-8 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div ref={rowRef} className={cn('kit-no-scrollbar -mx-1 overflow-x-auto px-1 [&_a:focus-visible]:outline-offset-[-2px] [&_button:focus-visible]:outline-offset-[-2px]', fade)}>
<div role="group" aria-label={t.topic} className="flex w-max gap-2">
<button type="button" aria-pressed={!topic} onClick={() => setTopic(null)} className={chip(!topic)}>
{t.all}
</button>
{topics.map((x) => (
<button key={x} type="button" aria-pressed={topic === x} onClick={() => setTopic(topic === x ? null : x)} className={chip(topic === x)}>
{x}
</button>
))}
</div>
</div>
<div className="flex items-center gap-3">
<label className="sr-only" htmlFor={yid}>
{t.year}
</label>
<select id={yid} value={year ?? ''} onChange={(e) => setYear(e.target.value ? Number(e.target.value) : null)} className="h-11 rounded-lg border border-border bg-card px-3 text-[14px] text-foreground">
<option value="">{t.allYears}</option>
{years.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
<p className="text-[13px] tabular-nums text-muted-foreground" aria-live="polite">
{shown.length === 1 ? t.count1 : t.count.replace('{n}', String(shown.length))}
</p>
</div>
</div>
<div key={`${topic}-${year}`}>
{lead && (
<a href={lead.href ?? '#'} className="kit-rise group/l relative block overflow-hidden rounded-[calc(var(--radius)+12px)] border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow] duration-300 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)] sm:p-8">
<span className="kit-dots pointer-events-none absolute inset-y-0 right-0 hidden w-1/3 opacity-60 lg:block" aria-hidden />
<p className="relative text-[13px] tabular-nums text-muted-foreground">
{d.long(lead.date)}
{lead.topic && <span className="ml-2 rounded-md bg-muted px-1.5 py-0.5 text-[12px] font-medium text-foreground">{lead.topic}</span>}
</p>
<h3 className="relative mt-4 max-w-[44rem] text-balance text-[26px] font-semibold leading-[1.18] tracking-[-0.018em] text-foreground sm:text-[32px]">{lead.title}</h3>
{lead.subtitle && <p className="relative mt-3 max-w-[40rem] text-pretty text-[16.5px] leading-[1.6] text-muted-foreground">{lead.subtitle}</p>}
<span className="relative mt-6 inline-flex items-center gap-1.5 text-[14.5px] font-medium text-foreground">
{t.read}
<Icon name="arrow-right" size={16} className="transition-transform duration-300 group-hover/l:translate-x-1" />
</span>
</a>
)}
<ol className="mt-4 border-t border-border">
{rest.map((r, i) => (
<li key={r.title} className="kit-rise border-b border-border" style={{ '--kit-delay': `${80 + i * 50}ms`, animationDuration: '.6s' } as React.CSSProperties}>
<a href={r.href ?? '#'} className="group/r -mx-3 grid grid-cols-[minmax(0,1fr)] gap-x-6 gap-y-1 rounded-xl px-3 py-5 transition-colors hover:bg-surface sm:grid-cols-[9rem_minmax(0,1fr)_auto] sm:items-baseline">
<span className="text-[13.5px] tabular-nums text-muted-foreground">{d.long(r.date)}</span>
<span className="min-w-0">
<span className="block text-pretty text-[17px] font-semibold leading-[1.35] text-foreground">{r.title}</span>
{r.subtitle && <span className="mt-1 block line-clamp-2 text-pretty text-[14.5px] leading-[1.55] text-muted-foreground">{r.subtitle}</span>}
</span>
{r.topic && <span className="hidden text-[12.5px] font-medium text-muted-foreground sm:block">{r.topic}</span>}
</a>
</li>
))}
</ol>
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* latest */
/* ------------------------------------------------------------------ */
function Latest(p: PressReleaseProps & { t: Texts }) {
const { t } = p;
const d = useDate();
const items = (p.releases ?? []).slice(0, 3);
return (
<section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
<div className="kit-container">
<Head
eyebrow={p.eyebrow}
title={p.title}
description={p.description}
aside={
p.allHref && (
<Button href={p.allHref} variant="outline" icon="arrow-right" className="self-start max-sm:w-full lg:self-end">
{t.allReleases}
</Button>
)
}
/>
<Stagger as="ol" step={90} className="grid grid-cols-[minmax(0,1fr)] gap-px overflow-hidden rounded-2xl border border-border bg-border lg:grid-cols-3 [&_a:focus-visible]:outline-offset-[-2px] [&_button:focus-visible]:outline-offset-[-2px]">
{items.map((r) => (
<li key={r.title} className="bg-background">
<a href={r.href ?? '#'} className="group/c relative flex h-full flex-col p-6 transition-colors duration-300 hover:bg-surface sm:p-7">
<span aria-hidden className="absolute inset-x-0 top-0 h-0.5 origin-left scale-x-0 bg-primary transition-transform duration-500 ease-[var(--ease-kit)] group-hover/c:scale-x-100 group-focus-visible/c:scale-x-100" />
<span className="flex items-center justify-between gap-3 text-[13px] text-muted-foreground">
<span className="tabular-nums">{d.long(r.date)}</span>
{r.topic && <span className="font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{r.topic}</span>}
</span>
<span className="mt-4 text-balance text-[19px] font-semibold leading-[1.3] tracking-[-0.01em] text-foreground">{r.title}</span>
{r.subtitle && <span className="mt-2 line-clamp-3 text-pretty text-[14.5px] leading-[1.55] text-muted-foreground">{r.subtitle}</span>}
<span className="mt-auto inline-flex items-center gap-1.5 pt-6 text-[14px] font-medium text-foreground">
{t.read}
<Icon name="arrow-right" size={15} className="transition-transform duration-300 group-hover/c:translate-x-1" />
</span>
</a>
</li>
))}
</Stagger>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* embargo */
/* ------------------------------------------------------------------ */
function Embargo(p: PressReleaseProps & { t: Texts }) {
const { t } = p;
const uid = useId();
const d = useDate();
const r = p.release;
const until = p.embargoUntil ? new Date(p.embargoUntil).getTime() : 0;
// Server and first render show no numbers, the clock starts in the browser
const [now, setNow] = useState<number | null>(null);
useEffect(() => {
setNow(Date.now());
const id = window.setInterval(() => setNow(Date.now()), 30000);
return () => window.clearInterval(id);
}, []);
const left = now === null ? null : Math.max(0, until - now);
const parts = left === null ? null : [Math.floor(left / 86400000), Math.floor((left % 86400000) / 3600000), Math.floor((left % 3600000) / 60000)];
const [sent, setSent] = useState<string | null>(null);
const [form, setForm] = useState({ name: '', outlet: '', email: '' });
if (!r) return null;
return (
<section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
<div className="kit-container">
<div className="grid grid-cols-[minmax(0,1fr)] gap-10 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-16">
<div>
<p className="kit-rise inline-flex items-center gap-2 rounded-lg bg-[color-mix(in_oklab,var(--warning)_18%,transparent)] px-2.5 py-1 text-[13px] font-semibold text-warning-foreground">
<Icon name="lock" size={14} />
{t.embargo}
</p>
<h2 className="kit-rise mt-5 text-balance text-[30px] font-semibold leading-[1.12] tracking-[-0.022em] text-foreground sm:text-[40px]" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>
{r.title}
</h2>
{r.subtitle && <p className="kit-rise mt-4 text-pretty text-[17.5px] leading-[1.6] text-muted-foreground" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{r.subtitle}</p>}
{p.embargoUntil && (
<Reveal effect="rise" className="mt-8">
<p className="text-[14px] text-muted-foreground">{t.lifts.replace('{d}', d.withTime(p.embargoUntil))}</p>
<div className="mt-3 grid max-w-[24rem] grid-cols-3 gap-px overflow-hidden rounded-xl border border-border bg-border" aria-live="polite">
{[t.days, t.hours, t.minutes].map((label, i) => (
<div key={label} className="bg-card px-4 py-4 text-center">
<p className="text-[34px] font-semibold leading-none tracking-[-0.03em] text-foreground tabular-nums">{parts ? String(parts[i]).padStart(2, '0') : '00'}</p>
<p className="mt-1.5 text-[12.5px] text-muted-foreground">{label}</p>
</div>
))}
</div>
{left === 0 && <p className="mt-3 text-[14px] font-medium text-success-foreground">{t.lifted}</p>}
</Reveal>
)}
<Reveal effect="fade" className="mt-10 hidden lg:block">
<ContactCard c={p.contact} t={t} />
</Reveal>
</div>
<Reveal effect="rise" delay={120} className="kit-card relative self-start overflow-hidden p-6 sm:p-8">
<span className="kit-beam pointer-events-none absolute inset-0 rounded-[inherit]" aria-hidden />
<h3 className="text-[20px] font-semibold text-foreground">{t.request}</h3>
<p className="mt-2 text-pretty text-[15px] leading-[1.6] text-muted-foreground">{t.requestText}</p>
{sent ? (
<p role="status" className="kit-rise mt-6 flex items-start gap-3 rounded-xl border border-[color-mix(in_oklab,var(--success)_40%,var(--border))] bg-[color-mix(in_oklab,var(--success)_8%,transparent)] p-4 text-[14.5px] leading-[1.5] text-foreground">
<span className="grid size-6 shrink-0 place-items-center rounded-full bg-success text-white">
<Icon name="check" size={14} strokeWidth={2.4} />
</span>
{t.sent.replace('{e}', sent)}
</p>
) : (
<form
className="mt-6 space-y-4"
onSubmit={(e) => {
e.preventDefault();
p.onRequest?.(form);
setSent(form.email);
}}
>
<Input id={`${uid}-n`} label={t.name} required autoComplete="name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<Input id={`${uid}-o`} label={t.outlet} required autoComplete="organization" value={form.outlet} onChange={(e) => setForm({ ...form, outlet: e.target.value })} />
<Input id={`${uid}-e`} label={t.email} type="email" required autoComplete="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
<Magnetic className="flex w-full">
<Button type="submit" className="w-full" icon="arrow-right">
{t.send}
</Button>
</Magnetic>
<p className="text-[12.5px] leading-[1.5] text-muted-foreground">{t.terms}</p>
</form>
)}
</Reveal>
<div className="lg:hidden">
<ContactCard c={p.contact} t={t} />
</div>
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
export function PressRelease(props: PressReleaseProps) {
const t = useTexts(TEXTS, props.labels);
const { variant = 'release' } = props;
if (variant === 'newsroom') return <Newsroom {...props} t={t} />;
if (variant === 'latest') return <Latest {...props} t={t} />;
if (variant === 'embargo') return <Embargo {...props} t={t} />;
return <Release {...props} t={t} />;
}Frequent questions
How do I install Press release?
With aiw add @ai-website/press-release. The command puts components/ui/press-release.tsx into your project, the code is yours from then on. It brings along button, icon, input, reveal, motion and section. It needs no further npm packages.
Which variants does Press release have?
4 variants, Release, Newsroom, Latest releases and Under embargo. You pick one with the variant prop, for example variant="newsroom".
Can I translate the texts of Press release?
Yes. Press release carries 31 texts of its own in English and German, such as “Press release”, “Press contact” and “Downloads”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Press release?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Press release with aiw_components and install it with aiw_add @ai-website/press-release. Name the variant in your prompt, for example variant="release", and the assistant writes your content straight into the props.
Can I adjust Press release without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching PressRelease code to copy.