# Print button

> A print action for pages the site has already prepared for paper. Awaits an optional beforePrint, opens the browser's print dialog, runs afterPrint on afterprint, and never appears in print output.

- ID: `cmp_print_button_01`
- Slug: `print-button-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-22T01:36:55Z
- Updated: 2026-10-22
- Available versions: `1.0.0`
- Kind: control
- Primary category: `buttons`
- Detail page: https://pagesugar.com/components/print-button-01
- Preview: https://pagesugar.com/preview/print-button-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-18858d0dfde34177a9ea89e132ad7cd0d99c204d736bd226e2ecaf6c99577f1c` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Imports: 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.
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: yes
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/print-button-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Drop it where a reader expects to print, and name what gets printed in label. Clicking awaits beforePrint (if you pass one), lets Svelte flush, opens the browser's print dialog and runs afterPrint when the afterprint event arrives. It does not lay the page out for paper: your print stylesheet does that, and print:hidden on your own chrome. It does not make PDFs, detect printers or fall back to a download.

Required props: none

```svelte
<script lang="ts">
	import PrintButton from '$lib/components/print-button-01/PrintButton.svelte';

	let methodOpen = $state(false);
	let wasOpen = false;
</script>

<PrintButton
	label="Print recipe"
	helper="Ingredients and method, on two pages"
	beforePrint={() => {
		wasOpen = methodOpen;
		methodOpen = true;
	}}
	afterPrint={() => (methodOpen = wasOpen)}
/>

<details bind:open={methodOpen}>
	<summary>Method</summary>
	<p>Brown the butter, then let it cool for ten minutes.</p>
</details>
```

Limitations:

- afterPrint depends on the browser's afterprint event. Mobile Safari and some Android browsers do not always fire it when the print sheet is dismissed, so do not rely on it for anything more than restoring view state.
- If afterprint never arrives (a dismissed mobile print sheet), the page stays prepared and afterPrint does not run until a later print fires afterprint; later clicks print without calling beforePrint again.
- If beforePrint throws or rejects, the dialog does not open and the error is rethrown from the click handler. If the component is destroyed while beforePrint runs, it does not print when beforePrint settles.
- Chromium and Firefox block inside window.print() until the dialog closes; Safari returns at once. Code after a click should not assume either.
- Where window.print does not exist the control is in the server HTML and is hidden once the page hydrates. Some in-app webviews define window.print but do nothing with it; the component cannot detect that.
- The button's accessible name is label; iconOnly also sets it as the title tooltip. A tooltip from title does not appear on touch screens or on keyboard focus.
- iconOnly with variant link renders as a ghost square, since a square link has nothing to mark it.

## Usage guide

### Print button

#### Prepare the page, not the button

The button opens the browser's print dialog for the page as it is. What lands on paper is up to
your print styles:

```svelte
<header class="print:hidden">…site navigation…</header>
<article class="print:text-[11pt]">…recipe…</article>
```

Hide navigation, banners and other controls with `print:hidden`. The print button already hides
itself, helper line included.

#### Expanding collapsed content

Collapsed `<details>` print closed. Either open them in print with CSS, or open them in
`beforePrint` and put them back in `afterPrint`:

```svelte
<script lang="ts">
	import PrintButton from '$lib/components/print-button-01/PrintButton.svelte';

	let open = $state([true, false, false]);
	let saved: boolean[] = [];
</script>

<PrintButton
	label="Print itinerary"
	beforePrint={() => {
		saved = [...open];
		open = open.map(() => true);
	}}
	afterPrint={() => (open = saved)}
/>

{#each open as _, i}
	<details bind:open={open[i]}>…</details>
{/each}
```

The component waits one tick after `beforePrint` so Svelte's update reaches the page before the
browser takes its snapshot. `afterPrint` is best effort: some mobile browsers do not fire
`afterprint` when the print sheet is dismissed, so keep it to restoring view state.

#### Where printing makes no sense

