Skip to content

Before you use this component

  • Install uqr@^0.1.3.
Palette Default
Download ZIP

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

Preview

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_qr_code_display_01 · version 1.0.0 · Default palette
Using the PageSugar MCP server, fetch component cmp_qr_code_display_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-1c9ed6e32397f72c77f6c7b99c596635c9a0ae0560857dcd6c6bef37c9f6a77a
QrCode.svelte Svelte · 10.8 KB Raw
<!--
	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>

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.

Suggested location
src/lib/components/qr-code-display-01
Required props
valuelabel

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.

Example

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
/>

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.

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

On this page
NameTypeRequiredDefaultDescription
valuestringYesNoneThe data to encode: a URL, a ticket code or a Wi-Fi string.
labelstringYesNoneAccessible name for the code, read as the image description, e.g. "QR code linking to your ticket".
captionstringNoNoneVisible instruction beside the code that says what scanning does.
showValuebooleanNotrueShow 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.
sizenumberNo160Width 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.
copyablebooleanNofalseShow a button that copies the value. It appears once JavaScript runs and the Clipboard API is available.
copyLabelstringNo'Copy'Copy button label.
copiedLabelstringNo'Copied'Button label and polite announcement after a successful copy.
copyFailedLabelstringNo"Couldn't copy"Button label and polite announcement when the browser refuses the copy, so the reader knows to select the text instead.
errorMessagestringNoNoneShown 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.
classstringNoNoneExtra classes on the root figure, such as a max width or margins.

Customization#

On this page

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.

Public CSS variables

VariableToken
--qr-code-display-accentaccent
--qr-code-display-inkink
--qr-code-display-mutedmuted
--qr-code-display-hairlinehairline
--qr-code-display-foregroundforeground

Dependencies and services#

On this page
PackageRangeResolved at build timePurpose
uqr^0.1.30.1.3Encodes the value into a QR module matrix (Reed-Solomon error correction, masking, version selection) synchronously, in any runtime, with no dependencies of its own.

Install with (shown for reference, run it yourself)

npm install uqr@^0.1.3

Accessibility#

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

Release details#

On this page
Integration
  • Presentational
  • Works without client-side JavaScript
  • 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 · 21 October 2026

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