Zum Inhalt springen
UI Kit
Accent

Teilen

Eine Seite per Link, E-Mail, Nachricht, Teilen-Menü des Geräts oder QR-Code teilen. Keine fremden Logos, eigene Kanäle als URL-Vorlage.

Eine von 3 Components in Blog

4 Variants

Variants

In einer Reihe

Runde Knöpfe unter dem Artikel-Header, ein Tooltip steigt beim Hovern auf. Kopieren bestätigt mit Haken, der QR-Code öffnet an Ort und Stelle.

Seitenleiste

Eine Leiste neben dem Text mit einem Ring, der sich beim Lesen füllt. Auf Telefon und Tablet stehen dieselben Wege am Ende des Artikels.

Im Studio öffnen

Artikelende

Die Autorin mit kurzer Bio, die Wege zum Teilen und eine kurze Frage, ob der Artikel geholfen hat.

Im Studio öffnen

Zitat teilen

Markiere einen Satz, und eine kleine Leiste bietet Teilen oder Kopieren an. Herausgestellte Zitate tragen dieselben zwei Aktionen für Touch und Tastatur.

Im Studio öffnen

Details

Was Teilen mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Blog
Bereich
Content
Variants
4
Version
1.0.1 · seit 1.0.0
Datei
components/ui/share.tsx
Bringt mit
utilskitbuttoniconrevealmotionsectiongraphics
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 22 eigene Texte

Props

Die Schnittstelle ShareProps aus components/ui/share.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    ShareVariant

    inline-rowside-railarticle-endquote-select

  • urloptional
    string

    Address to share, default the current page

  • titleoptional
    string

    Title of the page, default the document title

  • textoptional
    string

    A short line sent with email and message

  • channelsoptional
    ShareChannel[]

    Your own channels after the built-in ones

  • hideoptional
    ('copy' | 'email' | 'message' | 'native' | 'qr')[]

    Hide built-in ways, e.g. ['qr', 'message']

  • authoroptional
    ShareAuthor

    article-end: the author

  • paragraphsoptional
    string[]

    side-rail, quote-select: the article text, one string per paragraph

  • quotesoptional
    string[]

    quote-select: sentences shown as pull quotes

  • headingoptional
    string

    side-rail, quote-select: the article heading

  • onShareoptional
    (way: string) => void

    Called with the way something was shared, e.g. copy or email

Eingebaute Texte

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

Alle 22 Texte anzeigen
shareEnglisch ShareDeutsch Teilen
shareArticleEnglisch Share this articleDeutsch Artikel teilen
copyEnglisch Copy linkDeutsch Link kopieren
copiedEnglisch Link copiedDeutsch Link kopiert
emailEnglisch EmailDeutsch E-Mail
messageEnglisch MessageDeutsch Nachricht
nativeEnglisch More optionsDeutsch Weitere Optionen
qrEnglisch QR codeDeutsch QR-Code
qrHintEnglisch Point your phone camera here to open this page on your phone.Deutsch Halte die Handykamera darauf, um die Seite auf dem Telefon zu öffnen.
closeEnglisch CloseDeutsch Schließen
readEnglisch {n}% readDeutsch {n} % gelesen
writtenByEnglisch Written byDeutsch Geschrieben von
helpfulEnglisch Was this article helpful?Deutsch War dieser Artikel hilfreich?
yesEnglisch YesDeutsch Ja
noEnglisch NoDeutsch Nein
thanksEnglisch Thank you. Your answer helps us write the next one.Deutsch Danke. Deine Antwort hilft uns beim nächsten Artikel.
shareQuoteEnglisch Share quoteDeutsch Zitat teilen
copyQuoteEnglisch Copy quoteDeutsch Zitat kopieren
quoteCopiedEnglisch Quote copiedDeutsch Zitat kopiert
selectHintEnglisch Select a sentence to share itDeutsch Markiere einen Satz, um ihn zu teilen
qoEnglisch “Deutsch „
qcEnglisch ”Deutsch “

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Teilen reagiert selbst auf die Tasten Escape.
  • Setzt aria-describedby und aria-label, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen img, status, toolbar und tooltip.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • 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).
  • Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 572 Zeilen
