Skip to content
Palette Neutral
Download ZIP

Neutral palette · 6.9 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_print_button_01 · version 1.0.0 · Neutral palette
Using the PageSugar MCP server, fetch component cmp_print_button_01 version 1.0.0 with variant "neutral", 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
Neutral
Version
1.0.0
Digest
Full digest
sha256-18858d0dfde34177a9ea89e132ad7cd0d99c204d736bd226e2ecaf6c99577f1c
PrintButton.svelte Svelte · 9.1 KB Raw
<!--
	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>

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.

Suggested location
src/lib/components/print-button-01

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.

Example

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>

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.

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 and content inputs#

On this page
NameTypeRequiredDefaultDescription
labelstringNo'Print this page'Visible label, or the aria-label and tooltip when iconOnly. Name what gets printed: 'Print recipe', 'Print invoice HAL-2026-0142'.
helperstringNoNoneOne short line under the button saying what lands on paper, linked with aria-describedby. Not shown when iconOnly.
beforePrint() => void | Promise<void>NoNoneCalled 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() => voidNoNoneCalled 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.
iconOnlybooleanNofalseRenders a 36 px square printer button (44 px on touch screens) with label as its accessible name and tooltip.
preparingLabelstringNo'Preparing'Shown with an ellipsis in place of the label while a slow beforePrint runs, clipped to the label's width.
disabledbooleanNofalseNative disabled. Say nearby why printing is unavailable.
classstringNoNoneExtra classes on the outer element, for placement such as margins or grid placement.

Customization#

On this page

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.

Public CSS variables

VariableToken
--print-button-accentaccent
--print-button-inkink
--print-button-mutedmuted
--print-button-hairlinehairline
--print-button-surfacesurface

Accessibility#

On this page
  • 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.

Release details#

On this page
Integration
  • Local interaction
  • Requires client-side JavaScript to be interactive
  • Server-side rendering supported
Dependencies
No additional runtime packages beyond Svelte and Tailwind CSS
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.