Zum Inhalt springen
UI Kit
Accent

Buchlaunch

Alles, was eine Buchseite braucht. Ein Cover, das sich zum Zeiger neigt, eine Leseprobe mit Fortschritt, das Inhaltsverzeichnis, Vorbestellung mit Boni und signiertem Exemplar und Rezensionen.

Eine von 3 Components in Creator

5 Variants

Variants

Launch-Hero

Das Cover mit Seiten dahinter, es neigt sich zum Zeiger und wandert beim Scrollen. Erscheinungsdatum mit den verbleibenden Tagen, beide Hauptaktionen, Ausgaben und ein Zitat.

Im Studio öffnen

Leseprobe

Die ersten Seiten auf warmem Papier mit Initiale. Eine Linie füllt sich beim Lesen, am Ende blendet der Text aus und das ganze Kapitel kommt per E-Mail.

Im Studio öffnen

Inhalt

Das Inhaltsverzeichnis mit Seitenzahlen auf gepunkteten Linien. Kapitel öffnen sich mit Zusammenfassung und einem Balken für ihre Länge, Leseprobe markiert. Das Cover bleibt daneben stehen.

Im Studio öffnen

Vorbestellung

Ausgaben als Karten mit Preis, ein signiertes Exemplar mit Widmung, die Summe wechselt weich. Die Boni stapeln sich beim Hereinscrollen.

Im Studio öffnen

Rezensionen

Ein großes Zitat nach dem anderen mit Zeitlinie, unter dem Zeiger pausiert es. Bewertungen als Balken, die hineinwachsen, kurze Pressestimmen daneben.

Im Studio öffnen

Details

Was Buchlaunch mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Creator
Bereich
Industries
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/book-launch.tsx
Bringt mit
utilskitbuttoniconmotionrevealsection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 34 eigene Texte
In Sammlungen
Creator-Seite

Props

Die Schnittstelle BookLaunchProps aus components/ui/book-launch.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    BookLaunchVariant

    heroexcerptchapterspreorderreviews

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • bookPflicht
    { 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

Eingebaute Texte

Diese Texte schreibt Buchlaunch selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 34 Texte anzeigen
outEnglisch Out on {date}Deutsch Erscheint am {date}
outNowEnglisch Out nowDeutsch Jetzt erhältlich
daysEnglisch {n} days to goDeutsch Noch {n} Tage
dayEnglisch 1 day to goDeutsch Noch 1 Tag
orderEnglisch Pre-order nowDeutsch Jetzt vorbestellen
buyEnglisch Buy nowDeutsch Jetzt kaufen
sampleEnglisch Read a sampleDeutsch Leseprobe lesen
byEnglisch by {name}Deutsch von {name}
pagesEnglisch {n} pagesDeutsch {n} Seiten
keepReadingEnglisch Read the whole first chapterDeutsch Das ganze erste Kapitel lesen
keepTextEnglisch The full chapter as a PDF, straight to your inbox.Deutsch Das vollständige Kapitel als PDF, direkt in dein Postfach.
emailEnglisch EmailDeutsch E-Mail
emailPhEnglisch you@example.comDeutsch du@beispiel.de
sendEnglisch Send me chapter 1Deutsch Kapitel 1 schicken
sentEnglisch Chapter 1 is on its way to {e}.Deutsch Kapitel 1 ist unterwegs an {e}.
invalidEnglisch Please enter an email address like name@example.com.Deutsch Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein.
contentsEnglisch ContentsDeutsch Inhalt
freeEnglisch Free sampleDeutsch Leseprobe
pageEnglisch p. {n}Deutsch S. {n}
formatEnglisch FormatDeutsch Ausgabe
signedEnglisch Signed copy with dedicationDeutsch Signiert mit Widmung
dedicationEnglisch Name for the dedicationDeutsch Name für die Widmung
dedicationPhEnglisch For SamDeutsch Für Sam
bonusesEnglisch Included with every pre-orderDeutsch Bei jeder Vorbestellung dabei
totalEnglisch TotalDeutsch Gesamt
worthEnglisch worth {v}Deutsch Wert {v}
readersEnglisch Reader ratingsDeutsch Bewertungen
ratingOfEnglisch {r} of 5Deutsch {r} von 5
basedEnglisch from {n} ratingsDeutsch aus {n} Bewertungen
prevEnglisch Previous quoteDeutsch Vorheriges Zitat
nextEnglisch Next quoteDeutsch Nächstes Zitat
quoteEnglisch Quote {n} of {total}Deutsch Zitat {n} von {total}
pressEnglisch In the pressDeutsch In der Presse
starsEnglisch {n} starsDeutsch {n} Sterne

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-controls, aria-describedby, aria-expanded, aria-invalid, aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen img, radiogroup, status und switch.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 5 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 701 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Buchlaunch?

Mit aiw add @ai-website/book-launch. Der Befehl legt die Datei components/ui/book-launch.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, motion, reveal und section. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Buchlaunch?

5 Varianten, Launch-Hero, Leseprobe, Inhalt, Vorbestellung und Rezensionen. Du wählst sie mit der Prop variant, zum Beispiel variant="excerpt".

Kann ich die Texte von Buchlaunch übersetzen?

Ja. Buchlaunch bringt 34 eigene Texte auf Deutsch und Englisch mit, etwa „Jetzt erhältlich“, „Noch 1 Tag“ und „Jetzt vorbestellen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Buchlaunch einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Buchlaunch mit aiw_components und installieren sie mit aiw_add @ai-website/book-launch. Nenn im Prompt die Variante, etwa variant="hero", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Buchlaunch ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden BookLaunch-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert