Skip to content
UI Kit
Accent color

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

5 variants

Variants

Launch hero

The cover with pages behind it, it tilts toward the pointer and drifts on scroll. Release date with the days to go, both main actions, formats and one quote.

Open in Studio

Reading sample

The first pages on a warm page with a drop cap. A line fills while you read, the text fades out at the end and the full chapter comes by email.

Open in Studio

Contents

The contents with page numbers on dotted leaders. Chapters open with their summary and a bar for their length, free ones are marked. The cover stays beside it.

Open in Studio

Pre-order

Format cards with prices, a signed copy with a dedication, the total that changes softly. The bonuses stack up as they scroll into view.

Open in Studio

Reviews

One large quote at a time with a timer line, it pauses under the pointer. Reader ratings as bars that grow in, short press lines beside them.

Open in Studio

Details

What Book launch brings along, read from the registry and the source.

Category
Creators
Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/book-launch.tsx
Brings along
utilskitbuttoniconmotionrevealsection
npm packages
None
Languages
English and German, 34 texts of its own
In collections
Creator page

Props

The BookLaunchProps interface from components/ui/book-launch.tsx, with the comments from the code.

  • variantoptional
    BookLaunchVariant

    heroexcerptchapterspreorderreviews

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • bookrequired
    { title: string; subtitle?: string; author: string; /** YYYY-MM-DD */ release?: string; pages?: number; cover?: React.ReactNode; }
  • formatsoptional
    BookFormat[]
  • chaptersoptional
    BookChapter[]
  • excerptoptional
    { chapter: string; paragraphs: string[] }

    excerpt: paragraphs of the sample, the first gets a drop cap

  • quotesoptional
    BookQuote[]
  • ratingsoptional
    number[]

    reviews: count of ratings per star, five first

  • bonusesoptional
    { label: string; value?: string; icon?: IconName }[]

    preorder: bonuses for ordering before the release

  • signedExtraoptional
    number

    preorder: price of a signed copy on top

  • currencyoptional
    string
  • onSubmitoptional
    (data: Record<string, string>) => void
  • linksoptional
    { 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
outEnglish Out on {date}German Erscheint am {date}
outNowEnglish Out nowGerman Jetzt erhältlich
daysEnglish {n} days to goGerman Noch {n} Tage
dayEnglish 1 day to goGerman Noch 1 Tag
orderEnglish Pre-order nowGerman Jetzt vorbestellen
buyEnglish Buy nowGerman Jetzt kaufen
sampleEnglish Read a sampleGerman Leseprobe lesen
byEnglish by {name}German von {name}
pagesEnglish {n} pagesGerman {n} Seiten
keepReadingEnglish Read the whole first chapterGerman Das ganze erste Kapitel lesen
keepTextEnglish The full chapter as a PDF, straight to your inbox.German Das vollständige Kapitel als PDF, direkt in dein Postfach.
emailEnglish EmailGerman E-Mail
emailPhEnglish you@example.comGerman du@beispiel.de
sendEnglish Send me chapter 1German Kapitel 1 schicken
sentEnglish Chapter 1 is on its way to {e}.German Kapitel 1 ist unterwegs an {e}.
invalidEnglish Please enter an email address like name@example.com.German Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein.
contentsEnglish ContentsGerman Inhalt
freeEnglish Free sampleGerman Leseprobe
pageEnglish p. {n}German S. {n}
formatEnglish FormatGerman Ausgabe
signedEnglish Signed copy with dedicationGerman Signiert mit Widmung
dedicationEnglish Name for the dedicationGerman Name für die Widmung
dedicationPhEnglish For SamGerman Für Sam
bonusesEnglish Included with every pre-orderGerman Bei jeder Vorbestellung dabei
totalEnglish TotalGerman Gesamt
worthEnglish worth {v}German Wert {v}
readersEnglish Reader ratingsGerman Bewertungen
ratingOfEnglish {r} of 5German {r} von 5
basedEnglish from {n} ratingsGerman aus {n} Bewertungen
prevEnglish Previous quoteGerman Vorheriges Zitat
nextEnglish Next quoteGerman Nächstes Zitat
quoteEnglish Quote {n} of {total}German Zitat {n} von {total}
pressEnglish In the pressGerman In der Presse
starsEnglish {n} starsGerman {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
components/ui/book-launch.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { 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.

Often searched

Jump to

Copied to the clipboard