components/ui/share.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { useScrollProgress } from '@/components/ui/motion';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { QrPattern } from '@/components/ui/graphics';

/*
 * Sharing a page without a single foreign logo. The built-in ways are the
 * ones every visitor has: copy the link, send it by email or message, the
 * share sheet of the device and a QR code for the phone next to you. Add
 * your own channels with a URL template ({url}, {title}, {text}).
 *
 *   inline-row    a row of round buttons under the article header
 *   side-rail     a rail next to the text that follows the reading
 *   article-end   author box, share and a short "was this helpful"
 *   quote-select  select a sentence or press a pull quote to share it
 */

export type ShareVariant = 'inline-row' | 'side-rail' | 'article-end' | 'quote-select';

export interface ShareChannel {
  label: string;
  icon: IconName;
  /** URL template with {url}, {title} and {text}, e.g. https://example.com/share?u={url} */
  href: string;
}

export interface ShareAuthor {
  name: string;
  role?: string;
  bio?: string;
  avatar?: React.ReactNode;
  links?: { label: string; href: string }[];
}

export interface ShareProps {
  variant?: ShareVariant;
  /** Address to share, default the current page */
  url?: string;
  /** Title of the page, default the document title */
  title?: string;
  /** A short line sent with email and message */
  text?: string;
  /** Your own channels after the built-in ones */
  channels?: ShareChannel[];
  /** Hide built-in ways, e.g. ['qr', 'message'] */
  hide?: ('copy' | 'email' | 'message' | 'native' | 'qr')[];
  /** article-end: the author */
  author?: ShareAuthor;
  /** side-rail, quote-select: the article text, one string per paragraph */
  paragraphs?: string[];
  /** quote-select: sentences shown as pull quotes */
  quotes?: string[];
  /** side-rail, quote-select: the article heading */
  heading?: string;
  /** Called with the way something was shared, e.g. copy or email */
  onShare?: (way: string) => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    share: 'Share',
    shareArticle: 'Share this article',
    copy: 'Copy link',
    copied: 'Link copied',
    email: 'Email',
    message: 'Message',
    native: 'More options',
    qr: 'QR code',
    qrHint: 'Point your phone camera here to open this page on your phone.',
    close: 'Close',
    read: '{n}% read',
    writtenBy: 'Written by',
    helpful: 'Was this article helpful?',
    yes: 'Yes',
    no: 'No',
    thanks: 'Thank you. Your answer helps us write the next one.',
    shareQuote: 'Share quote',
    copyQuote: 'Copy quote',
    quoteCopied: 'Quote copied',
    selectHint: 'Select a sentence to share it',
    qo: '“',
    qc: '”',
  },
  de: {
    share: 'Teilen',
    shareArticle: 'Artikel teilen',
    copy: 'Link kopieren',
    copied: 'Link kopiert',
    email: 'E-Mail',
    message: 'Nachricht',
    native: 'Weitere Optionen',
    qr: 'QR-Code',
    qrHint: 'Halte die Handykamera darauf, um die Seite auf dem Telefon zu öffnen.',
    close: 'Schließen',
    read: '{n} % gelesen',
    writtenBy: 'Geschrieben von',
    helpful: 'War dieser Artikel hilfreich?',
    yes: 'Ja',
    no: 'Nein',
    thanks: 'Danke. Deine Antwort hilft uns beim nächsten Artikel.',
    shareQuote: 'Zitat teilen',
    copyQuote: 'Zitat kopieren',
    quoteCopied: 'Zitat kopiert',
    selectHint: 'Markiere einen Satz, um ihn zu teilen',
    qo: '„',
    qc: '“',
  },
};

type Texts = (typeof TEXTS)['en'];

/* ------------------------------------------------------------------ */
/* Ways to share                                                       */
/* ------------------------------------------------------------------ */

const fill = (tpl: string, d: { url: string; title: string; text: string }) =>
  tpl.replace(/\{url\}/g, encodeURIComponent(d.url)).replace(/\{title\}/g, encodeURIComponent(d.title)).replace(/\{text\}/g, encodeURIComponent(d.text));

