# QR code display

> Encodes a URL or text as an inline SVG QR code during render, on a white tile with a four-module quiet zone, and shows the destination beside it as a link or text, with an optional copy button.

- ID: `cmp_qr_code_display_01`
- Slug: `qr-code-display-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-21T22:56:46Z
- Updated: 2026-10-22
- Available versions: `1.0.0`
- Kind: control
- Primary category: `typography`
- Detail page: https://pagesugar.com/components/qr-code-display-01?variant=default
- Preview: https://pagesugar.com/preview/qr-code-display-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `default` | Default | yes | `sha256-1c9ed6e32397f72c77f6c7b99c596635c9a0ae0560857dcd6c6bef37c9f6a77a` |

## 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: no
- Integration level: presentational
- Appearance modes: light
- Suggested directory: `src/lib/components/qr-code-display-01`

## Dependencies

- `uqr` `^0.1.3` (resolved at build time: `0.1.3`): Encodes the value into a QR module matrix (Reed-Solomon error correction, masking, version selection) synchronously, in any runtime, with no dependencies of its own.

## Services

No external services required.

## Usage

Pass value and label; it encodes the value while rendering and shows a white tile with the code, a caption and the value as a link or text. It does not shorten or track URLs, scan codes, embed logos, offer a download, or decide whether a phone visitor should see a code at all.

Required props: `value`, `label`

```svelte
<script lang="ts">
	import QrCode from '$lib/components/qr-code-display-01/QrCode.svelte';
</script>

<QrCode
	value="https://halcyon.app/m/boards/q3-launch"
	label="QR code linking to the Q3 launch board in the Halcyon app"
	caption="Scan with your phone camera to open the Q3 launch board in the Halcyon app."
	copyable
/>
```

Limitations:

- A version 40 code holds about 2,300 bytes of mixed text at M correction (fewer at Q and H, and fewer characters for non-Latin text, which encodes as UTF-8). The encoder packs digits and capitals more tightly. Longer values show the error message instead of a code.
- Whole-pixel modules only fit when size is at least the module count; a dense code in a small tile is drawn at fractional modules and may not scan from a distance. Keep dense codes at 160 px or larger.
- Only http and https values with a host become links. Malformed URLs, mailto:, tel: and Wi-Fi strings are shown as text.
- The copy button needs JavaScript and the Clipboard API; without them the button never appears and its row stays reserved, so nothing moves.
- A visitor already on a phone cannot scan their own screen. Pair the code with a direct link or button on small screens.
- A value that changes after the page loads and no longer fits shows the error message without announcing it; announce the change from the page if it matters.

## Usage guide

### QR code display

Use it where someone may want the same destination on another device: a desktop page handing
off to a phone, a ticket, a printed notice.

#### Text beside the code

The code is never the only way in. Keep `showValue` on unless the same destination is offered
elsewhere on the page (a button, or the Wi-Fi case where the password should stay off screen).

#### Phones

A visitor reading the page on a phone cannot scan it. Pair the code with a direct link:

```svelte
<a href={url} class="sm:hidden">Open the board</a>
<QrCode value={url} label="QR code linking to the board" class="max-sm:hidden" />
```

#### Sizes

- 96 px: short URLs only (a footer, a receipt).
- 160 px (default): on-screen handoff.
- 192 px and up: codes scanned at arm's length, and long or Q and H level values.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `value` | `string` | yes |  | The data to encode: a URL, a ticket code or a Wi-Fi string. |
| `label` | `string` | yes |  | Accessible name for the code, read as the image description, e.g. "QR code linking to your ticket". |
| `caption` | `string` | no |  | Visible instruction beside the code that says what scanning does. |
| `showValue` | `boolean` | no | `true` | Show the value beside the code, as a link when it is an http(s) URL and as text otherwise. With false, offer the destination elsewhere on the page. |
| `size` | `number` | no | `160` | Width and height of the tile in CSS pixels, at least 48. The code is drawn at the largest whole-pixel module size that fits. |
| `errorCorrection` | 'L' \| 'M' \| 'Q' \| 'H' | no | `'M'` | Error correction level: L 7%, M 15%, Q 25%, H 30% of the code can be damaged and still scan. Higher levels make the code denser. |
| `copyable` | `boolean` | no | `false` | Show a button that copies the value. It appears once JavaScript runs and the Clipboard API is available. |
| `copyLabel` | `string` | no | `'Copy'` | Copy button label. |
| `copiedLabel` | `string` | no | `'Copied'` | Button label and polite announcement after a successful copy. |
| `copyFailedLabel` | `string` | no | `"Couldn't copy"` | Button label and polite announcement when the browser refuses the copy, so the reader knows to select the text instead. |
| `errorMessage` | `string` | no |  | Shown instead of the caption when the value is too long to encode. Defaults to an English sentence that mentions the text below when showValue is true. |
| `class` | `string` | no |  | Extra classes on the root figure, such as a max width or margins. |

## Customization

Retone the text and focus ring through five --qr-code-display-\* variables. The tile stays white and the modules dark by design, so the code scans on any page.

- Content: value, label and caption are props; write the caption as the action scanning performs ("Scan to open your ticket"), not "Scan me".
- Dark or tinted page: set --qr-code-display-ink: #fafafa, --qr-code-display-muted: #a1a1aa, --qr-code-display-hairline: rgb(255 255 255 / 0.12) and --qr-code-display-accent: #fafafa on an ancestor. The tile stays white, which is what keeps the code scannable on a dark band.
- Module colour: --qr-code-display-foreground retones the code. Keep it dark (at least 7:1 against white, roughly #595959 or darker); light or pastel modules fail on many phone cameras.
- Size: 160 px suits screens; use 192 px or more for codes scanned at arm's length (gates, posters) and for long values. 96 px is the floor for a short URL.
- Layout: the text wraps under the code when its column is narrower than about 400 px. Pass class="max-w-md" or similar to bound it.
- Phones: hide the code below a breakpoint (class="max-sm:hidden") and show a direct link instead when the page is likely to be open on the phone already.
- Language: pass copyLabel, copiedLabel and errorMessage in the page language; the value keeps its own direction in right-to-left text.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--qr-code-display-accent` |
| `ink` | `--qr-code-display-ink` |
| `muted` | `--qr-code-display-muted` |
| `hairline` | `--qr-code-display-hairline` |
| `foreground` | `--qr-code-display-foreground` |

