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.
cmp_print_button_01 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.
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
<!--
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>
Usage#
On this pageDrop 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
afterPrintdepends 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
afterPrintdoes not run until a later print fires afterprint; later clicks print without callingbeforePrintagain. - If
beforePrintthrows or rejects, the dialog does not open and the error is rethrown from the click handler. If the component is destroyed whilebeforePrintruns, it does not print whenbeforePrintsettles. - 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.printdoes not exist the control is in the server HTML and is hidden once the page hydrates. Some in-app webviews definewindow.printbut do nothing with it; the component cannot detect that. - The button's accessible name is label;
iconOnlyalso sets it as the title tooltip. A tooltip from title does not appear on touch screens or on keyboard focus. iconOnlywith variant link renders as a ghost square, since a square link has nothing to mark it.
Example
<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:
<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:
<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| 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 | None | One short line under the button saying what lands on paper, linked with aria-describedby. Not shown when iconOnly. |
beforePrint | () => void | Promise<void> | No | None | 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 | None | 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 | None | Extra classes on the outer element, for placement such as margins or grid placement. |
Customization#
On this pageChange 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:hiddentoo, and useprint:blockor a @media print rule to show what should always print, such as the contents of collapsed details. beforePrintandafterPrint: change state through Svelte (set open on a bound details, for example); the component waits a tick afterbeforePrintso the change reaches the page before the dialog snapshots it.- Accent:
--print-button-accentis the focus ring. Keep it at 3:1 against the page. - Neutrals:
--print-button-inkis the label,--print-button-mutedthe glyph, the helper line and the resting link colour,--print-button-surfacethe secondary fill and--print-button-hairlineits edge. - Worked retone for a
#09090bband:--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
| Variable | Token |
|---|---|
--print-button-accent | accent |
--print-button-ink | ink |
--print-button-muted | muted |
--print-button-hairline | hairline |
--print-button-surface | surface |
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 becomesaria-labeland title, and the printer glyph isaria-hidden. - helper is linked to the button with
aria-describedby, so it is read after the name. - While
beforePrintruns the button setsaria-busyandaria-disabledand 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-screencontrols 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.