function useShare(p: ShareProps) {
  const [copied, setCopied] = useState<string | null>(null);
  const timer = useRef(0);
  useEffect(() => () => window.clearTimeout(timer.current), []);
  const data = () => ({ url: p.url ?? window.location.href, title: p.title ?? document.title, text: p.text ?? '' });
  const copy = async (value?: string, what = 'link') => {
    try {
      await navigator.clipboard.writeText(value ?? data().url);
    } catch {
      /* clipboard blocked, the confirmation still shows */
    }
    setCopied(what);
    p.onShare?.(what === 'link' ? 'copy' : what);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setCopied(null), 1800);
  };
  const open = (tpl: string, way: string) => {
    p.onShare?.(way);
    window.location.href = fill(tpl, data());
  };
  const native = async () => {
    const d = data();
    p.onShare?.('native');
    if (navigator.share) {
      try {
        await navigator.share({ url: d.url, title: d.title, text: d.text });
      } catch {
        /* closed by the visitor */
      }
    } else copy();
  };
  return { copied, copy, open, native, data };
}

interface Way {
  id: string;
  label: string;
  icon: IconName;
  run: () => void;
  /** A real address where one is known on the server */
  href?: string;
}

function useWays(p: ShareProps, t: Texts, share: ReturnType<typeof useShare>, onQr?: () => void): Way[] {
  const hide = new Set(p.hide ?? []);
  const ssr = { url: p.url ?? '', title: p.title ?? '', text: p.text ?? '' };
  const ways: Way[] = [];
  if (!hide.has('copy')) ways.push({ id: 'copy', label: share.copied === 'link' ? t.copied : t.copy, icon: share.copied === 'link' ? 'check' : 'link', run: () => share.copy() });
  if (!hide.has('email')) ways.push({ id: 'email', label: t.email, icon: 'mail', href: p.url ? fill('mailto:?subject={title}&body={text}%0A%0A{url}', ssr) : undefined, run: () => share.open('mailto:?subject={title}&body={text}%0A%0A{url}', 'email') });
  if (!hide.has('message')) ways.push({ id: 'message', label: t.message, icon: 'message-circle', href: p.url ? fill('sms:?&body={title}%20{url}', ssr) : undefined, run: () => share.open('sms:?&body={title}%20{url}', 'message') });
  for (const c of p.channels ?? []) ways.push({ id: c.label, label: c.label, icon: c.icon, href: p.url ? fill(c.href, ssr) : undefined, run: () => share.open(c.href, c.label) });
  if (!hide.has('qr') && onQr) ways.push({ id: 'qr', label: t.qr, icon: 'qr-code', run: onQr });
  if (!hide.has('native')) ways.push({ id: 'native', label: t.native, icon: 'share-out', run: () => share.native() });
  return ways;
}

/** A round button with a tooltip that rises on hover and focus */
function WayButton({ way, size = 'md', tip = 'top', active }: { way: Way; size?: 'md' | 'lg'; tip?: 'top' | 'right'; active?: boolean }) {
  const cls = cn(
    'group/way relative grid shrink-0 place-items-center rounded-full border border-border bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,background-color,border-color,color] duration-300 ease-[var(--ease-kit)] hover:-translate-y-0.5 hover:border-border-strong active:scale-95 motion-reduce:hover:translate-y-0',
    size === 'lg' ? 'size-12' : 'size-11',
    (way.icon === 'check' || active) && 'border-[color-mix(in_oklch,var(--success)_40%,var(--border))] text-success',
  );
  const inner = (
    <>
      <Icon name={way.icon} size={17} className={cn('transition-transform duration-300', way.icon === 'check' && 'scale-110')} />
      <span
        role="tooltip"
        className={cn(
          'pointer-events-none absolute z-20 whitespace-nowrap rounded-md bg-foreground px-2 py-1 text-[12px] font-medium text-background opacity-0 shadow-[var(--shadow-md)] transition-[opacity,transform] duration-200 group-hover/way:opacity-100 group-focus-visible/way:opacity-100 max-sm:hidden',
          tip === 'top' ? 'bottom-[calc(100%+8px)] left-1/2 -translate-x-1/2 translate-y-1 group-hover/way:translate-y-0 group-focus-visible/way:translate-y-0' : 'left-[calc(100%+10px)] top-1/2 -translate-x-1 -translate-y-1/2 group-hover/way:translate-x-0 group-focus-visible/way:translate-x-0',
        )}
      >
        {way.label}
      </span>
    </>
  );
  if (way.href)
    return (
      <a
        href={way.href}
        aria-label={way.label}
        onClick={(e) => {
          e.preventDefault();
          way.run();
        }}
        className={cls}
      >
        {inner}
      </a>
    );
  return (
    <button type="button" aria-label={way.label} onClick={way.run} className={cls}>
      {inner}
    </button>
  );
}

