Skip to content

Before you use this component

  • Install bits-ui@^2.19.3.
  • Requires client-side JavaScript to work.
Palette Default
Download ZIP

Default palette · 11.3 KB ZIP File receipt View as Markdown View code

Preview

Demonstration — no data is sent

Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.

Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
cmp_share_button_01 · version 1.0.0 · Default palette
Using the PageSugar MCP server, fetch component cmp_share_button_01 version 1.0.0 with variant "default", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.

Not connected yet? Set up the MCP server

Code

Palette
Default
Version
1.0.0
Digest
Full digest
sha256-cc51b893571fce19bc35f9e61062003c89c7538662a13f9ae4e70fcf481e1d28
ShareButton.svelte Svelte · 22.7 KB Raw
<!--
	A share control. On click it hands the page to the device's share sheet (navigator.share) when
	that is available, and otherwise opens a small non-modal panel: the page being shared, its link
	in a read-only field joined to a Copy button that confirms in place, and an optional list of
	plain share links the consumer builds (email, a network's intent URL). It loads no third-party
	script, sends nothing, and treats a cancelled share sheet as a non-event.
-->
<script lang="ts" module>
	export type ShareMethod = 'native' | 'copy' | 'link';

	/** One fallback destination: a plain link the consumer builds, such as mailto: or an intent URL. */
	export interface ShareTarget {
		/** Names the destination: "Share by email", "Post to Bluesky". */
		label: string;
		/** mailto:, sms: or an https:// intent URL. https links open in a new tab. */
		href: string;
	}

	/** What each attempt reports. Nothing is sent anywhere; this is for your own analytics. */
	export interface ShareResult {
		method: ShareMethod;
		ok: boolean;
	}

	/** The panel's own words, for other languages. */
	export interface ShareButtonMessages {
		/** Copy button. */
		copy: string;
		/** Copy button once the link is on the clipboard. */
		copied: string;
		/** Accessible name of the link field. */
		linkLabel: string;
		/** Announced politely after a successful copy. */
		copiedAnnouncement: string;
		/** Shown and announced when the clipboard refuses; the link is left selected. */
		copyFailed: string;
		/** Read after each link that opens in a new tab. */
		newTab: string;
		/** Accessible name of the panel's close button. */
		close: string;
	}

	export const defaultMessages: ShareButtonMessages = {
		copy: 'Copy',
		copied: 'Copied',
		linkLabel: 'Link to this page',
		copiedAnnouncement: 'Link copied to the clipboard',
		copyFailed:
			'Could not copy automatically. The link is selected: choose Copy from its menu, or press Ctrl+C or ⌘C.',
		newTab: 'opens in a new tab',
		close: 'Close'
	};

	/** How long "Copied" holds before the button returns to "Copy". */
	const COPIED_MS = 2000;

	type Mark = 'mail' | 'message' | 'web';

	/** The leading mark for a destination row, read from the link's scheme. */
	function markFor(href: string): Mark {
		if (/^mailto:/i.test(href)) return 'mail';
		if (/^(sms|smsto):/i.test(href)) return 'message';
		return 'web';
	}

	/** Web links leave for another site, so they open in a new tab; mailto: and sms: hand off in place. */
	function opensNewTab(href: string): boolean {
		return /^https?:/i.test(href);
	}
</script>

<script lang="ts">
	import { Popover } from 'bits-ui';

	interface Props {
		/** The URL to share, exactly as it should be copied. */
		url: string;
		/** The page's title, passed to the share sheet and set at the head of the panel. */
		title: string;
		/** Optional text the share sheet adds alongside the link. */
		text?: string;
		/** Trigger label, and the eyebrow above the title in the panel. */
		label?: string;
		/** Fallback destinations, shown under the link. Built by you; nothing is loaded. */
		targets?: ShareTarget[];
		/** Use the device's share sheet when it is available. False always opens the panel. */
		preferNative?: boolean;
		/** Show only the icon; the label becomes the accessible name. */
		iconOnly?: boolean;
		/** Which edge of the trigger the panel lines up with: its start, or its end near a page edge. */
		align?: 'start' | 'end';
		/** A hairline secondary trigger, or a filled one in the accent. */
		variant?: 'secondary' | 'primary';
		/** The panel's own words. Partial: anything left out keeps its English default. */
		messages?: Partial<ShareButtonMessages>;
		/** Reports each attempt: the share sheet, a copy or a destination link. */
		onShare?: (result: ShareResult) => void;
		/** Extra classes on the outer wrapper, for placement. */
		class?: string;
	}

	let {
		url,
		title,
		text,
		label = 'Share',
		targets = [],
		preferNative = true,
		iconOnly = false,
		align = 'start',
		variant = 'secondary',
		messages,
		onShare,
		class: className
	}: Props = $props();

	const uid = $props.id();
	const m = $derived({ ...defaultMessages, ...messages });

	let root = $state<HTMLElement | null>(null);
	let trigger = $state<HTMLButtonElement | null>(null);
	let field = $state<HTMLInputElement | null>(null);
	let copyButton = $state<HTMLButtonElement | null>(null);

	let open = $state(false);
	/* While the share sheet is up, further presses are ignored (a second share() would throw). */
	let sharing = $state(false);
	let copyState = $state<'idle' | 'copied' | 'failed'>('idle');
	let announcement = $state('');
	let direction = $state<'ltr' | 'rtl'>('ltr');

	/*
	 * The server renders the trigger alone. Once mounted, the trigger only claims a popup when the
	 * panel is what a press will open: the share sheet is not ours to describe. If the sheet ever
	 * hands over to the panel, the claim stays from then on.
	 */
	let mounted = $state(false);
	let nativeLikely = $state(false);
	let usedPanel = $state(false);
	const claimsPopup = $derived(mounted && (!nativeLikely || usedPanel || open));

	$effect(() => {
		mounted = true;
		nativeLikely = preferNative && typeof navigator.share === 'function';
	});

	let resetTimer: ReturnType<typeof setTimeout> | undefined;
	/* Each copy, close and teardown bumps this, so a clipboard that answers late changes nothing. */
	let copyRun = 0;
	$effect(() => () => {
		copyRun += 1;
		clearTimeout(resetTimer);
	});

	function shareData(): ShareData {
		return text ? { url, title, text } : { url, title };
	}

	/** Whether this press should go to the share sheet, checked at the moment of the press. */
	function nativeAvailable(data: ShareData): boolean {
		if (!preferNative || typeof navigator === 'undefined') return false;
		if (typeof navigator.share !== 'function') return false;
		return typeof navigator.canShare === 'function' ? navigator.canShare(data) : true;
	}

	/* The panel lines up with a logical edge of the trigger, so it needs the page's direction. */
	function preparePanel() {
		if (root) direction = getComputedStyle(root).direction === 'rtl' ? 'rtl' : 'ltr';
		usedPanel = true;
	}

	function openPanel() {
		preparePanel();
		open = true;
	}

	/*
	 * Runs before Bits UI's own trigger handler. Going native, it prevents the default so the
	 * panel stays shut; share() is called inside the press so it keeps the user activation.
	 */
	function activate(event: Event) {
		// A sheet is already up: a second share() would throw, and the panel must not open under it.
		if (sharing) {
			event.preventDefault();
			return;
		}
		if (open) return;
		const data = shareData();
		if (!nativeAvailable(data)) {
			preparePanel();
			return;
		}
		event.preventDefault();
		sharing = true;
		navigator
			.share(data)
			.then(() => onShare?.({ method: 'native', ok: true }))
			.catch((error: unknown) => {
				// DOMException is not an Error subclass in every engine, so read the name directly.
				const name = (error as { name?: unknown } | null)?.name;
				onShare?.({ method: 'native', ok: false });
				// Cancelling the sheet is a choice, not a failure: nothing is shown.
				if (name === 'AbortError') return;
				// Refused (no activation, blocked in a frame, unsupported data): offer the panel.
				openPanel();
			})
			.finally(() => (sharing = false));
	}

	function onTriggerKeydown(event: KeyboardEvent) {
		if (event.key !== 'Enter' && event.key !== ' ') return;
		if (event.repeat) {
			event.preventDefault();
			return;
		}
		activate(event);
	}

	/* Closing resets the panel, so it always reopens on "Copy" with nothing announced. */
	function resetPanel() {
		copyRun += 1;
		clearTimeout(resetTimer);
		copyState = 'idle';
		announcement = '';
	}

	function onOpenChange(next: boolean) {
		if (!next) resetPanel();
	}

	function closePanel() {
		open = false;
		resetPanel();
	}

	/*
	 * Focus goes back to the trigger whenever it would otherwise be lost (Escape, the close button,
	 * a followed link), however the panel was opened. A press outside that focused something else
	 * keeps that focus.
	 */
	function onCloseAutoFocus(event: Event) {
		event.preventDefault();
		const active = document.activeElement;
		const lost = !active || active === document.body || Boolean(root?.contains(active));
		if (lost) trigger?.focus({ preventScroll: true });
	}

	/* Focus lands on Copy, the action most people opened the panel for. */
	function onOpenAutoFocus(event: Event) {
		if (!copyButton) return;
		event.preventDefault();
		copyButton.focus({ preventScroll: true });
	}

	/** The pre-Clipboard-API path, for insecure pages and older WebViews. */
	function legacyCopy(): boolean {
		if (!field) return false;
		field.select();
		try {
			return document.execCommand('copy');
		} catch {
			return false;
		}
	}

	async function copy() {
		const run = ++copyRun;
		clearTimeout(resetTimer);
		announcement = '';
		let ok = false;
		try {
			if (navigator.clipboard?.writeText) {
				await navigator.clipboard.writeText(url);
				ok = true;
			}
		} catch {
			ok = false;
		}
		if (run !== copyRun || !open) return;
		if (!ok) ok = legacyCopy();
		copyState = ok ? 'copied' : 'failed';
		announcement = ok ? m.copiedAnnouncement : m.copyFailed;
		onShare?.({ method: 'copy', ok });
		if (ok) {
			window.getSelection()?.removeAllRanges();
			resetTimer = setTimeout(() => {
				copyState = 'idle';
				announcement = '';
			}, COPIED_MS);
		} else if (field) {
			// Leave the link selected in its field so Ctrl+C or a long press finishes the job.
			field.focus();
			field.select();
		}
	}

	function followTarget() {
		onShare?.({ method: 'link', ok: true });
		closePanel();
	}

	/* 36 px under a mouse, 44 px for a finger; the label never wraps. */
	const triggerClass = [
		'share-button__trigger inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg text-sm leading-5 font-medium whitespace-nowrap select-none',
		'bg-(--_fill) text-(--_label) shadow-(--_edge) hover:bg-(--_fill-hover) data-[state=open]:bg-(--_fill-hover) data-sharing:bg-(--_fill-hover)',
		'transition-[color,background-color,box-shadow,scale] duration-150 ease-(--_ease) active:scale-[.98] active:duration-[80ms] motion-reduce:transition-[color,background-color,box-shadow] motion-reduce:active:scale-100',
		'outline-offset-2 outline-(--_accent) focus-visible:outline-2'
	];

	/* Opening settles in over 200 ms from the trigger; closing is quicker, on the exit curve. */
	const panelClass =
		'share-button__panel z-50 w-80 max-w-[calc(100vw-2rem)] origin-(--bits-popover-content-transform-origin) rounded-2xl bg-(--_surface) p-2 text-(--_ink) shadow-(--_shadow-panel) outline-none transition-[opacity,scale] duration-200 ease-[cubic-bezier(.16,1,.3,1)] data-[ending-style]:scale-[.98] data-[ending-style]:opacity-0 data-[ending-style]:duration-150 data-[ending-style]:ease-[cubic-bezier(.4,0,1,1)] data-[starting-style]:scale-[.96] data-[starting-style]:opacity-0 motion-reduce:data-[ending-style]:scale-100 motion-reduce:data-[starting-style]:scale-100';

	const rowClass =
		'flex h-10 items-center gap-3 rounded-lg px-2 text-sm leading-5 font-medium text-(--_ink) transition-colors duration-150 ease-(--_ease) hover:bg-(--_row-hover) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent) active:bg-(--_row-pressed) active:duration-[80ms] pointer-coarse:h-11';
</script>

{#snippet shareIcon()}
	<svg class="size-4 shrink-0" viewBox="0 0 16 16" fill="none" aria-hidden="true">
		<path
			d="M8 10V2M5 5l3-3 3 3M3 8v4.5A1.5 1.5 0 0 0 4.5 14h7a1.5 1.5 0 0 0 1.5-1.5V8"
			stroke="currentColor"
			stroke-width="1.75"
			stroke-linecap="round"
			stroke-linejoin="round"
		/>
	</svg>
{/snippet}

{#snippet markIcon(mark: Mark)}
	<svg class="size-4 shrink-0 text-(--_muted)" viewBox="0 0 16 16" fill="none" aria-hidden="true">
		{#if mark === 'mail'}
			<path
				d="M3 3.5h10A1.5 1.5 0 0 1 14.5 5v6a1.5 1.5 0 0 1-1.5 1.5H3A1.5 1.5 0 0 1 1.5 11V5A1.5 1.5 0 0 1 3 3.5ZM2 4.5 8 9l6-4.5"
				stroke="currentColor"
				stroke-width="1.75"
				stroke-linecap="round"
				stroke-linejoin="round"
			/>
		{:else if mark === 'message'}
			<path
				d="M3.5 2.5h9A1.5 1.5 0 0 1 14 4v6a1.5 1.5 0 0 1-1.5 1.5H7L4 14v-2.5h-.5A1.5 1.5 0 0 1 2 10V4a1.5 1.5 0 0 1 1.5-1.5Z"
				stroke="currentColor"
				stroke-width="1.75"
				stroke-linecap="round"
				stroke-linejoin="round"
			/>
		{:else}
			<path
				d="M8 14.5A6.5 6.5 0 1 0 8 1.5a6.5 6.5 0 0 0 0 13ZM1.5 8h13M8 1.5c-1.75 1.75-2.5 4-2.5 6.5s.75 4.75 2.5 6.5c1.75-1.75 2.5-4 2.5-6.5S9.75 3.25 8 1.5Z"
				stroke="currentColor"
				stroke-width="1.75"
				stroke-linecap="round"
				stroke-linejoin="round"
			/>
		{/if}
	</svg>
{/snippet}

<span
	bind:this={root}
	class={['share-button inline-flex shrink-0 align-middle', className]}
	data-variant={variant}
>
	<Popover.Root bind:open {onOpenChange}>
		<Popover.Trigger onclick={activate} onkeydown={onTriggerKeydown}>
			{#snippet child({ props })}
				{@const {
					'aria-haspopup': haspopup,
					'aria-expanded': expanded,
					'aria-controls': controls,
					...base
				} = props}
				<button
					{...base}
					bind:this={trigger}
					aria-haspopup={claimsPopup ? (haspopup as 'dialog') : undefined}
					aria-expanded={claimsPopup ? (expanded as 'true' | 'false') : undefined}
					aria-controls={claimsPopup ? (controls as string | undefined) : undefined}
					aria-label={iconOnly ? label : undefined}
					data-sharing={sharing ? '' : undefined}
					class={[
						triggerClass,
						iconOnly ? 'size-9 pointer-coarse:size-11' : 'h-9 px-3 pointer-coarse:h-11'
					]}
				>
					{@render shareIcon()}
					{#if !iconOnly}<span>{label}</span>{/if}
				</button>
			{/snippet}
		</Popover.Trigger>
		<Popover.Content
			side="bottom"
			{align}
			sideOffset={8}
			collisionPadding={16}
			dir={direction}
			trapFocus={false}
			{onOpenAutoFocus}
			{onCloseAutoFocus}
		>
			{#snippet child({ props, wrapperProps })}
				<div {...wrapperProps}>
					<div
						{...props}
						role="dialog"
						aria-modal="false"
						aria-labelledby="{uid}-eyebrow {uid}-title"
						class={panelClass}
					>
						<div class="flex items-start gap-2 ps-2 pt-2 pb-3">
							<div class="min-w-0 flex-1">
								<p
									id="{uid}-eyebrow"
									class="text-[11px] leading-4 font-medium tracking-[0.06em] text-(--_muted) uppercase rtl:tracking-normal rtl:normal-case"
								>
									{label}
								</p>
								<p
									id="{uid}-title"
									class="share-button__title mt-1 line-clamp-2 text-sm leading-5 font-semibold tracking-[-0.006em] text-pretty break-words text-(--_ink) rtl:tracking-normal"
								>
									{title}
								</p>
							</div>
							<!-- One eyebrow-plus-title tall at most; it sits level with the eyebrow. -->
							<Popover.Close>
								{#snippet child({ props: closeProps })}
									<button
										{...closeProps}
										aria-label={m.close}
										class="-me-1 -mt-1 inline-grid size-8 shrink-0 cursor-pointer place-items-center rounded-lg text-(--_muted) transition-colors duration-150 ease-(--_ease) hover:bg-(--_row-hover) hover:text-(--_ink) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent) active:bg-(--_row-pressed) active:duration-[80ms] pointer-coarse:size-11"
									>
										<svg class="size-4" viewBox="0 0 16 16" fill="none" aria-hidden="true">
											<path
												d="M4 4l8 8M12 4l-8 8"
												stroke="currentColor"
												stroke-width="1.75"
												stroke-linecap="round"
											/>
										</svg>
									</button>
								{/snippet}
							</Popover.Close>
						</div>

						<!-- The link and its Copy button are one control: one boundary, one baseline. -->
						<div
							class="share-button__field flex h-11 items-center gap-2 rounded-lg bg-(--_surface) ps-3 pe-1 ring-1 ring-(--_field) ring-inset pointer-coarse:h-13"
						>
							<input
								bind:this={field}
								id="{uid}-link"
								type="text"
								readonly
								dir="ltr"
								value={url}
								aria-label={m.linkLabel}
								aria-describedby={copyState === 'failed' ? `${uid}-failed` : undefined}
								class="min-w-0 flex-1 self-stretch truncate bg-transparent text-sm leading-5 text-(--_muted) outline-none selection:bg-(--_selection)"
								onfocus={(event) => event.currentTarget.select()}
							/>
							<button
								bind:this={copyButton}
								type="button"
								class="share-button__copy grid h-9 shrink-0 cursor-pointer rounded-sm bg-(--_accent) px-3 text-sm leading-5 font-medium text-(--_on-accent) transition-[background-color,scale] duration-150 ease-(--_ease) hover:bg-(--_accent-hover) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent) active:scale-[.98] active:duration-[80ms] motion-reduce:transition-colors motion-reduce:active:scale-100 pointer-coarse:h-11"
								onclick={copy}
							>
								<!-- Both labels share one cell, so the button is as wide as the longer one. -->
								<span
									class={[
										'col-start-1 row-start-1 inline-flex items-center justify-center gap-2 transition-opacity duration-150 ease-(--_ease)',
										copyState === 'copied' && 'opacity-0'
									]}
									aria-hidden={copyState === 'copied' ? 'true' : undefined}
								>
									<svg class="size-4 shrink-0" viewBox="0 0 16 16" fill="none" aria-hidden="true">
										<path
											d="M5.5 5.5V3.5A1.5 1.5 0 0 1 7 2h5.5A1.5 1.5 0 0 1 14 3.5V9a1.5 1.5 0 0 1-1.5 1.5h-2M3.5 5.5H9A1.5 1.5 0 0 1 10.5 7v5.5A1.5 1.5 0 0 1 9 14H3.5A1.5 1.5 0 0 1 2 12.5V7a1.5 1.5 0 0 1 1.5-1.5Z"
											stroke="currentColor"
											stroke-width="1.75"
											stroke-linejoin="round"
										/>
									</svg>
									{m.copy}
								</span>
								<span
									class={[
										'col-start-1 row-start-1 inline-flex items-center justify-center gap-2 transition-opacity duration-150 ease-(--_ease)',
										copyState !== 'copied' && 'opacity-0'
									]}
									aria-hidden={copyState === 'copied' ? undefined : 'true'}
								>
									<svg class="size-4 shrink-0" viewBox="0 0 16 16" fill="none" aria-hidden="true">
										<path
											d="M3 8.5l3 3 7-7"
											stroke="currentColor"
											stroke-width="1.75"
											stroke-linecap="round"
											stroke-linejoin="round"
										/>
									</svg>
									{m.copied}
								</span>
							</button>
						</div>

						{#if copyState === 'failed'}
							<p
								id="{uid}-failed"
								class="mt-2 flex gap-2 px-2 text-xs leading-4 text-pretty text-(--_danger)"
							>
								<span class="flex h-4 shrink-0 items-center" aria-hidden="true">
									<svg class="size-3" viewBox="0 0 12 12" fill="none">
										<circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.5" />
										<path
											d="M6 3.5v3M6 8.5v.01"
											stroke="currentColor"
											stroke-width="1.5"
											stroke-linecap="round"
										/>
									</svg>
								</span>
								<span>{m.copyFailed}</span>
							</p>
						{/if}

						{#if targets.length}
							<ul class="-mx-2 mt-2 border-t border-(--_hairline) px-2 pt-2">
								{#each targets as target, index (index)}
									{@const newTab = opensNewTab(target.href)}
									<li>
										<a
											href={target.href}
											target={newTab ? '_blank' : undefined}
											rel={newTab ? 'noopener noreferrer' : undefined}
											class={rowClass}
											onclick={followTarget}
										>
											{@render markIcon(markFor(target.href))}
											<span class="min-w-0 flex-1 truncate">{target.label}</span>
											{#if newTab}
												<span class="sr-only">({m.newTab})</span>
												<svg
													class="size-4 shrink-0 text-(--_muted) rtl:-scale-x-100"
													viewBox="0 0 16 16"
													fill="none"
													aria-hidden="true"
												>
													<path
														d="M5.5 10.5l5-5M6.5 5.5h4v4"
														stroke="currentColor"
														stroke-width="1.75"
														stroke-linecap="round"
														stroke-linejoin="round"
													/>
												</svg>
											{:else}
												<!-- Holds the arrow's column, so every label truncates at the same edge. -->
												<span class="size-4 shrink-0" aria-hidden="true"></span>
											{/if}
										</a>
									</li>
								{/each}
							</ul>
						{/if}
					</div>
				</div>
			{/snippet}
		</Popover.Content>
	</Popover.Root>
	<!-- Outside the panel, so it exists before anything is announced into it. -->
	<span class="sr-only" role="status" aria-live="polite" aria-atomic="true">{announcement}</span>
</span>

<style>
	/* Public tokens: set --share-button-* on the button or any ancestor to retone it. */
	.share-button {
		--_accent: var(--share-button-accent, #18181b);
		--_on-accent: var(--share-button-on-accent, #ffffff);
		--_ink: var(--share-button-ink, #18181b);
		--_muted: var(--share-button-muted, #52525b);
		--_hairline: var(--share-button-hairline, rgb(0 0 0 / 0.08));
		--_surface: var(--share-button-surface, #ffffff);
		--_field: var(--share-button-field, #d4d4d8);
		--_danger: var(--share-button-danger, #b91c1c);

		/* Formulas: the popover elevation from DESIGN §3.5, and tints that follow any retone. */
		--_ease: cubic-bezier(0.2, 0, 0, 1);
		--_shadow-panel:
			0 0 0 1px rgb(0 0 0 / 0.05), 0 4px 6px -1px rgb(0 0 0 / 0.07),
			0 10px 15px -3px rgb(0 0 0 / 0.05);
		--_accent-hover: color-mix(in oklab, var(--_accent) 88%, var(--_on-accent));
		--_row-hover: color-mix(in oklab, var(--_ink) 5%, transparent);
		--_row-pressed: color-mix(in oklab, var(--_ink) 8%, transparent);
		--_selection: color-mix(in oklab, var(--_accent) 18%, transparent);
	}

	/* The trigger's variants fill these in; the utilities on the button only read them. */
	.share-button[data-variant='secondary'] {
		--_fill: var(--_surface);
		--_fill-hover: color-mix(in oklab, var(--_surface) 95%, var(--_ink));
		--_label: var(--_ink);
		/* Inset, so the secondary is exactly as large as a filled button beside it. */
		--_edge: inset 0 0 0 1px var(--_field);
	}

	.share-button[data-variant='primary'] {
		--_fill: var(--_accent);
		--_fill-hover: var(--_accent-hover);
		--_label: var(--_on-accent);
		--_edge: inset 0 1px 0 rgb(255 255 255 / 0.12);
	}

	/*
	 * While its panel is open, a filled trigger steps back to the hairline look, so Copy is the
	 * one filled action in view. Same box, so nothing moves.
	 */
	.share-button[data-variant='primary'] .share-button__trigger[data-state='open'] {
		--_fill: var(--_surface);
		--_fill-hover: color-mix(in oklab, var(--_surface) 95%, var(--_ink));
		--_label: var(--_ink);
		--_edge: inset 0 0 0 1px var(--_field);
	}

	/*
	 * The field's focus ring belongs to the whole joined control, not the bare input, so it is
	 * drawn on the shape when the input inside it has keyboard focus.
	 */
	.share-button__field:has(> input:focus-visible) {
		outline: 2px solid var(--_accent);
		outline-offset: 2px;
	}

	/* Japanese titles break at phrase boundaries; Korean keeps its spaced words whole. */
	.share-button__title:lang(ja) {
		word-break: auto-phrase;
	}

	.share-button__title:lang(ko) {
		word-break: keep-all;
	}
</style>

Pass the url and title to share. A press goes to the device's share sheet when navigator.share can take them, and opens the panel otherwise. It does not count shares, shorten links, add UTM parameters or build network URLs for you: targets are plain links you supply, and onShare only tells you what happened.

Suggested location
src/lib/components/share-button-01
Required props
urltitle

Limitations

  • Install bits-ui (npm install bits-ui@^2.19.3) before using the component.
  • Desktop Firefox and some desktop browsers have no share sheet, so they always get the panel. Inside an iframe the share sheet and the Clipboard API need allow="web-share; clipboard-write" on the frame; without it the press opens the panel and copying falls back to the legacy copy command.
  • Copying needs a secure page (https or localhost) for the Clipboard API; elsewhere it uses the legacy copy command and, if that is refused too, leaves the link selected with a message.
  • The server renders the trigger alone; the press does nothing until the page has hydrated.
  • The panel renders beside the trigger, not in a portal, so an ancestor with overflow: hidden and a transform can clip it.

Example

Svelte
<script lang="ts">
	import ShareButton from '$lib/components/share-button-01/ShareButton.svelte';

	const url = 'https://halcyon.app/journal/plan-a-quarter-on-one-board';
	const title = 'How we plan a quarter on one Halcyon board';
</script>

<ShareButton
	{url}
	{title}
	text="One roadmap board, three timelines and a guest seat for the client."
	targets={[
		{ label: 'Share by email', href: `mailto:?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(url)}` },
		{ label: 'Post to Bluesky', href: `https://bsky.app/intent/compose?text=${encodeURIComponent(`${title} ${url}`)}` }
	]}
	onShare={(result) => console.log(result)}
/>

The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.

Share button#

How a press is decided#

The choice happens at the moment of the press, never on the server:

  1. If preferNative is true and navigator.share exists and navigator.canShare({ url, title, text }) accepts the data, the device's share sheet opens and the panel stays shut.
    • It resolves: onShare({ method: 'native', ok: true }).
    • It is cancelled (AbortError): onShare({ method: 'native', ok: false }), nothing on screen.
    • It is refused for another reason (no user activation, blocked in a frame, data the platform will not take): onShare({ method: 'native', ok: false }) and the panel opens instead.
  2. Otherwise the panel opens below the trigger.

While a share sheet is open, further presses are ignored.

Building targets#

Targets are plain links. Encode everything you put in a query string:

TypeScript
const targets = [
	{
		label: 'Share by email',
		href: `mailto:?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(url)}`
	},
	{ label: 'Send as a text message', href: `sms:?&body=${encodeURIComponent(`${title} ${url}`)}` },
	{
		label: 'Share on WhatsApp',
		href: `https://wa.me/?text=${encodeURIComponent(`${title} ${url}`)}`
	},
	{
		label: 'Share on LinkedIn',
		href: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`
	}
];

https: links open in a new tab with rel="noopener noreferrer"; mailto: and sms: hand off in place. The row's mark is read from the scheme: an envelope, a speech bubble or a globe.

Copying#

Copy tries navigator.clipboard.writeText(url) first, then the legacy copy command. If both are refused, the panel says so and leaves the link selected in its field so Ctrl+C or a long press finishes the job. "Copied" holds for two seconds and a second press restarts the timer.

Inside an iframe#

Embedded pages need the frame to delegate both features, or the press always opens the panel and copying falls back to the legacy command:

HTML
<iframe src="…" allow="web-share; clipboard-write"></iframe>

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
urlstringYesNoneThe URL to share, passed to the share sheet and copied exactly as given. Pass the canonical URL, not location.href with its query and fragment.
titlestringYesNoneThe page's title. Passed to the share sheet and set at the head of the panel, clamped to two lines.
textstringNoNoneOptional text the share sheet adds alongside the link. Not shown in the panel.
labelstringNo'Share'Trigger label and the small eyebrow above the title in the panel. The accessible name when iconOnly is set.
targets{ label: string; href: string }[]No[]Destination links under the copy row, built by you: mailto:, sms: or a network's https intent URL. https links open in a new tab and say so; each row's mark comes from the scheme. Empty, the panel holds the link alone.
preferNativebooleanNotrueUse navigator.share when it is available and navigator.canShare accepts the data. False always opens the panel.
iconOnlybooleanNofalseShow only the share icon in a 36 px square (44 px on touch screens); label becomes its accessible name.
align'start' | 'end'No'start'Which edge of the trigger the panel lines up with, in the reading direction. Use end when the button sits at the inline end of a row.
variant'secondary' | 'primary'No'secondary'A hairline trigger on the surface colour, or a filled one in the accent for a page where sharing is the main action.
messagesPartial<ShareButtonMessages>NoNoneThe panel's own words for other languages: copy, copied, linkLabel, copiedAnnouncement, copyFailed, newTab and close. Anything left out keeps its English default.
onShare(result: { method: 'native' | 'copy' | 'link'; ok: boolean }) => voidNoNoneCalled after each attempt: the share sheet resolving (ok true) or being cancelled or refused (ok false), a copy, or a destination link being followed. It sends nothing.
classstringNoNoneExtra classes on the outer wrapper, for placement such as margins.

Customization#

On this page

Content comes through url, title, label, targets and messages; retone through eight --share-button-* variables on the button or any ancestor. The accent fills the Copy button (and a primary trigger) and draws every focus ring; the panel and a secondary trigger sit on the surface colour.

  • Content: build each target's href yourself with encodeURIComponent, for example mailto:?subject=…&body=… or https://wa.me/?text=…. Name the destination in the label ("Share by email"), not just the network.
  • Placement: set align="end" when the button sits at the end of a byline or toolbar so the panel opens back across the row rather than off the page edge.
  • Trigger: variant="primary" fills it with the accent for an event or product page where sharing is the main action; iconOnly suits cards and dense toolbars.
  • Colours: --share-button-accent and --share-button-on-accent are the Copy button and primary trigger; --share-button-surface is the panel and the secondary trigger; --share-button-field is the link field's and secondary trigger's boundary; --share-button-hairline is the rule above the destinations; --share-button-danger is the refused-copy message.
  • Dark page retone: --share-button-surface: #18181b; --share-button-ink: #fafafa; --share-button-muted: #a1a1aa; --share-button-field: rgb(255 255 255 / 0.16); --share-button-hairline: rgb(255 255 255 / 0.1); --share-button-accent: #fafafa; --share-button-on-accent: #18181b; --share-button-danger: #fca5a5.
  • Languages: pass messages with every panel string translated, and set dir and lang on the page; the panel lines up with the trigger's logical edge and the new-tab arrow flips.
  • Behaviour: preferNative={false} always shows the panel, for example on a desktop-only tool where people expect to copy the link.
  • Motion: the panel opens from the trigger over 200 ms and closes in 150 ms; Copied holds for two seconds. Under reduced motion the panel only fades.

Public CSS variables

VariableToken
--share-button-accentaccent
--share-button-on-accentonAccent
--share-button-inkink
--share-button-mutedmuted
--share-button-hairlinehairline
--share-button-surfacesurface
--share-button-fieldfield
--share-button-dangerdanger

Dependencies and services#

On this page
PackageRangeResolved at build timePurpose
bits-ui^2.19.32.19.3Headless Popover primitive for the fallback panel: positioning against the trigger with collision handling, Escape and outside-press dismissal, focus return to the trigger, and presence for the open and close transitions.

Install with (shown for reference, run it yourself)

npm install bits-ui@^2.19.3

Accessibility#

On this page
  • The trigger is a real button. Once the page has hydrated and the panel is what a press will open, it carries aria-haspopup="dialog", aria-expanded and aria-controls; where the device's share sheet will open instead, it is a plain button, because that sheet is not part of the page.
  • The panel is a non-modal dialog (role=dialog, aria-modal=false) named by its eyebrow and the page title. Focus moves to Copy when it opens; Tab can leave it; Escape, the close button and a press outside close it and return focus to the trigger.
  • The link sits in a read-only field with its own accessible name; focusing it selects the whole URL.
  • The copy result is announced through a polite live region that exists before anything is copied. A refused copy is also shown in text with a mark, tied to the field with aria-describedby, and the link is left selected.
  • Destination links that open a new tab carry visually hidden text saying so, and a visible arrow; mailto: and sms: links hand off in place.
  • Controls are 36 px under a mouse and 44 px on touch screens; every focus ring is the accent at two pixels with a two-pixel offset.
  • Consumer responsibilities: give each target a label that names the destination, translate messages for non-English pages, and keep the url canonical.

Known limitations

  • Screen readers announce the share sheet itself according to the platform; the component announces nothing for it, success or cancel.
  • When a share sheet refuses the data for a reason other than cancelling, the panel opens instead; some platforms report that only after their own message.

Release details#

On this page
Integration
  • Local interaction
  • Requires client-side JavaScript to be interactive
  • Server-side rendering supported
License

MIT. Default license approval is pending; see the license status before adopting the source.

Version history
  • 1.0.0 (Published) Current release · 22 October 2026

Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.