## Accessibility

- The svg has role="img" and aria-label from label, with no \<title\>, so screen readers read one name and no tooltip appears.
- The value sits beside the code as text, or as a link for http(s) URLs, so the code is never the only way to the information (WCAG 1.1.1). With showValue false, the page must offer the destination some other way.
- The modules are dark on a white tile with a four-module quiet zone in every theme; scanners need dark on light, so the code never inverts for dark mode.
- The link and copy button have a two-pixel focus ring in the accent colour. Under a coarse pointer both have a 44 px tall target.
- A copy is announced through a polite live region, whether it worked or the browser refused it, and the label change holds the button width.
- The over-capacity message replaces the caption as text, with a mark that is hidden from assistive technology.
- When a value is too long to encode, its text sits in a focusable region labelled by the error message, at most 256 px tall, so it can be scrolled by keyboard without running down the page.

Known limitations:

- A screen reader user hears the label, not the code contents; write a label that names the destination.
- The component cannot tell whether a phone visitor can scan it; that decision belongs to the page.

## License

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

## Source

- Palette: Default (`default`)
- Entry: `QrCode.svelte`
- Suggested directory: `src/lib/components/qr-code-display-01`
- Files: 1
- Artifact digest: `sha256-1c9ed6e32397f72c77f6c7b99c596635c9a0ae0560857dcd6c6bef37c9f6a77a`

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

#### `QrCode.svelte`

Role: entry · 11011 bytes · SHA-256 `f1ca0ccb0fef1f81d6d8210793441a9c3f3f96dcc837a8af06bd6716185f2d54`