function QrPanel({ open, onClose, t, className }: { open: boolean; onClose: () => void; t: Texts; className?: string }) {
  return (
    <div className={cn('grid transition-[grid-template-rows,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', className)}>
      <div className="min-h-0 overflow-hidden" inert={!open}>
        <div className="mt-4 flex items-center gap-4 rounded-2xl border border-border bg-surface p-4">
          <span className={cn('block size-24 shrink-0 rounded-xl bg-white p-1.5 shadow-[var(--shadow-sm)] transition-transform duration-500 ease-[var(--ease-kit)]', open ? 'scale-100' : 'scale-90')}>
            <QrPattern seed={11} className="size-full" />
          </span>
          <p className="flex-1 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{t.qrHint}</p>
          <button type="button" onClick={onClose} aria-label={t.close} className="grid size-9 shrink-0 place-items-center self-start rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground max-sm:size-11">
            <Icon name="x" size={16} />
          </button>
        </div>
      </div>
    </div>
  );
}

function Status({ copied, t }: { copied: string | null; t: Texts }) {
  return (
    <span role="status" className="sr-only">
      {copied === 'link' ? t.copied : copied === 'quote' ? t.quoteCopied : ''}
    </span>
  );
}

/* ------------------------------------------------------------------ */
/* inline-row                                                          */
/* ------------------------------------------------------------------ */