Do not render the button on pages without a print layout. Where `window.print` is missing (some
embedded browsers), the button removes itself once the page hydrates.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `label` | `string` | no | `'Print this page'` | Visible label, or the aria-label and tooltip when iconOnly. Name what gets printed: 'Print recipe', 'Print invoice HAL-2026-0142'. |
| `helper` | `string` | no |  | One short line under the button saying what lands on paper, linked with aria-describedby. Not shown when iconOnly. |
| `beforePrint` | () =\> void \| Promise\<void\> | no |  | Called on click before the dialog opens, for example to expand collapsed sections. Awaited; a promise that takes longer than 150 ms shows the preparing state. |
| `afterPrint` | `() => void` | no |  | Called once on the afterprint event that follows a print, to restore what beforePrint changed. Until it fires, further clicks print the prepared page again without calling beforePrint. The listener is removed when it fires and on destroy. |
| `variant` | 'secondary' \| 'ghost' \| 'link' | no | `'secondary'` | secondary: a hairline-edged button; ghost: no edge until hover, for toolbars and headers that already have a primary; link: an inline text action for footers. |
| `iconOnly` | `boolean` | no | `false` | Renders a 36 px square printer button (44 px on touch screens) with label as its accessible name and tooltip. |
| `preparingLabel` | `string` | no | `'Preparing'` | Shown with an ellipsis in place of the label while a slow beforePrint runs, clipped to the label's width. |
| `disabled` | `boolean` | no | `false` | Native disabled. Say nearby why printing is unavailable. |
| `class` | `string` | no |  | Extra classes on the outer element, for placement such as margins or grid placement. |

## Customization

Change the label, helper and variant through props, and retone it through five --print-button-\* CSS variables. Hover tones are mixed from the surface and ink, so they follow whatever page it sits on.

