Book launch
Everything a book page needs. A cover that tilts toward the pointer, a reading sample with progress, the contents, pre-order with bonuses and a signed copy, and reviews.
One of 3 components in Creators
Variants
Details
What Book launch brings along, read from the registry and the source.
- Category
- Creators
- Area
- Industries
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/book-launch.tsx
- npm packages
- None
- Languages
- English and German, 34 texts of its own
- Studio
- All 5 variants editable
- In collections
- Creator page
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The BookLaunchProps interface from components/ui/book-launch.tsx, with the comments from the code.
variantoptionalBookLaunchVariantheroexcerptchapterspreorderreviews
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodebookrequired{ title: string; subtitle?: string; author: string; /** YYYY-MM-DD */ release?: string; pages?: number; cover?: React.ReactNode; }formatsoptionalBookFormat[]chaptersoptionalBookChapter[]excerptoptional{ chapter: string; paragraphs: string[] }excerpt: paragraphs of the sample, the first gets a drop cap
quotesoptionalBookQuote[]ratingsoptionalnumber[]reviews: count of ratings per star, five first
bonusesoptional{ label: string; value?: string; icon?: IconName }[]preorder: bonuses for ordering before the release
signedExtraoptionalnumberpreorder: price of a signed copy on top
currencyoptionalstringonSubmitoptional(data: Record<string, string>) => voidlinksoptional{ excerpt?: string }Where the jumps go, sections of the same page by default: #excerpt
Built-in texts
Book launch writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 34 texts
| Key | English | German |
|---|---|---|
| out | English Out on {date} | German Erscheint am {date} |
| outNow | English Out now | German Jetzt erhältlich |
| days | English {n} days to go | German Noch {n} Tage |
| day | English 1 day to go | German Noch 1 Tag |
| order | English Pre-order now | German Jetzt vorbestellen |
| buy | English Buy now | German Jetzt kaufen |
| sample | English Read a sample | German Leseprobe lesen |
| by | English by {name} | German von {name} |
| pages | English {n} pages | German {n} Seiten |
| keepReading | English Read the whole first chapter | German Das ganze erste Kapitel lesen |
| keepText | English The full chapter as a PDF, straight to your inbox. | German Das vollständige Kapitel als PDF, direkt in dein Postfach. |
| English Email | German E-Mail | |
| emailPh | English you@example.com | German du@beispiel.de |
| send | English Send me chapter 1 | German Kapitel 1 schicken |
| sent | English Chapter 1 is on its way to {e}. | German Kapitel 1 ist unterwegs an {e}. |
| invalid | English Please enter an email address like name@example.com. | German Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein. |
| contents | English Contents | German Inhalt |
| free | English Free sample | German Leseprobe |
| page | English p. {n} | German S. {n} |
| format | English Format | German Ausgabe |
| signed | English Signed copy with dedication | German Signiert mit Widmung |
| dedication | English Name for the dedication | German Name für die Widmung |
| dedicationPh | English For Sam | German Für Sam |
| bonuses | English Included with every pre-order | German Bei jeder Vorbestellung dabei |
| total | English Total | German Gesamt |
| worth | English worth {v} | German Wert {v} |
| readers | English Reader ratings | German Bewertungen |
| ratingOf | English {r} of 5 | German {r} von 5 |
| based | English from {n} ratings | German aus {n} Bewertungen |
| prev | English Previous quote | German Vorheriges Zitat |
| next | English Next quote | German Nächstes Zitat |
| quote | English Quote {n} of {total} | German Zitat {n} von {total} |
| press | English In the press | German In der Presse |
| stars | English {n} stars | German {n} Sterne |
Accessibility
Read from the source of the component.
- Sets aria-controls, aria-describedby, aria-expanded, aria-invalid, aria-label and aria-live, so screen readers announce purpose and state.
- Uses the roles img, radiogroup, status and switch.
- 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
- 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. 5 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, 701 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Magnetic, Parallax, Stagger, useScrollProgress } from '@/components/ui/motion';
import { Reveal } from '@/components/ui/reveal';
import { Section, SectionHeader } from '@/components/ui/section';
/*
* Sections for a book: the launch, a reading sample, the chapters, the
* pre-order with bonuses and what readers and the press say. The cover is a
* picture slot, the kit draws nothing in it.
*
* hero cover that tilts toward the pointer and drifts on scroll, release date, formats and one quote
* excerpt the first pages as a page, a progress line while reading, the rest by email
* chapters the contents with page numbers, each chapter opens with its summary, free ones marked
* preorder format cards, a signed copy, bonuses that stack up and the days until release
* reviews one large quote at a time, reader ratings as bars and short press lines
*/
export type BookLaunchVariant = 'hero' | 'excerpt' | 'chapters' | 'preorder' | 'reviews';
export interface BookFormat {
id: string;
label: string;
/** e.g. "Hardcover, 288 pages" */
detail?: string;
price: number;
icon?: IconName;
href?: string;
}
export interface BookChapter {
id: string;
title: string;
page: number;
summary?: string;
/** Part of the free sample */
free?: boolean;
}
export interface BookQuote {
quote: string;
name: string;
/** e.g. "Author of ..." or a newspaper */
role?: string;
rating?: number;
}
export interface BookLaunchProps {
variant?: BookLaunchVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
book: {
title: string;
subtitle?: string;
author: string;
/** YYYY-MM-DD */
release?: string;
pages?: number;
cover?: React.ReactNode;
};
formats?: BookFormat[];
chapters?: BookChapter[];
/** excerpt: paragraphs of the sample, the first gets a drop cap */
excerpt?: { chapter: string; paragraphs: string[] };
quotes?: BookQuote[];
/** reviews: count of ratings per star, five first */
ratings?: number[];
/** preorder: bonuses for ordering before the release */
bonuses?: { label: string; value?: string; icon?: IconName }[];
/** preorder: price of a signed copy on top */
signedExtra?: number;
currency?: string;
onSubmit?: (data: Record<string, string>) => void;
/** Where the jumps go, sections of the same page by default: #excerpt */
links?: { excerpt?: string };
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
out: 'Out on {date}',
outNow: 'Out now',
days: '{n} days to go',
day: '1 day to go',
order: 'Pre-order now',
buy: 'Buy now',
sample: 'Read a sample',
by: 'by {name}',
pages: '{n} pages',
keepReading: 'Read the whole first chapter',
keepText: 'The full chapter as a PDF, straight to your inbox.',
email: 'Email',
emailPh: 'you@example.com',
send: 'Send me chapter 1',
sent: 'Chapter 1 is on its way to {e}.',
invalid: 'Please enter an email address like name@example.com.',
contents: 'Contents',
free: 'Free sample',
page: 'p. {n}',
format: 'Format',
signed: 'Signed copy with dedication',
dedication: 'Name for the dedication',
dedicationPh: 'For Sam',
bonuses: 'Included with every pre-order',
total: 'Total',
worth: 'worth {v}',
readers: 'Reader ratings',
ratingOf: '{r} of 5',
based: 'from {n} ratings',
prev: 'Previous quote',
next: 'Next quote',
quote: 'Quote {n} of {total}',
press: 'In the press',
stars: '{n} stars',
},
de: {
out: 'Erscheint am {date}',
outNow: 'Jetzt erhältlich',
days: 'Noch {n} Tage',
day: 'Noch 1 Tag',
order: 'Jetzt vorbestellen',
buy: 'Jetzt kaufen',
sample: 'Leseprobe lesen',
by: 'von {name}',
pages: '{n} Seiten',
keepReading: 'Das ganze erste Kapitel lesen',
keepText: 'Das vollständige Kapitel als PDF, direkt in dein Postfach.',
email: 'E-Mail',
emailPh: 'du@beispiel.de',
send: 'Kapitel 1 schicken',
sent: 'Kapitel 1 ist unterwegs an {e}.',
invalid: 'Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein.',
contents: 'Inhalt',
free: 'Leseprobe',
page: 'S. {n}',
format: 'Ausgabe',
signed: 'Signiert mit Widmung',
dedication: 'Name für die Widmung',
dedicationPh: 'Für Sam',
bonuses: 'Bei jeder Vorbestellung dabei',
total: 'Gesamt',
worth: 'Wert {v}',
readers: 'Bewertungen',
ratingOf: '{r} von 5',
based: 'aus {n} Bewertungen',
prev: 'Vorheriges Zitat',
next: 'Nächstes Zitat',
quote: 'Zitat {n} von {total}',
press: 'In der Presse',
stars: '{n} Sterne',
},
};
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) {
const whole = Number.isInteger(value);
return new Intl.NumberFormat(TAG[locale], { style: 'currency', currency, minimumFractionDigits: whole ? 0 : 2, maximumFractionDigits: whole ? 0 : 2 }).format(value);
}
function dateText(iso: string, locale: Locale) {
const [y, m, d] = iso.split('-').map(Number);
return new Intl.DateTimeFormat(TAG[locale], { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }).format(new Date(Date.UTC(y, m - 1, d)));
}
/** Days until the release, only in the browser. On the server null, the date shows alone. */
function useDaysLeft(iso?: string) {
const [n, setN] = useState<number | null>(null);
useEffect(() => {
if (!iso) return;
const [y, m, d] = iso.split('-').map(Number);
const now = new Date();
const today = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
setN(Math.round((Date.UTC(y, m - 1, d) - today) / 864e5));
}, [iso]);
return n;
}
const MOTION_CSS = `
@keyframes kitbook-in{from{opacity:0;transform:translateY(8px)}}
@keyframes kitbook-swap{from{opacity:0;filter:blur(4px);transform:translateY(6px)}}
@keyframes kitbook-quote{from{opacity:0;transform:translateY(14px)}}
@keyframes kitbook-grow{from{transform:scaleX(0)}}
@keyframes kitbook-pop{0%{transform:scale(.5);opacity:0}60%{transform:scale(1.12);opacity:1}100%{transform:scale(1)}}
@media (prefers-reduced-motion:reduce){[class*="kitbook-"],.kitbook-anim{animation:none!important;transition:none!important}}`;
function BookMotion() {
return (
<style href="kitbook-motion" precedence="default">
{MOTION_CSS}
</style>
);
}
function Stars({ value, size = 16, className, label }: { value: number; size?: number; className?: string; label?: string }) {
return (
<span role="img" aria-label={label} className={cn('inline-flex gap-0.5', className)}>
{[1, 2, 3, 4, 5].map((i) => (
<svg key={i} width={size} height={size} viewBox="0 0 24 24" aria-hidden className={i <= Math.round(value) ? 'text-[color-mix(in_oklab,var(--warning)_85%,var(--foreground))]' : 'text-border-strong'}>
<path fill="currentColor" d="M12 2.8l2.7 5.6 6.1.8-4.5 4.2 1.1 6.1L12 16.6l-5.4 2.9 1.1-6.1-4.5-4.2 6.1-.8z" />
</svg>
))}
</span>
);
}
/** The cover in a book shape: spine shade on the left, pages on the right edge, tilts toward a mouse */
function Cover({ cover, title, tilt = true, className }: { cover?: React.ReactNode; title: string; tilt?: boolean; className?: string }) {
const ref = useRef<HTMLDivElement>(null);
const move = (e: React.PointerEvent<HTMLDivElement>) => {
const el = ref.current;
if (!tilt || !el || e.pointerType !== 'mouse' || prefersReducedMotion()) return;
const r = el.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width - 0.5;
const y = (e.clientY - r.top) / r.height - 0.5;
el.style.transform = `perspective(1200px) rotateY(${(x * 14).toFixed(2)}deg) rotateX(${(-y * 10).toFixed(2)}deg)`;
el.style.setProperty('--gx', `${((x + 0.5) * 100).toFixed(1)}%`);
el.style.setProperty('--gy', `${((y + 0.5) * 100).toFixed(1)}%`);
};
const leave = () => {
if (ref.current) ref.current.style.transform = '';
};
return (
<div onPointerMove={move} onPointerLeave={leave} className={cn('relative', className)}>
<div ref={ref} className="relative aspect-[2/3] transition-transform duration-500 ease-[var(--ease-kit)] [transform-style:preserve-3d] motion-reduce:transition-none">
{/* pages behind the cover */}
<span aria-hidden className="absolute inset-y-[1.2%] -right-[3%] left-[4%] rounded-r-[6px] bg-[repeating-linear-gradient(to_bottom,#f4efe6_0,#f4efe6_2px,#e2dccf_2px,#e2dccf_3px)] shadow-[var(--shadow-md)]" />
<div className="relative size-full overflow-hidden rounded-[3px_8px_8px_3px] bg-muted shadow-[0_30px_60px_-20px_rgb(0_0_0/0.45),var(--shadow-md)]" data-clip-ok>
<div className={cn('size-full', fit)} role="img" aria-label={title}>
{cover}
</div>
<span aria-hidden className="absolute inset-y-0 left-0 w-[7%] bg-[linear-gradient(to_right,rgb(0_0_0/0.35),rgb(255_255_255/0.12)_45%,rgb(0_0_0/0.08))]" />
<span aria-hidden className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_var(--gx,30%)_var(--gy,20%),rgb(255_255_255/0.22),transparent_55%)] mix-blend-soft-light" />
</div>
</div>
</div>
);
}
function ReleaseChip({ release, t }: { release?: string; t: Texts }) {
const locale = useLocale();
const left = useDaysLeft(release);
if (!release) return null;
const out = left !== null && left <= 0;
return (
<span className="inline-flex min-h-8 flex-wrap items-center gap-x-2 gap-y-0.5 rounded-full border max-sm:rounded-xl border-border bg-card/70 px-3 py-1 text-[13px] text-foreground backdrop-blur">
<span className="relative flex size-2">
<span aria-hidden className="absolute inset-0 animate-ping rounded-full bg-primary opacity-60 motion-reduce:animate-none" />
<span className="relative size-2 rounded-full bg-primary" />
</span>
<span className="font-medium">{out ? t.outNow : fill(t.out, { date: dateText(release, locale) })}</span>
{left !== null && left > 0 && <span className="whitespace-nowrap text-muted-foreground [animation:kitbook-in_.4s_var(--ease-kit)] max-sm:basis-full max-sm:pl-4"><span aria-hidden className="max-sm:hidden">· </span>{left === 1 ? t.day : fill(t.days, { n: left })}</span>}
</span>
);
}
/* ------------------------------------------------------------------ */
/* hero */
/* ------------------------------------------------------------------ */
function Hero({ book, formats = [], quotes = [], links, t }: BookLaunchProps & { t: Texts }) {
const locale = useLocale();
const left = useDaysLeft(book.release);
const preorder = left === null ? !!book.release : left > 0;
const q = quotes[0];
return (
<div className="grid grid-cols-1 items-center gap-12 md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] lg:gap-20">
<div className="relative mx-auto w-full max-w-[220px] sm:max-w-[280px] md:order-2 md:max-w-[380px]">
<span aria-hidden data-clip-ok className="absolute inset-x-[-20%] top-[10%] h-[80%] rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklab,var(--primary)_28%,transparent),transparent)] blur-2xl" />
<Parallax speed={50}>
<div className="kit-rise [--kit-delay:120ms]">
<Cover cover={book.cover} title={book.title} />
</div>
</Parallax>
</div>
<div className="md:order-1">
<div className="kit-rise">
<ReleaseChip release={book.release} t={t} />
</div>
<h2 className="kit-rise mt-6 text-balance text-h1 font-semibold text-foreground [--kit-delay:60ms]">{book.title}</h2>
{book.subtitle && <p className="kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground [--kit-delay:120ms]">{book.subtitle}</p>}
<p className="kit-rise mt-5 text-[15px] text-foreground [--kit-delay:160ms]">
{fill(t.by, { name: book.author })}
{book.pages && <span className="text-muted-foreground"> · {fill(t.pages, { n: book.pages })}</span>}
</p>
<div className="kit-rise mt-8 flex flex-wrap items-center gap-3 [--kit-delay:220ms] max-sm:flex-col max-sm:items-stretch">
<Magnetic className="max-sm:flex max-sm:w-full">
<Button href={formats[0]?.href ?? '#'} icon="arrow-right" className="max-sm:w-full">
{preorder ? t.order : t.buy}
</Button>
</Magnetic>
<Button href={links?.excerpt ?? '#excerpt'} variant="outline" iconLeft="book-open" className="max-sm:w-full">
{t.sample}
</Button>
</div>
{formats.length > 0 && (
<ul className="kit-rise mt-6 flex flex-wrap gap-x-5 gap-y-2 text-[13.5px] text-muted-foreground [--kit-delay:260ms]">
{formats.map((f) => (
<li key={f.id} className="inline-flex items-center gap-1.5">
<Icon name={f.icon ?? 'book-open'} size={15} />
{f.label}
</li>
))}
</ul>
)}
{q && (
<Reveal delay={300} className="mt-10 border-l-2 border-primary pl-5">
<p className="text-pretty text-[17px] italic leading-relaxed text-foreground">{locale === 'de' ? '„' : '“'}{q.quote}{locale === 'de' ? '“' : '”'}</p>
<p className="mt-2 text-[13.5px] text-muted-foreground">
{q.name}
{q.role && `, ${q.role}`}
</p>
</Reveal>
)}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* excerpt */
/* ------------------------------------------------------------------ */
function Excerpt({ book, excerpt, onSubmit, t }: BookLaunchProps & { t: Texts }) {
const page = useScrollProgress<HTMLDivElement>({ start: 0.15, end: 0.85 });
const [email, setEmail] = useState('');
const [state, setState] = useState<'idle' | 'error' | 'done'>('idle');
const id = useId();
const submit = (e: React.FormEvent) => {
e.preventDefault();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return setState('error');
onSubmit?.({ email });
setState('done');
};
if (!excerpt) return null;
const [first, ...rest] = excerpt.paragraphs;
return (
<div id="excerpt" className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,220px)_minmax(0,1fr)] lg:gap-16">
<aside className="lg:sticky lg:top-8 lg:self-start">
<div className="flex items-center gap-4 lg:block">
<div className="w-16 shrink-0 lg:w-36">
<Cover cover={book.cover} title={book.title} tilt={false} />
</div>
<div className="lg:mt-6">
<p className="text-[15px] font-semibold text-foreground">{book.title}</p>
<p className="mt-0.5 text-[13.5px] text-muted-foreground">{fill(t.by, { name: book.author })}</p>
{book.pages && <p className="mt-2 text-[13px] text-muted-foreground">{fill(t.pages, { n: book.pages })}</p>}
</div>
</div>
</aside>
<div ref={page} className="relative w-full max-w-[720px]">
<div aria-hidden className="sticky top-0 z-[1] -mx-1 mb-6 h-1 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]">
<div className="kitbook-anim h-full origin-left rounded-full bg-primary" style={{ transform: 'scaleX(var(--kit-progress, 1))' }} />
</div>
<article className="rounded-md bg-[color-mix(in_oklab,var(--card)_95%,var(--warning))] px-6 py-10 shadow-[var(--highlight),var(--shadow-md)] ring-1 ring-border sm:px-12 sm:py-14">
<p className="text-center text-[12px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">{excerpt.chapter}</p>
<div className="mt-8 space-y-5 text-pretty text-[17px] leading-[1.75] text-foreground sm:text-[18px]">
<p className="first-letter:float-left first-letter:mr-1.5 first-letter:mt-1 first-letter:text-[3.6em] first-letter:font-semibold first-letter:leading-[0.8] first-letter:text-primary">{first}</p>
{rest.map((p, i) => (
<p key={i} className={cn(i === rest.length - 1 && 'relative')}>
{p}
</p>
))}
</div>
<div aria-hidden className="pointer-events-none relative -mt-24 h-24 bg-[linear-gradient(to_bottom,transparent,color-mix(in_oklab,var(--card)_95%,var(--warning)))]" />
<div className="mt-2 border-t border-border pt-8 text-center">
<p className="text-[18px] font-semibold text-foreground">{t.keepReading}</p>
<p className="mt-1.5 text-[14.5px] text-muted-foreground">{t.keepText}</p>
{state === 'done' ? (
<p role="status" className="mx-auto mt-6 inline-flex items-center gap-2 rounded-full bg-[color-mix(in_oklab,var(--success)_14%,transparent)] px-4 py-2 text-[14px] font-medium text-success-foreground [animation:kitbook-swap_.45s_var(--ease-kit)]">
<Icon name="check" size={16} />
{fill(t.sent, { e: email })}
</p>
) : (
<form onSubmit={submit} noValidate className="mx-auto mt-6 max-w-[440px] text-left">
<label htmlFor={id} className="sr-only">
{t.email}
</label>
<div className="flex flex-col gap-2 sm:flex-row">
<input id={id} type="email" value={email} onChange={(e) => { setEmail(e.target.value); if (state === 'error') setState('idle'); }} placeholder={t.emailPh} aria-invalid={state === 'error'} aria-describedby={state === 'error' ? `${id}-e` : undefined} className={cn('h-11 w-full min-w-0 rounded-lg border bg-background px-3 text-[15px] text-foreground placeholder:text-subtle-foreground sm:w-0 sm:flex-1', focusRing, state === 'error' ? 'border-destructive' : 'border-border-strong')} />
<Button type="submit" icon="arrow-right">
{t.send}
</Button>
</div>
{state === 'error' && (
<p id={`${id}-e`} className="mt-2 text-[13px] text-destructive">
{t.invalid}
</p>
)}
</form>
)}
</div>
</article>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* chapters */
/* ------------------------------------------------------------------ */
function Chapters({ book, chapters = [], t }: BookLaunchProps & { t: Texts }) {
const [open, setOpen] = useState<string | null>(chapters.find((c) => c.free)?.id ?? null);
const base = useId();
const total = book.pages ?? chapters[chapters.length - 1]?.page ?? 1;
return (
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] lg:gap-16">
<div className="hidden lg:block">
<div className="sticky top-8 mx-auto max-w-[260px]">
<Cover cover={book.cover} title={book.title} />
<p className="mt-6 text-center text-[13.5px] text-muted-foreground">{fill(t.pages, { n: total })}</p>
</div>
</div>
<div>
<p className="mb-3 text-[13px] font-medium text-muted-foreground">{t.contents}</p>
<Stagger as="ol" step={50} className="border-t border-border">
{chapters.map((c, i) => {
const on = open === c.id;
const next = chapters[i + 1]?.page ?? total;
const share = round(((next - c.page) / total) * 100, 2);
return (
<li key={c.id} className="border-b border-border">
<h3>
<button type="button" aria-expanded={on} aria-controls={`${base}-${c.id}`} onClick={() => setOpen(on ? null : c.id)} disabled={!c.summary} className={cn('group grid w-full grid-cols-[2.25rem_minmax(0,1fr)_auto] items-baseline gap-x-3 py-4 text-left sm:grid-cols-[3rem_minmax(0,1fr)_auto]', focusRing, 'focus-visible:ring-inset focus-visible:ring-offset-0')}>
<span className="text-[14px] tabular-nums text-muted-foreground">{String(i + 1).padStart(2, '0')}</span>
<span className="flex min-w-0 items-baseline gap-3">
<span className={cn('text-[16.5px] font-medium leading-snug transition-colors', on ? 'text-foreground' : 'text-foreground/85 group-hover:text-foreground')}>{c.title}</span>
{c.free && <span className="shrink-0 rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] px-2 py-0.5 text-[11.5px] font-semibold text-foreground">{t.free}</span>}
<span aria-hidden className="hidden h-px min-w-6 flex-1 translate-y-[-4px] border-b border-dotted border-border-strong sm:block" />
</span>
<span className="flex items-center gap-3 text-[13.5px] tabular-nums text-muted-foreground">
{fill(t.page, { n: c.page })}
{c.summary && <Icon name="chevron-down" size={16} className={cn('transition-transform duration-300', on && 'rotate-180')} />}
</span>
</button>
</h3>
<div id={`${base}-${c.id}`} hidden={!on} className="pb-5 pl-[3rem] sm:pl-[3.75rem]">
{on && (
<div className="[animation:kitbook-swap_.45s_var(--ease-kit)]">
<p className="max-w-[40rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{c.summary}</p>
<div className="mt-4 flex items-center gap-3">
<div aria-hidden className="h-1 w-full max-w-[200px] overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]">
<div className="h-full origin-left rounded-full bg-primary [animation:kitbook-grow_.8s_var(--ease-kit)]" style={{ width: `${Math.min(100, Math.max(4, share * 4))}%` }} />
</div>
<span className="text-[12.5px] tabular-nums text-muted-foreground">{fill(t.pages, { n: next - c.page })}</span>
</div>
</div>
)}
</div>
</li>
);
})}
</Stagger>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* preorder */
/* ------------------------------------------------------------------ */
function Preorder({ book, formats = [], bonuses = [], signedExtra, currency = 'EUR', t }: BookLaunchProps & { t: Texts }) {
const locale = useLocale();
const [fmt, setFmt] = useState(formats[0]?.id ?? '');
const [signed, setSigned] = useState(false);
const [name, setName] = useState('');
const base = useId();
const f = formats.find((x) => x.id === fmt) ?? formats[0];
const physical = f?.icon === 'book-open' || f?.id === 'hardcover' || f?.id === 'paperback';
const total = (f?.price ?? 0) + (signed && physical && signedExtra ? signedExtra : 0);
const [ref, inView] = useInView<HTMLUListElement>();
const left = useDaysLeft(book.release);
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)] lg:gap-12">
<div className="kit-card p-6 sm:p-8">
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-[15px] font-semibold text-foreground">{t.format}</p>
<ReleaseChip release={book.release} t={t} />
</div>
<div role="radiogroup" aria-label={t.format} className="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-3">
{formats.map((x) => {
const on = x.id === f?.id;
return (
<label key={x.id} className={cn('relative flex cursor-pointer flex-col rounded-xl border p-4 transition-[border-color,background-color,transform] duration-300 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring max-sm:flex-row max-sm:items-center max-sm:gap-3', on ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))]' : 'border-border hover:-translate-y-0.5 hover:border-border-strong')}>
<input type="radio" name={`${base}-f`} checked={on} onChange={() => setFmt(x.id)} className="sr-only" />
<Icon name={x.icon ?? 'book-open'} size={20} className={on ? 'text-primary' : 'text-muted-foreground'} />
<span className="mt-3 block min-w-0 flex-1 max-sm:mt-0">
<span className="block text-[15px] font-semibold text-foreground">{x.label}</span>
{x.detail && <span className="block text-[12.5px] text-muted-foreground">{x.detail}</span>}
</span>
<span className="mt-3 text-[16px] font-semibold tabular-nums text-foreground max-sm:mt-0">{money(x.price, currency, locale)}</span>
</label>
);
})}
</div>
{signedExtra !== undefined && (
<div className={cn('mt-5 rounded-xl border border-border p-4 transition-opacity', !physical && 'opacity-50')}>
<label className="flex cursor-pointer items-center justify-between gap-4">
<span className="flex items-center gap-3">
<Icon name="edit" size={18} className="text-muted-foreground" />
<span className="text-[14.5px] font-medium text-foreground">{t.signed}</span>
</span>
<span className="flex items-center gap-3">
<span className="whitespace-nowrap text-[14px] tabular-nums text-muted-foreground">+{money(signedExtra, currency, locale)}</span>
<input type="checkbox" role="switch" checked={signed && physical} disabled={!physical} onChange={(e) => setSigned(e.target.checked)} className="peer sr-only" />
<span aria-hidden className={cn('relative h-6 w-10 rounded-full transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-ring', signed && physical ? 'bg-primary' : 'bg-border-strong')}>
<span className={cn('absolute left-0.5 top-0.5 size-5 rounded-full bg-white shadow transition-transform duration-300 ease-[var(--ease-kit)]', signed && physical && 'translate-x-4')} />
</span>
</span>
</label>
{signed && physical && (
<div className="mt-4 [animation:kitbook-swap_.4s_var(--ease-kit)]">
<label htmlFor={`${base}-d`} className="mb-2 block text-[13.5px] font-medium text-foreground">
{t.dedication}
</label>
<input id={`${base}-d`} value={name} onChange={(e) => setName(e.target.value)} placeholder={t.dedicationPh} className={cn('h-11 w-full rounded-lg border border-border-strong bg-background px-3 text-[15px] text-foreground placeholder:text-subtle-foreground', focusRing)} />
</div>
)}
</div>
)}
<div className="mt-6 flex flex-col gap-4 border-t border-border pt-6 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-[13px] text-muted-foreground">{t.total}</p>
<p key={total} className="text-[30px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground [animation:kitbook-swap_.4s_var(--ease-kit)]">
{money(total, currency, locale)}
</p>
</div>
<Button href={f?.href ?? '#'} icon="arrow-right" size="lg" className="max-sm:w-full">
{left !== null && left <= 0 ? t.buy : t.order}
</Button>
</div>
</div>
{bonuses.length > 0 && (
<div className="relative overflow-hidden rounded-xl border border-border bg-surface p-6 sm:p-8">
<p className="text-[15px] font-semibold text-foreground">{t.bonuses}</p>
<ul ref={ref} className="relative z-[1] mt-5 grid gap-3">
{bonuses.map((b, i) => (
<li key={b.label} className="flex items-center gap-4 rounded-xl border border-border bg-card p-4 transition-[transform,opacity] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: inView ? 'none' : `translateY(${16 + i * 10}px) scale(0.97)`, opacity: inView ? 1 : 0, transitionDelay: inView ? `${i * 120}ms` : '0ms' }}>
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-primary">
<Icon name={b.icon ?? 'gift'} size={19} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[14.5px] font-medium text-foreground">{b.label}</span>
{b.value && <span className="mt-0.5 block text-[13px] text-muted-foreground sm:hidden">{fill(t.worth, { v: b.value })}</span>}
</span>
{b.value && <span className="shrink-0 text-[13px] text-muted-foreground max-sm:hidden">{fill(t.worth, { v: b.value })}</span>}
</li>
))}
</ul>
<div aria-hidden data-clip-ok className="pointer-events-none absolute -bottom-24 -right-12 w-36 rotate-12 opacity-80 max-lg:hidden">
<Cover cover={book.cover} title={book.title} tilt={false} />
</div>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* reviews */
/* ------------------------------------------------------------------ */
function Reviews({ quotes = [], ratings = [], t }: BookLaunchProps & { t: Texts }) {
const locale = useLocale();
const big = quotes.filter((q) => q.rating === undefined || q.quote.length > 90);
const small = quotes.filter((q) => !big.includes(q));
const [i, setI] = useState(0);
const [paused, setPaused] = useState(false);
const count = ratings.reduce((a, b) => a + b, 0);
const avg = count ? ratings.reduce((a, n, k) => a + n * (5 - k), 0) / count : 0;
const [ref, inView] = useInView<HTMLDivElement>();
useEffect(() => {
if (paused || big.length < 2 || prefersReducedMotion()) return;
const id = window.setInterval(() => setI((v) => (v + 1) % big.length), 7000);
return () => window.clearInterval(id);
}, [paused, big.length]);
const q = big[i % Math.max(1, big.length)];
const nf = new Intl.NumberFormat(TAG[locale], { maximumFractionDigits: 1, minimumFractionDigits: 1 });
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)] lg:gap-8">
{q && (
<figure onPointerEnter={() => setPaused(true)} onPointerLeave={() => setPaused(false)} onFocus={() => setPaused(true)} onBlur={() => setPaused(false)} className="kit-card relative flex min-h-[300px] flex-col justify-between overflow-hidden p-8 sm:p-10">
<Icon name="quote" size={56} className="absolute right-8 top-8 text-[color-mix(in_oklab,var(--primary)_22%,transparent)]" />
<blockquote key={i} aria-live="polite" className="relative max-w-[40rem] text-balance pr-12 text-[22px] font-medium leading-[1.4] tracking-[-0.01em] text-foreground [animation:kitbook-quote_.6s_var(--ease-kit)] sm:text-[26px]">
{q.quote}
</blockquote>
<figcaption key={`c${i}`} className="relative mt-8 flex flex-wrap items-end justify-between gap-4 [animation:kitbook-in_.5s_var(--ease-kit)_both] [animation-delay:120ms]">
<span>
<span className="block text-[15px] font-semibold text-foreground">{q.name}</span>
{q.role && <span className="block text-[13.5px] text-muted-foreground">{q.role}</span>}
</span>
{big.length > 1 && (
<span className="flex items-center gap-3">
<span className="text-[13px] tabular-nums text-muted-foreground">{fill(t.quote, { n: i + 1, total: big.length })}</span>
<button type="button" aria-label={t.prev} onClick={() => setI((v) => (v - 1 + big.length) % big.length)} className={cn('flex size-11 items-center justify-center rounded-full border border-border text-foreground transition-colors hover:bg-accent', focusRing)}>
<Icon name="arrow-left" size={18} />
</button>
<button type="button" aria-label={t.next} onClick={() => setI((v) => (v + 1) % big.length)} className={cn('flex size-11 items-center justify-center rounded-full border border-border text-foreground transition-colors hover:bg-accent', focusRing)}>
<Icon name="arrow-right" size={18} />
</button>
</span>
)}
</figcaption>
{big.length > 1 && !paused && (
<span aria-hidden key={`p${i}`} className="absolute inset-x-0 bottom-0 h-0.5 origin-left bg-primary animate-kit-progress [--kit-duration:7s] motion-reduce:hidden" />
)}
</figure>
)}
{count > 0 && (
<div ref={ref} className="kit-card p-6">
<p className="text-[13px] font-medium text-muted-foreground">{t.readers}</p>
<div className="mt-2 flex items-end gap-3">
<span className="text-[44px] font-semibold leading-none tracking-[-0.03em] tabular-nums text-foreground">{nf.format(avg)}</span>
<span className="pb-1">
<Stars value={avg} label={fill(t.ratingOf, { r: nf.format(avg) })} />
<span className="block text-[12.5px] text-muted-foreground">{fill(t.based, { n: new Intl.NumberFormat(TAG[locale]).format(count) })}</span>
</span>
</div>
<ul className="mt-5 grid gap-2">
{ratings.map((n, k) => (
<li key={k} className="grid grid-cols-[2.5rem_minmax(0,1fr)_2.75rem] items-center gap-3 text-[13px] tabular-nums text-muted-foreground">
<span aria-label={fill(t.stars, { n: 5 - k })}>{5 - k} ★</span>
<span className="h-2 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]">
<span className="block h-full origin-left rounded-full bg-[color-mix(in_oklab,var(--warning)_85%,var(--foreground))] transition-transform duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${inView ? round(n / Math.max(...ratings), 3) : 0})`, transitionDelay: inView ? `${k * 90}ms` : '0ms' }} />
</span>
<span className="text-right">{Math.round((n / count) * 100)}{locale === 'de' ? ' %' : '%'}</span>
</li>
))}
</ul>
</div>
)}
{small.length > 0 && (
<div className="lg:col-span-2">
<p className="mb-3 text-[13px] font-medium text-muted-foreground">{t.press}</p>
<Stagger as="ul" step={80} className="grid grid-cols-1 gap-3 md:grid-cols-3">
{small.map((s) => (
<li key={s.name} className="rounded-xl border border-border p-4">
{s.rating !== undefined && <Stars value={s.rating} size={14} label={fill(t.ratingOf, { r: s.rating })} />}
<p className="mt-2 text-pretty text-[14.5px] leading-relaxed text-foreground">{s.quote}</p>
<p className="mt-2 text-[13px] font-medium text-muted-foreground">{s.name}</p>
</li>
))}
</Stagger>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* BookLaunch */
/* ------------------------------------------------------------------ */
/**
* Sections for a book launch.
* hero tilting cover, release date with days to go, formats and one quote
* excerpt the first pages with a reading line, the rest by email
* chapters the contents, chapters open with their summary
* preorder formats, signed copy, bonuses and the total
* reviews rotating quotes, reader ratings and press lines
*/
export function BookLaunch(props: BookLaunchProps) {
const { variant = 'hero', eyebrow, title, description, labels, className } = props;
const t = useTexts(TEXTS, labels);
const body = (() => {
switch (variant) {
case 'excerpt':
return <Excerpt {...props} t={t} />;
case 'chapters':
return <Chapters {...props} t={t} />;
case 'preorder':
return <Preorder {...props} t={t} />;
case 'reviews':
return <Reviews {...props} t={t} />;
default:
return <Hero {...props} t={t} />;
}
})();
return (
<Section className={cn('overflow-clip', className)} background={variant === 'hero' ? 'glow' : variant === 'excerpt' ? 'surface' : 'default'}>
<BookMotion />
{title && variant !== 'hero' ? <SectionHeader eyebrow={eyebrow} title={title} description={description} /> : null}
{body}
</Section>
);
}Frequent questions
How do I install Book launch?
With aiw add @ai-website/book-launch. The command puts components/ui/book-launch.tsx into your project, the code is yours from then on. It brings along button, icon, motion, reveal and section. It needs no further npm packages.
Which variants does Book launch have?
5 variants, Launch hero, Reading sample, Contents, Pre-order and Reviews. You pick one with the variant prop, for example variant="excerpt".
Can I translate the texts of Book launch?
Yes. Book launch carries 34 texts of its own in English and German, such as “Out now”, “1 day to go” and “Pre-order now”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Book launch?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Book launch with aiw_components and install it with aiw_add @ai-website/book-launch. Name the variant in your prompt, for example variant="hero", and the assistant writes your content straight into the props.
Can I adjust Book launch without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching BookLaunch code to copy.