function InlineRow(p: ShareProps & { t: Texts }) {
  const { t } = p;
  const share = useShare(p);
  const [qr, setQr] = useState(false);
  const ways = useWays(p, t, share, () => setQr((v) => !v));
  return (
    <div className={p.className}>
      <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-5">
        <p className="text-[14px] font-medium text-foreground">{t.shareArticle}</p>
        <ul className="flex flex-wrap gap-2.5">
          {ways.map((w, i) => (
            <li key={w.id} className="kit-rise" style={{ '--kit-delay': `${i * 50}ms` } as React.CSSProperties}>
              <WayButton way={w} active={w.id === 'qr' && qr} />
            </li>
          ))}
        </ul>
      </div>
      <QrPanel open={qr} onClose={() => setQr(false)} t={t} className="max-w-[28rem]" />
      <Status copied={share.copied} t={t} />
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* side-rail                                                           */
/* ------------------------------------------------------------------ */

function SideRail(p: ShareProps & { t: Texts }) {
  const { t } = p;
  const share = useShare(p);
  const ways = useWays(p, t, share);
  const ref = useScrollProgress<HTMLDivElement>({ start: 0.15, end: 0.85 });
  const [pct, setPct] = useState(0);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    let frame = 0;
    const read = () => {
      frame = 0;
      setPct(Math.round(Number(getComputedStyle(el).getPropertyValue('--kit-progress') || 0) * 100));
    };
    const on = () => {
      if (!frame) frame = requestAnimationFrame(read);
    };
    read();
    window.addEventListener('scroll', on, { passive: true });
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', on);
    };
  }, [ref]);
  return (
    <Section spacing="sm" className={p.className}>
      <div ref={ref} className="grid grid-cols-[minmax(0,1fr)] gap-8 lg:grid-cols-[4rem_minmax(0,44rem)] lg:justify-center lg:gap-16">
        <aside className="hidden lg:block">
          <div className="sticky top-28 flex flex-col items-center gap-2.5">
            {/* The ring fills while the article is read */}
            <div className="relative mb-2 grid size-12 place-items-center" aria-label={t.read.replace('{n}', String(pct))} role="img">
              <svg viewBox="0 0 48 48" className="absolute inset-0 -rotate-90" aria-hidden>
                <circle cx="24" cy="24" r="21" fill="none" stroke="var(--border)" strokeWidth="2" />
                <circle cx="24" cy="24" r="21" fill="none" stroke="var(--primary)" strokeWidth="2" strokeLinecap="round" pathLength={100} strokeDasharray="100" strokeDashoffset={100 - pct} className="transition-[stroke-dashoffset] duration-200" />
              </svg>
              <span className="relative text-[11px] font-semibold tabular-nums text-foreground">{pct}</span>
            </div>
            <p className="text-[11.5px] font-medium text-muted-foreground">{t.share}</p>
            {ways.map((w) => (
              <WayButton key={w.id} way={w} tip="right" />
            ))}
          </div>
        </aside>
        <article className="min-w-0">
          {p.heading && <h2 className="mb-6 text-balance text-h2 font-semibold text-foreground">{p.heading}</h2>}
          <div className="space-y-5 text-pretty text-[17px] leading-[1.75] text-[color-mix(in_oklab,var(--foreground)_84%,var(--background))]">
            {(p.paragraphs ?? []).map((x, i) => (
              <Reveal as="div" key={i} effect="fade">
                <p>{x}</p>
              </Reveal>
            ))}
          </div>
          {/* Phones and tablets: the same ways in a row at the end */}
          <div className="mt-10 border-t border-border pt-6 lg:hidden">
            <p className="mb-3 text-[14px] font-medium text-foreground">{t.shareArticle}</p>
            <ul className="flex flex-wrap gap-2.5">
              {ways.map((w) => (
                <li key={w.id}>
                  <WayButton way={w} />
                </li>
              ))}
            </ul>
          </div>
        </article>
      </div>
      <Status copied={share.copied} t={t} />
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* article-end                                                         */
/* ------------------------------------------------------------------ */

function ArticleEnd(p: ShareProps & { t: Texts }) {
  const { t } = p;
  const share = useShare(p);
  const [qr, setQr] = useState(false);
  const ways = useWays(p, t, share, () => setQr((v) => !v));
  const [vote, setVote] = useState<'yes' | 'no' | null>(null);
  const a = p.author;
  return (
    <Section spacing="sm" className={p.className}>
      <div className="mx-auto max-w-[46rem] space-y-4">
        {a && (
          <Reveal className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] sm:p-7">
            <div className="flex flex-col gap-5 sm:flex-row">
              <span data-clip-ok className="relative grid size-16 shrink-0 place-items-center overflow-hidden rounded-full bg-muted text-[18px] font-semibold text-muted-foreground ring-1 ring-border sm:size-20 [&>*]:size-full">
                {a.avatar ?? a.name.split(/\s+/).map((w) => w[0]).slice(0, 2).join('')}
              </span>
              <div className="min-w-0 flex-1">
                <p className="text-[12.5px] font-medium text-muted-foreground">{t.writtenBy}</p>
                <p className="mt-0.5 text-[18px] font-semibold text-foreground">{a.name}</p>
                {a.role && <p className="text-[14px] text-muted-foreground">{a.role}</p>}
                {a.bio && <p className="mt-3 text-pretty text-[15px] leading-relaxed text-[color-mix(in_oklab,var(--foreground)_84%,var(--background))]">{a.bio}</p>}
                {a.links && a.links.length > 0 && (
                  <ul className="-ml-2 mt-3 flex flex-wrap gap-1">
                    {a.links.map((l) => (
                      <li key={l.label}>
                        <a href={l.href} className="group/al inline-flex min-h-10 items-center gap-1 rounded-md px-2 text-[14px] font-medium text-foreground transition-colors hover:bg-accent">
                          {l.label}
                          <Icon name="arrow-up-right" size={14} className="text-muted-foreground transition-transform duration-300 group-hover/al:-translate-y-0.5 group-hover/al:translate-x-0.5" />
                        </a>
                      </li>
                    ))}
                  </ul>
                )}
              </div>
            </div>
          </Reveal>
        )}
        <Reveal delay={80} className="grid grid-cols-[minmax(0,1fr)] gap-4 sm:grid-cols-2">
          <div className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)]">
            <p className="mb-3 text-[14px] font-semibold text-foreground">{t.shareArticle}</p>
            <ul className="flex flex-wrap gap-2">
              {ways.map((w) => (
                <li key={w.id}>
                  <WayButton way={w} active={w.id === 'qr' && qr} />
                </li>
              ))}
            </ul>
            <QrPanel open={qr} onClose={() => setQr(false)} t={t} />
          </div>
          <div className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)]">
            <p className="mb-3 text-[14px] font-semibold text-foreground">{t.helpful}</p>
            {vote ? (
              <p role="status" className="kit-rise flex gap-2.5 text-pretty text-[14px] leading-relaxed text-muted-foreground">
                <Icon name="check-circle" size={18} className="mt-0.5 shrink-0 text-success" />
                {t.thanks}
              </p>
            ) : (
              <div className="flex gap-2">
                {(['yes', 'no'] as const).map((v) => (
                  <Button
                    key={v}
                    variant="outline"
                    iconLeft={v === 'yes' ? 'thumbs-up' : 'thumbs-down'}
                    onClick={() => {
                      setVote(v);
                      p.onShare?.(`helpful-${v}`);
                    }}
                    className="flex-1"
                  >
                    {t[v]}
                  </Button>
                ))}
              </div>
            )}
          </div>
        </Reveal>
      </div>
      <Status copied={share.copied} t={t} />
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* quote-select                                                        */
/* ------------------------------------------------------------------ */