- Label: name the document, not the act. 'Print recipe' and 'Print invoice' tell a screen reader user what they will get; 'Print' alone does not.
- Helper: keep it to one short line that says what lands on paper ('One page, without the photos'). Leave it out in toolbars.
- Variant: secondary for a print action that stands on its own, ghost beside a primary button or in a toolbar, link in a footer or a line of metadata.
- Print styles: the component hides itself in print. Hide the rest of your chrome with print:hidden too, and use print:block or a @media print rule to show what should always print, such as the contents of collapsed details.
- beforePrint and afterPrint: change state through Svelte (set open on a bound details, for example); the component waits a tick after beforePrint so the change reaches the page before the dialog snapshots it.
- Accent: --print-button-accent is the focus ring. Keep it at 3:1 against the page.
- Neutrals: --print-button-ink is the label, --print-button-muted the glyph, the helper line and the resting link colour, --print-button-surface the secondary fill and --print-button-hairline its edge.
- Worked retone for a #09090b band: --print-button-accent: #fafafa; --print-button-ink: #fafafa; --print-button-muted: #a1a1aa; --print-button-surface: #18181b; --print-button-hairline: rgb(255 255 255 / 0.12).
- Cream page (#fbf7f0): --print-button-surface: #fbf7f0; --print-button-hairline: rgb(60 40 20 / 0.16); keep ink and muted dark.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--print-button-accent` |
| `ink` | `--print-button-ink` |
| `muted` | `--print-button-muted` |
| `hairline` | `--print-button-hairline` |
| `surface` | `--print-button-surface` |

## Accessibility

- A native \<button type="button"\>, so Enter and Space activate it and it never submits a surrounding form. Printing is a command, not navigation, so there is no link rendering; the link variant is a button styled as text.
- Name what gets printed in label. With iconOnly, label becomes aria-label and title, and the printer glyph is aria-hidden.
- helper is linked to the button with aria-describedby, so it is read after the name.
- While beforePrint runs the button sets aria-busy and aria-disabled and ignores clicks, without the disabled attribute, so focus stays on it. The label keeps its box and stays the accessible name while the visible text reads 'Preparing…'.
- No live announcement is made: the browser's print dialog takes focus once it opens.
- Focus is a two-pixel ring in the accent, two pixels outside the button and following its radius. Every variant is at least 44 px tall on coarse pointers.
- Under reduced motion the press does not scale and the spinner pulses instead of turning.
- The consumer is responsible for a print stylesheet that keeps the printed page readable, and for hiding other on-screen controls in print.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `PrintButton.svelte`
- Suggested directory: `src/lib/components/print-button-01`
- Files: 1
- Artifact digest: `sha256-18858d0dfde34177a9ea89e132ad7cd0d99c204d736bd226e2ecaf6c99577f1c`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `PrintButton.svelte`

Role: entry · 9338 bytes · SHA-256 `cd2926ee1f6166c57031475b2de22ce1b27a0a9546385e70570ec19ab190cf4a`

```svelte
<!--
	A print action for a page the site has already prepared for paper: a recipe, an invoice, an
	itinerary, a policy. Clicking awaits the optional beforePrint, then opens the browser's print
	dialog; afterPrint runs on the afterprint event that follows. The whole control, helper line
	included, is print:hidden, and it removes itself once hydrated in a browser with no
	window.print. Nothing touches a browser API until a click.
-->
<script lang="ts" module>
	export type PrintButtonVariant = 'secondary' | 'ghost' | 'link';
</script>

<script lang="ts">
	import { onMount, tick } from 'svelte';
	import type { HTMLButtonAttributes } from 'svelte/elements';

	type Passthrough = Omit<
		HTMLButtonAttributes,
		| 'type'
		| 'children'
		| 'onclick'
		| 'class'
		| 'disabled'
		| 'aria-busy'
		| 'aria-disabled'
		| 'aria-label'
		| 'aria-describedby'
	>;

	interface Props extends Passthrough {
		/** Name what gets printed: "Print recipe", "Print invoice". The accessible name when iconOnly. */
		label?: string;
		/** One short line under the button saying what lands on paper. Not shown when iconOnly. */
		helper?: string;
		/** Runs before the dialog opens, for example to expand collapsed sections. Awaited. */
		beforePrint?: () => void | Promise<void>;
		/** Runs on the afterprint event that follows this click, to restore what beforePrint changed. */
		afterPrint?: () => void;
		/** secondary: outlined; ghost: no edge until hover; link: inline text action. */
		variant?: PrintButtonVariant;
		/** A square printer button; label becomes its aria-label and tooltip. */
		iconOnly?: boolean;
		/** Shown in place of the label while a slow beforePrint runs. */
		preparingLabel?: string;
		/** Native disabled. Explain nearby why printing is unavailable. */
		disabled?: boolean;
		/** Extra classes for placement on the outer element (margins, grid placement). */
		class?: string;
	}

	let {
		label = 'Print this page',
		helper,
		beforePrint,
		afterPrint,
		variant = 'secondary',
		iconOnly = false,
		preparingLabel = 'Preparing',
		disabled = false,
		class: className,
		...rest
	}: Props = $props();

	const uid = $props.id();
	/* A square link would be a ghost button with no edge to show for it, so it is one. */
	const look = $derived(iconOnly && variant === 'link' ? 'ghost' : variant);
	const showHelper = $derived(Boolean(helper) && !iconOnly);

	/** True from the click until the dialog opens; clicks in between are ignored. */
	let busy = $state(false);
	/** The visible preparing state, shown only once beforePrint has run long enough to notice. */
	let preparing = $state(false);
	/** False after hydration in a browser that cannot print, such as some in-app webviews. */
	let supported = $state(true);

	let revealTimer: ReturnType<typeof setTimeout> | undefined;
	let detachAfterPrint: (() => void) | undefined;
	/** Set on destroy, so a beforePrint that settles afterwards neither prints nor listens. */
	let destroyed = false;

	onMount(() => {
		supported = typeof window.print === 'function';
		return () => {
			destroyed = true;
			clearTimeout(revealTimer);
			detachAfterPrint?.();
		};
	});

	function settle() {
		clearTimeout(revealTimer);
		busy = false;
		preparing = false;
	}

	async function handleClick(event: MouseEvent) {
		if (busy) {
			event.preventDefault();
			return;
		}
		// The page is still prepared from a click whose afterprint has not arrived (the dialog was
		// dismissed without one, as some mobile browsers do): print it again rather than running
		// beforePrint over its own changes, so afterPrint still restores the original state.
		if (detachAfterPrint) {
			window.print();
			return;
		}
		busy = true;
		try {
			const pending = beforePrint?.();
			if (pending && typeof pending.then === 'function') {
				// A quick beforePrint never flashes the spinner; a slow one shows it after 150 ms.
				revealTimer = setTimeout(() => (preparing = true), 150);
				await pending;
			}
			// Let whatever beforePrint changed reach the DOM before the browser snapshots the page.
			await tick();
		} finally {
			settle();
		}
		if (destroyed) return;

		// One afterprint listener per preparation, removed when it fires and on destroy.
		const onAfterPrint = () => {
			detachAfterPrint?.();
			afterPrint?.();
		};
		window.addEventListener('afterprint', onAfterPrint);
		detachAfterPrint = () => {
			window.removeEventListener('afterprint', onAfterPrint);
			detachAfterPrint = undefined;
		};
		window.print();
	}
</script>

<span
	class={[
		'print-button max-w-full flex-col items-start gap-2 align-middle print:hidden',
		supported ? 'inline-flex' : 'hidden',
		className
	]}
>
	<button
		{...rest}
		type="button"
		{disabled}
		aria-label={iconOnly ? label : undefined}
		title={iconOnly ? label : rest.title}
		aria-describedby={showHelper ? `${uid}-helper` : undefined}
		aria-busy={busy ? 'true' : undefined}
		aria-disabled={busy ? 'true' : undefined}
		data-variant={look}
		class={[
			'print-button__control relative inline-flex max-w-full cursor-pointer items-start text-sm leading-5 font-medium select-none',
			'text-(--_label) transition-[color,background-color,box-shadow,scale] duration-150 ease-(--_ease)',
			'outline-offset-2 outline-(--_accent) focus-visible:outline-2',
			'disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100 aria-busy:cursor-progress aria-busy:active:scale-100',
			look === 'link'
				? 'min-h-6 rounded-sm py-0.5 decoration-1 underline-offset-4 hover:text-(--_ink) hover:underline pointer-coarse:min-h-11 pointer-coarse:items-center'
				: 'min-h-9 rounded-lg bg-(--_fill) py-2 shadow-(--_edge) hover:bg-(--_fill-hover) active:scale-[.98] active:duration-[80ms] motion-reduce:active:scale-100 pointer-coarse:min-h-11 pointer-coarse:items-center',
			look !== 'link' && (iconOnly ? 'min-w-9 justify-center px-2 pointer-coarse:min-w-11' : 'px-4')
		]}
		onclick={handleClick}
	>
		<!-- One row aligned to the label's first line, so a wrapped label keeps the glyph level. -->
		<span class="flex min-w-0 items-start gap-2">
			<span
				class="grid h-lh w-4 shrink-0 place-items-center text-(--_icon) [&_svg]:size-4"
				aria-hidden="true"
			>
				{#if preparing}
					<svg
						class="animate-spin motion-reduce:animate-pulse"
						viewBox="0 0 16 16"
						fill="none"
						focusable="false"
					>
						<circle
							cx="8"
							cy="8"
							r="6.25"
							stroke="currentColor"
							stroke-opacity="0.25"
							stroke-width="1.75"
						/>
						<path
							d="M14.25 8A6.25 6.25 0 0 0 8 1.75"
							stroke="currentColor"
							stroke-width="1.75"
							stroke-linecap="round"
						/>
					</svg>
				{:else}
					<svg viewBox="0 0 16 16" fill="none" focusable="false">
						<path
							d="M4.75 6V2.75h6.5V6M4.75 11.25h-1.5a1 1 0 0 1-1-1v-3.5a1 1 0 0 1 1-1h9.5a1 1 0 0 1 1 1v3.5a1 1 0 0 1-1 1h-1.5"
							stroke="currentColor"
							stroke-width="1.75"
							stroke-linecap="round"
							stroke-linejoin="round"
						/>
						<path
							d="M4.75 9.25h6.5v4h-6.5z"
							stroke="currentColor"
							stroke-width="1.75"
							stroke-linejoin="round"
						/>
					</svg>
				{/if}
			</span>
			{#if !iconOnly}
				<!-- While preparing the label keeps its box (and the button's name) and the visible
				     text becomes preparingLabel, clipped to that box so the width never moves. -->
				<span class="relative min-w-0">
					<span class={['block text-start text-pretty break-words', preparing && 'opacity-0']}
						>{label}</span
					>
					{#if preparing}
						<span class="absolute inset-0 truncate text-start" aria-hidden="true"
							>{preparingLabel}…</span
						>
					{/if}
				</span>
			{/if}
		</span>
	</button>
	{#if showHelper}
		<span
			id="{uid}-helper"
			class="max-w-[30em] text-xs leading-4 text-pretty break-words text-(--_muted)">{helper}</span
		>
	{/if}
</span>

<style>
	/* Public tokens: set --print-button-* on the control or any ancestor to retone it. */
	.print-button {
		--_accent: var(--print-button-accent, #18181b);
		--_ink: var(--print-button-ink, #18181b);
		--_muted: var(--print-button-muted, #52525b);
		--_hairline: var(--print-button-hairline, rgb(0 0 0 / 0.12));
		--_surface: var(--print-button-surface, #ffffff);
		--_ease: cubic-bezier(0.2, 0, 0, 1);
	}

	/* Each variant fills these in; the classes in the markup only ever read them. Hover moves the
	   fill one small step toward the ink, so it follows any surface the consumer sets. */
	.print-button__control[data-variant='secondary'] {
		--_fill: var(--_surface);
		--_fill-hover: color-mix(in oklab, var(--_surface) 95%, var(--_ink));
		--_label: var(--_ink);
		--_icon: var(--_muted);
		/* Inset, so the outlined button is exactly as large as a filled one beside it. */
		--_edge: inset 0 0 0 1px var(--_hairline), 0 1px 2px rgb(0 0 0 / 0.05);
	}

	.print-button__control[data-variant='ghost'] {
		--_fill: transparent;
		--_fill-hover: color-mix(in oklab, var(--_ink) 6%, transparent);
		--_label: var(--_ink);
		--_icon: var(--_muted);
		--_edge: 0 0 #0000;
	}

	.print-button__control[data-variant='link'] {
		--_label: var(--_muted);
		--_icon: currentColor;
	}

	/* Disabled and preparing controls do not change tone on hover. */
	.print-button__control:disabled,
	.print-button__control[aria-busy='true'] {
		--_fill-hover: var(--_fill);
	}
</style>
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-18858d0dfde34177a9ea89e132ad7cd0d99c204d736bd226e2ecaf6c99577f1c`
- Entry: `PrintButton.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_print_button_01/1.0.0/neutral/sha256-18858d0dfde34177a9ea89e132ad7cd0d99c204d736bd226e2ecaf6c99577f1c/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_print_button_01/1.0.0/neutral/sha256-18858d0dfde34177a9ea89e132ad7cd0d99c204d736bd226e2ecaf6c99577f1c/bundle.zip (7037 bytes, sha256 `9939c23c9b2e75781c3b37faaf084f390579e52f3f0cd4934e351465c6776a63`)

Files:

- `PrintButton.svelte` (entry, 9338 bytes): https://pagesugar.com/artifacts/cmp_print_button_01/1.0.0/neutral/sha256-18858d0dfde34177a9ea89e132ad7cd0d99c204d736bd226e2ecaf6c99577f1c/source/PrintButton.svelte