```svelte
<!--
	QR code display. Encodes `value` while the page renders, so server HTML carries the whole
	code, and always offers the destination as text beside it. The code is dark on a fixed white
	tile in every theme: scanners read dark modules on a light field, never the reverse.
-->
<script lang="ts">
	import { onMount } from 'svelte';
	import { encode } from 'uqr';

	type ErrorCorrection = 'L' | 'M' | 'Q' | 'H';

	interface Props {
		/** The data to encode: a URL, a ticket code, a Wi-Fi string. */
		value: string;
		/** Accessible name for the code, e.g. "QR code linking to your ticket". */
		label: string;
		/** Visible instruction beside the code, e.g. "Scan with your phone camera to open your ticket." */
		caption?: string;
		/** Show the value as text, or as a link when it is an http(s) URL. */
		showValue?: boolean;
		/** Width and height of the code's tile in CSS pixels. */
		size?: number;
		/** How much of the code can be damaged and still scan: L 7%, M 15%, Q 25%, H 30%. */
		errorCorrection?: ErrorCorrection;
		/** Offer a button that copies the value. It appears once the page has JavaScript. */
		copyable?: boolean;
		/** Copy button label. */
		copyLabel?: string;
		/** Copy button label, and the announcement, after a successful copy. */
		copiedLabel?: string;
		/** Copy button label, and the announcement, when the browser refuses the copy. */
		copyFailedLabel?: string;
		/** Shown instead of the caption when the value is too long to encode. */
		errorMessage?: string;
		/** Extra classes on the root figure. */
		class?: string;
	}

	let {
		value,
		label,
		caption,
		showValue = true,
		size = 160,
		errorCorrection = 'M',
		copyable = false,
		copyLabel = 'Copy',
		copiedLabel = 'Copied',
		copyFailedLabel = "Couldn't copy",
		errorMessage,
		class: className
	}: Props = $props();

	const uid = $props.id();

	/** Light modules around the code. The QR specification asks for four. */
	const QUIET = 4;

	/**
	 * The dark modules as one path, a horizontal run per command, offset by the quiet zone.
	 * One path has no seams between modules, unlike a rect per module. Null when the value
	 * does not fit in a version 40 code at this error correction level.
	 */
	const code = $derived.by(() => {
		try {
			const { data, size: modules } = encode(value, { ecc: errorCorrection, border: 0 });
			let d = '';
			data.forEach((row, y) => {
				for (let x = 0; x < modules; x++) {
					if (!row[x]) continue;
					let run = 1;
					while (row[x + run]) run++;
					d += `M${x + QUIET} ${y + QUIET}h${run}v1h-${run}z`;
					x += run - 1;
				}
			});
			return { d, extent: modules + QUIET * 2 };
		} catch {
			return null;
		}
	});

	/** The tile's side, a whole number of pixels from 48 up. */
	const tile = $derived(Math.max(48, Math.round(size)));

	/**
	 * Whole-pixel modules where they fit: the code is drawn at the largest multiple of its
	 * module count inside the tile, and the white tile pads the rest as extra quiet zone.
	 */
	const drawn = $derived(
		code && tile >= code.extent ? Math.floor(tile / code.extent) * code.extent : tile
	);

	/** An http(s) URL with a host becomes a link; anything else, malformed URLs included, is text. */
	const isLink = $derived.by(() => {
		const trimmed = value.trim();
		if (!/^https?:\/\/\S+$/i.test(trimmed)) return false;
		try {
			return new URL(trimmed).hostname !== '';
		} catch {
			return false;
		}
	});
	const shown = $derived(isLink ? value.trim().replace(/^https?:\/\//i, '') : value);
	const error = $derived(
		errorMessage ??
			(showValue
				? 'This is too long for a QR code. Scroll to read the full text below.'
				: 'This is too long to fit in a QR code.')
	);

	/* The copy button needs the Clipboard API, so it only appears once the page is mounted. */
	let clipboard = $state(false);
	let mounted = false;
	let timer: ReturnType<typeof setTimeout> | undefined;

	onMount(() => {
		mounted = true;
		clipboard = !!navigator.clipboard?.writeText;
		return () => {
			mounted = false;
			clearTimeout(timer);
		};
	});

	const canCopy = $derived(copyable && clipboard);

	/* Remember which value the last copy was for, so a new value never reads as already copied. */
	let result = $state<{ value: string; ok: boolean } | null>(null);
	const copied = $derived(result?.value === value && result.ok);
	const failed = $derived(result?.value === value && !result.ok);

	async function copy() {
		const copying = value;
		let ok = true;
		try {
			await navigator.clipboard.writeText(copying);
		} catch {
			ok = false;
		}
		if (!mounted) return;
		result = { value: copying, ok };
		clearTimeout(timer);
		timer = setTimeout(() => (result = null), ok ? 2000 : 4000);
	}
</script>

{#snippet destination()}
	{#if isLink}
		<a
			href={value.trim()}
			dir="ltr"
			class="rounded-sm wrap-anywhere underline-offset-2 transition-colors duration-150 ease-(--_ease) hover:text-(--_ink) hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent) motion-reduce:transition-none pointer-coarse:-my-3 pointer-coarse:inline-block pointer-coarse:py-3"
			>{shown}&#8288;<svg
				viewBox="0 0 16 16"
				width="14"
				height="14"
				fill="none"
				stroke="currentColor"
				stroke-width="1.75"
				stroke-linecap="round"
				stroke-linejoin="round"
				class="ms-1 inline-block align-[-0.125em]"
				aria-hidden="true"><path d="M5.5 10.5l5-5M6.5 5.5h4v4" /></svg
			></a
		>
	{:else}
		<span class="wrap-anywhere whitespace-pre-line" dir="auto">{shown}</span>
	{/if}
{/snippet}

<figure class={['qr-code-display flex flex-wrap items-start gap-5', className]}>
	{#if code}
		<div
			class="grid shrink-0 place-items-center overflow-hidden rounded-xl bg-white ring-1 ring-(--_hairline)"
			style:width="{tile}px"
			style:height="{tile}px"
		>
			<svg
				role="img"
				aria-label={label}
				width={drawn}
				height={drawn}
				viewBox="0 0 {code.extent} {code.extent}"
				shape-rendering="crispEdges"
				class="block"
			>
				<rect width={code.extent} height={code.extent} fill="#ffffff" />
				<path d={code.d} fill="var(--_foreground)" />
			</svg>
		</div>
	{:else}
		<div
			class="grid shrink-0 place-items-center rounded-xl border border-dashed border-(--_frame) text-(--_muted)"
			style:width="{tile}px"
			style:height="{tile}px"
		>
			<svg
				viewBox="0 0 20 20"
				width="20"
				height="20"
				fill="none"
				stroke="currentColor"
				stroke-width="1.5"
				stroke-linecap="round"
				stroke-linejoin="round"
				aria-hidden="true"
			>
				<rect x="3.5" y="3.5" width="13" height="13" rx="2.5" />
				<path d="M4.5 15.5l11-11" />
			</svg>
		</div>
	{/if}

	{#if !code || caption || showValue || copyable}
		<figcaption class="max-w-[30em] min-w-0 flex-1 basis-56 text-sm/5">
			{#if !code}
				<p
					id="{uid}-error"
					class="flex gap-2 text-base leading-6 font-semibold tracking-[-0.011em] text-pretty text-(--_ink) rtl:tracking-normal"
				>
					<span class="flex h-lh shrink-0 items-center" aria-hidden="true">
						<svg
							viewBox="0 0 16 16"
							width="16"
							height="16"
							fill="none"
							stroke="currentColor"
							stroke-width="1.75"
							stroke-linecap="round"
						>
							<circle cx="8" cy="8" r="6.25" />
							<path d="M8 4.75v3.5M8 11.1v.15" />
						</svg>
					</span>
					{error}
				</p>
			{:else if caption}
				<p
					class="text-base leading-6 font-semibold tracking-[-0.011em] text-pretty text-(--_ink) rtl:tracking-normal"
				>
					{caption}
				</p>
			{/if}

			{#if showValue && !code}
				<!-- Too long for a code: the text stays whole, in a region that scrolls instead of running on. Print shows all of it. -->
				<!-- A scrolling region has to take focus so keyboard users can scroll it. -->
				<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
				<div
					role="region"
					aria-labelledby="{uid}-error"
					tabindex="0"
					class="mt-1 max-h-64 overflow-y-auto rounded-sm text-(--_muted) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent) print:max-h-none print:overflow-visible"
				>
					{@render destination()}
				</div>
			{:else if showValue}
				<p class={['text-(--_muted)', caption && 'mt-1']}>
					{@render destination()}
				</p>
			{/if}

			{#if copyable}
				<div class={['flex h-8 pointer-coarse:h-11', (!code || caption || showValue) && 'mt-3']}>
					{#if canCopy}
						<button
							type="button"
							onclick={copy}
							class="-ms-3 inline-flex items-center gap-2 rounded-md px-3 text-[13px] font-medium text-(--_ink) transition-[color,background-color,scale] duration-150 ease-(--_ease) hover:bg-(--_fill-hover) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent) active:scale-[.98] active:duration-80 motion-reduce:transition-none motion-reduce:active:scale-100"
						>
							<svg
								viewBox="0 0 16 16"
								width="14"
								height="14"
								fill="none"
								stroke="currentColor"
								stroke-width="1.75"
								stroke-linecap="round"
								stroke-linejoin="round"
								aria-hidden="true"
							>
								{#if copied}
									<path d="M3.5 8.5l3 3 6-7" />
								{:else if failed}
									<circle cx="8" cy="8" r="6.25" />
									<path d="M8 4.75v3.5M8 11.1v.15" />
								{:else}
									<rect x="5.5" y="5.5" width="8" height="8" rx="1.5" />
									<path
										d="M10.5 3.5v-.25A.75.75 0 0 0 9.75 2.5h-6.5a.75.75 0 0 0-.75.75v6.5c0 .41.34.75.75.75h.25"
									/>
								{/if}
							</svg>
							<!-- Three labels share one cell, so the button holds the width of the longest. -->
							<span class="grid text-start">
								<span class={['col-start-1 row-start-1', (copied || failed) && 'opacity-0']}
									>{copyLabel}</span
								>
								<span class={['col-start-1 row-start-1', !copied && 'invisible']} aria-hidden="true"
									>{copiedLabel}</span
								>
								<span class={['col-start-1 row-start-1', !failed && 'invisible']} aria-hidden="true"
									>{copyFailedLabel}</span
								>
							</span>
						</button>
						<span class="sr-only" aria-live="polite"
							>{copied ? copiedLabel : failed ? copyFailedLabel : ''}</span
						>
					{/if}
				</div>
			{/if}
		</figcaption>
	{/if}
</figure>

<style>
	/*
	 * Public tokens: set --qr-code-display-* on the figure or any ancestor. The tile stays white
	 * on purpose; a foreground lighter than about #595959 on white loses the contrast phone
	 * cameras need, so retone it only to a dark colour.
	 */
	.qr-code-display {
		--_accent: var(--qr-code-display-accent, #18181b);
		--_ink: var(--qr-code-display-ink, #18181b);
		--_muted: var(--qr-code-display-muted, #52525b);
		--_hairline: var(--qr-code-display-hairline, rgb(0 0 0 / 0.08));
		--_foreground: var(--qr-code-display-foreground, #09090b);
		/* Formulas read by utilities. */
		--_fill-hover: color-mix(in srgb, var(--_ink) 6%, transparent);
		--_frame: color-mix(in srgb, var(--_ink) 24%, transparent);
		--_ease: cubic-bezier(0.2, 0, 0, 1);
	}
</style>
```

## Artifacts

### Default (`default`) (default)

- Artifact digest: `sha256-1c9ed6e32397f72c77f6c7b99c596635c9a0ae0560857dcd6c6bef37c9f6a77a`
- Entry: `QrCode.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_qr_code_display_01/1.0.0/default/sha256-1c9ed6e32397f72c77f6c7b99c596635c9a0ae0560857dcd6c6bef37c9f6a77a/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_qr_code_display_01/1.0.0/default/sha256-1c9ed6e32397f72c77f6c7b99c596635c9a0ae0560857dcd6c6bef37c9f6a77a/bundle.zip (7627 bytes, sha256 `dff5e550f359b0e06b4043a1426cd8a2abf2467b0f6485a4b14303684f0113a0`)

Files:

- `QrCode.svelte` (entry, 11011 bytes): https://pagesugar.com/artifacts/cmp_qr_code_display_01/1.0.0/default/sha256-1c9ed6e32397f72c77f6c7b99c596635c9a0ae0560857dcd6c6bef37c9f6a77a/source/QrCode.svelte
