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
Variants
Details
What Share brings along, read from the registry and the source.
- Category
- Blog
- Area
- Content
- Variants
- 4
- Version
- 1.0.1 · since 1.0.0
- File
- components/ui/share.tsx
- npm packages
- None
- Languages
- English and German, 22 texts of its own
- Studio
- 3 of 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ShareProps interface from components/ui/share.tsx, with the comments from the code.
variantoptionalShareVariantinline-rowside-railarticle-endquote-select
urloptionalstringAddress to share, default the current page
titleoptionalstringTitle of the page, default the document title
textoptionalstringA short line sent with email and message
channelsoptionalShareChannel[]Your own channels after the built-in ones
hideoptional('copy' | 'email' | 'message' | 'native' | 'qr')[]Hide built-in ways, e.g. ['qr', 'message']
authoroptionalShareAuthorarticle-end: the author
paragraphsoptionalstring[]side-rail, quote-select: the article text, one string per paragraph
quotesoptionalstring[]quote-select: sentences shown as pull quotes
headingoptionalstringside-rail, quote-select: the article heading
onShareoptional(way: string) => voidCalled 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
| Key | English | German |
|---|---|---|
| share | English Share | German Teilen |
| shareArticle | English Share this article | German Artikel teilen |
| copy | English Copy link | German Link kopieren |
| copied | English Link copied | German Link kopiert |
| English Email | German E-Mail | |
| message | English Message | German Nachricht |
| native | English More options | German Weitere Optionen |
| qr | English QR code | German QR-Code |
| qrHint | English 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. |
| close | English Close | German Schließen |
| read | English {n}% read | German {n} % gelesen |
| writtenBy | English Written by | German Geschrieben von |
| helpful | English Was this article helpful? | German War dieser Artikel hilfreich? |
| yes | English Yes | German Ja |
| no | English No | German Nein |
| thanks | English Thank you. Your answer helps us write the next one. | German Danke. Deine Antwort hilft uns beim nächsten Artikel. |
| shareQuote | English Share quote | German Zitat teilen |
| copyQuote | English Copy quote | German Zitat kopieren |
| quoteCopied | English Quote copied | German Zitat kopiert |
| selectHint | English Select a sentence to share it | German Markiere einen Satz, um ihn zu teilen |
| qo | English “ | German „ |
| qc | English ” | 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
'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.