Skip to content
UI Kit
Accent color

Share

Share a page by link, email, message, the device share sheet or a QR code. No foreign logos, your own channels as URL templates.

One of 3 components in Blog

4 variants

Variants

Inline row

Round buttons under the article header, a tooltip rises on hover. Copy confirms with a check, the QR code opens in place.

Side rail

A rail next to the text with a ring that fills while reading. On phones and tablets the same ways sit at the end of the article.

Open in Studio

Article end

The author with a short bio, the ways to share and a quick question whether the article helped.

Open in Studio

Share a quote

Select a sentence and a small bar offers to share or copy it. Pull quotes carry the same two actions for touch and keyboard.

Open in Studio

Details

What Share brings along, read from the registry and the source.

Category
Blog
Variants
4
Version
1.0.1 · since 1.0.0
File
components/ui/share.tsx
Brings along
utilskitbuttoniconrevealmotionsectiongraphics
npm packages
None
Languages
English and German, 22 texts of its own

Props

The ShareProps interface from components/ui/share.tsx, with the comments from the code.

  • 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

Built-in texts

Share writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 22 texts
shareEnglish ShareGerman Teilen
shareArticleEnglish Share this articleGerman Artikel teilen
copyEnglish Copy linkGerman Link kopieren
copiedEnglish Link copiedGerman Link kopiert
emailEnglish EmailGerman E-Mail
messageEnglish MessageGerman Nachricht
nativeEnglish More optionsGerman Weitere Optionen
qrEnglish QR codeGerman QR-Code
qrHintEnglish Point your phone camera here to open this page on your phone.German Halte die Handykamera darauf, um die Seite auf dem Telefon zu öffnen.
closeEnglish CloseGerman Schließen
readEnglish {n}% readGerman {n} % gelesen
writtenByEnglish Written byGerman Geschrieben von
helpfulEnglish Was this article helpful?German War dieser Artikel hilfreich?
yesEnglish YesGerman Ja
noEnglish NoGerman Nein
thanksEnglish Thank you. Your answer helps us write the next one.German Danke. Deine Antwort hilft uns beim nächsten Artikel.
shareQuoteEnglish Share quoteGerman Zitat teilen
copyQuoteEnglish Copy quoteGerman Zitat kopieren
quoteCopiedEnglish Quote copiedGerman Zitat kopiert
selectHintEnglish Select a sentence to share itGerman Markiere einen Satz, um ihn zu teilen
qoEnglish “German „
qcEnglish ”German “

Accessibility

Read from the source of the component.

  • Share handles the keys Escape itself.
  • Sets aria-describedby and aria-label, so screen readers announce purpose and state.
  • Uses the roles img, status, toolbar and tooltip.
  • Announces changes through a live region without moving focus.
  • Adds text for screen readers where icons and graphics stand alone.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Moves with the scroll, its progress follows the position on the page.
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 2 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, 572 lines
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} />;
}

Frequent questions

How do I install Share?

With aiw add @ai-website/share. The command puts components/ui/share.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, motion, section and graphics. It needs no further npm packages.

Which variants does Share have?

4 variants, Inline row, Side rail, Article end and Share a quote. You pick one with the variant prop, for example variant="side-rail".

Can I translate the texts of Share?

Yes. Share carries 22 texts of its own in English and German, such as “Share”, “Share this article” and “Copy link”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Share?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Share with aiw_components and install it with aiw_add @ai-website/share. Name the variant in your prompt, for example variant="inline-row", and the assistant writes your content straight into the props.

Can I adjust Share without code?

Yes, in the Studio. 3 of 4 variants are editable there, texts, lists and switches. The Studio writes the matching Share code to copy.

Often searched

Jump to

Copied to the clipboard