function QuoteSelect(p: ShareProps & { t: Texts }) {
  const { t } = p;
  const share = useShare(p);
  const boxRef = useRef<HTMLDivElement>(null);
  const [bubble, setBubble] = useState<{ x: number; y: number; text: string } | null>(null);
  const tipId = useId();

  // A bubble above the selection, inside the article only
  useEffect(() => {
    const onUp = () => {
      window.setTimeout(() => {
        const sel = window.getSelection();
        const box = boxRef.current;
        const text = sel?.toString().trim() ?? '';
        if (!sel || !box || !text || text.length < 12 || !sel.rangeCount || !box.contains(sel.anchorNode)) {
          setBubble(null);
          return;
        }
        const r = sel.getRangeAt(0).getBoundingClientRect();
        const b = box.getBoundingClientRect();
        const x = Math.min(Math.max(r.left + r.width / 2 - b.left, 110), b.width - 110);
        setBubble({ x: Math.round(x), y: Math.round(r.top - b.top), text });
      }, 0);
    };
    const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setBubble(null);
    document.addEventListener('pointerup', onUp);
    document.addEventListener('keyup', onUp);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('pointerup', onUp);
      document.removeEventListener('keyup', onUp);
      document.removeEventListener('keydown', onKey);
    };
  }, []);

  const shareQuote = (text: string) => {
    const d = share.data();
    p.onShare?.('quote');
    if (navigator.share) navigator.share({ url: d.url, title: d.title, text: `${t.qo}${text}${t.qc}` }).catch(() => {});
    else share.copy(`${t.qo}${text}${t.qc} ${d.url}`, 'quote');
  };
  const quotes = p.quotes ?? [];

  return (
    <Section spacing="sm" className={p.className}>
      <div ref={boxRef} className="relative mx-auto max-w-[44rem]">
        {p.heading && <h2 className="mb-6 text-balance text-h2 font-semibold text-foreground sm:mb-2">{p.heading}</h2>}
        <p className="mb-8 inline-flex items-center gap-2 text-[13px] text-muted-foreground max-sm:hidden">
          <Icon name="type" size={14} />
          {t.selectHint}
        </p>
        <div className="space-y-6 text-pretty text-[17px] leading-[1.75] text-[color-mix(in_oklab,var(--foreground)_84%,var(--background))] selection:bg-[color-mix(in_oklab,var(--primary)_22%,transparent)]">
          {(p.paragraphs ?? []).map((x, i) => (
            <div key={i}>
              <p>{x}</p>
              {quotes[i] && (
                <Reveal className="mt-6">
                  <figure className="group/q relative">
                    <blockquote className="border-l-2 border-primary pl-5 text-[21px] font-medium leading-snug tracking-[-0.015em] text-foreground sm:pl-6 sm:text-[24px]">{t.qo}{quotes[i]}{t.qc}</blockquote>
                    <div className="mt-3 flex gap-1 pl-[14px] sm:pl-[18px]">
                      <button type="button" onClick={() => shareQuote(quotes[i])} className="inline-flex min-h-10 items-center gap-1.5 rounded-md px-2 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground max-sm:min-h-11">
                        <Icon name="share-out" size={14} />
                        {t.shareQuote}
                      </button>
                      <button type="button" onClick={() => share.copy(`${t.qo}${quotes[i]}${t.qc}`, 'quote')} className="inline-flex min-h-10 items-center gap-1.5 rounded-md px-2 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground max-sm:min-h-11">
                        <Icon name={share.copied === 'quote' ? 'check' : 'copy'} size={14} />
                        {share.copied === 'quote' ? t.quoteCopied : t.copyQuote}
                      </button>
                    </div>
                  </figure>
                </Reveal>
              )}
            </div>
          ))}
        </div>
        {bubble && (
          <div
            role="toolbar"
            aria-describedby={tipId}
            className="absolute z-30 flex -translate-x-1/2 -translate-y-[calc(100%+10px)] items-center gap-0.5 rounded-xl border border-border bg-popover p-1 shadow-[var(--highlight),var(--shadow-lg)]"
            style={{ left: bubble.x, top: bubble.y, animation: prefersReducedMotion() ? undefined : 'kit-rise .35s var(--ease-kit) both' }}
            onPointerUp={(e) => e.stopPropagation()}
          >
            <span id={tipId} className="sr-only">
              {t.selectHint}
            </span>
            <button type="button" onClick={() => shareQuote(bubble.text)} className="inline-flex h-9 items-center gap-1.5 rounded-lg px-2.5 text-[13px] font-medium text-foreground transition-colors hover:bg-accent">
              <Icon name="share-out" size={14} />
              {t.shareQuote}
            </button>
            <button
              type="button"
              onClick={() => {
                share.copy(`${t.qo}${bubble.text}${t.qc}`, 'quote');
                setBubble(null);
              }}
              className="inline-flex h-9 items-center gap-1.5 rounded-lg px-2.5 text-[13px] font-medium text-foreground transition-colors hover:bg-accent"
            >
              <Icon name="copy" size={14} />
              {t.copyQuote}
            </button>
            <span aria-hidden className="absolute -bottom-1.5 left-1/2 size-3 -translate-x-1/2 rotate-45 border-b border-r border-border bg-popover" />
          </div>
        )}
      </div>
      <Status copied={share.copied} t={t} />
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* Share                                                               */
/* ------------------------------------------------------------------ */

const VARIANTS: Record<ShareVariant, (p: ShareProps & { t: Texts }) => React.ReactNode> = {
  'inline-row': InlineRow,
  'side-rail': SideRail,
  'article-end': ArticleEnd,
  'quote-select': QuoteSelect,
};

/**
 * Share a page by link, email, message, the device share sheet or a QR code.
 *   <Share variant="inline-row" title="…" text="…" />
 *   <Share variant="article-end" author={{ name, role, bio }} />
 */
export function Share({ variant = 'inline-row', labels, ...props }: ShareProps) {
  const t = useTexts(TEXTS, labels);
  const View = VARIANTS[variant] ?? InlineRow;
  return <View {...props} t={t} />;
}

Häufige Fragen

Wie installiere ich Teilen?

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

Welche Varianten hat Teilen?

4 Varianten, In einer Reihe, Seitenleiste, Artikelende und Zitat teilen. Du wählst sie mit der Prop variant, zum Beispiel variant="side-rail".

Kann ich die Texte von Teilen übersetzen?

Ja. Teilen bringt 22 eigene Texte auf Deutsch und Englisch mit, etwa „Teilen“, „Artikel teilen“ und „Link kopieren“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Teilen einbauen?

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

Kann ich Teilen ohne Code anpassen?

Ja, im Studio. 3 von 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Share-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert