A modern, accessible QR code generator with a neumorphic/skeuomorphic design aesthetic.
This project wraps the qrcode.js library into a user-friendly interface supporting multiple QR code types:
- Text / URL
- WiFi credentials
- Email (mailto:)
- Phone numbers (tel:)
- SMS messages
- Contact cards (vCard)
- Geographic locations (geo:)
- Bitcoin payments (BIP21)
- SEPA/EPC payments
- PayPal payments (PayPal.me + PayPal Email Legacy)
- Calendar events (iCal/VEVENT)
Live at: https://gottz.de/qr
index.html- Minimal entry point, most UI is JS-generatedscript.js- Main application logic with custom dropdown componentstyle.css- Neumorphic styling with CSS variables, dark/light mode supportsw.js- Service worker (stale-while-revalidate + update notification)manifest.json- PWA manifest with share_targetqrcode.js- QR code generation library (by Kazuhiko Arase, MIT license)ce.js- DOM helper function (by GottZ)icons/- PWA icons (192/512, standard + maskable)
A custom DOM creation utility using CSS selector syntax, created by GottZ.
// Classes use dot notation in the tag selector
ce("div.card", parent);
ce("div.container.full-width", parent);
// IDs use hash notation
ce("textarea#my-input", parent);
ce("button#submit-btn.btn-primary", parent);
// Properties passed as third argument object
ce("input#email", parent, {
type: "email",
placeholder: "Enter email...",
disabled: true
});
// IMPORTANT: style must be an object, NOT a string
ce("img", parent, { style: { display: "none" } }); // Correct
ce("img", parent, { style: "display: none;" }); // WRONG - will error- Classes: Use
ce("div.card", parent)NOTce("div", parent, { className: "card" }) - IDs: Use
ce("input#name", parent)NOTce("input", parent, { id: "name" }) - Style: Must be object
{ style: { prop: value } }NOT string
qrcode.stringToBytes = qrcode.stringToBytesFuncs["UTF-8"];
const qr = qrcode(0, errorLevel); // 0 = auto-detect version
qr.addData(text);
qr.make();
// createDataURL(cellSize, marginInPixels)
const dataUrl = qr.createDataURL(8, 8);The createDataURL margin parameter is in pixels, not QR cell units. To add a margin of N cells:
const cellSize = 8;
const marginCells = 2;
const dataUrl = qr.createDataURL(cellSize, marginCells * cellSize);L- Low (7% recovery)M- Medium (15% recovery)Q- Quartile (25% recovery)H- High (30% recovery)
Native <select> elements have limited styling, so this project uses a custom dropdown with full ARIA support.
const select = createCustomSelect(
parentElement, // Container element
[ // Options array
{ value: "opt1", label: "Option 1" },
{ value: "opt2", label: "Option 2" }
],
"opt1", // Default value
"my-select" // ID (optional)
);
// Usage
select.value; // Get current value
select.value = "opt2"; // Set value
select.addEventListener("change", () => { /* handler */ });The component automatically handles:
role="combobox"witharia-haspopup="listbox"aria-expandedstate managementaria-controlsandaria-activedescendantaria-labelledby(links to preceding label)role="listbox"androle="option"witharia-selected
- Arrow Up/Down: Navigate options
- Enter/Space: Toggle dropdown
- Escape: Close dropdown
- Home/End: Jump to first/last option
The project uses a neumorphic/skeuomorphic design matching https://login.home.gottz.de
:root {
/* Colors */
--bg, --bg-gradient /* Page background */
--surface, --surface-solid /* Card/input backgrounds */
--text, --text-muted /* Typography */
--border /* Subtle borders */
--primary, --primary-hover, --primary-glow /* Accent (green hue 126.453) */
/* Shadows (neumorphic) */
--card-shadow /* Raised cards with inset highlights */
--input-shadow /* Inset inputs */
--button-shadow, --button-shadow-hover, --button-shadow-active
/* Spacing */
--radius: 16px /* Large radius */
--radius-sm: 10px /* Small radius */
}Automatic via @media (prefers-color-scheme: light) - all colors adapt through CSS variables.
/* Screen reader only content */
.sr-only { /* visually hidden but accessible */ }
/* Keyboard focus indicator */
:focus-visible { outline: 2px solid var(--primary); }
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) { /* disable animations */ }Styled for webkit (Chrome/Safari/Edge) and Firefox to match the theme.
- ARIA Combobox Pattern: Full screen reader support for custom dropdowns
- Live Region: Status announcements for QR generation (
role="status",aria-live="polite") - Descriptive Alt Text: Dynamic alt text based on QR content type
- Keyboard Navigation: Full keyboard support throughout
- Focus Management: Clear focus indicators, proper focus return
- Landmarks:
role="main"with proper labeling - Reduced Motion: Respects
prefers-reduced-motion
// WiFi
`WIFI:T:${security};S:${ssid};P:${password};H:${hidden};;`
// Email
`mailto:${email}?subject=${subject}&body=${body}`
// Phone
`tel:${number}`
// SMS
`sms:${number}?body=${message}`
// vCard
`BEGIN:VCARD\nVERSION:3.0\nN:${last};${first};;;\nFN:${first} ${last}\n...END:VCARD`
// Location
`geo:${latitude},${longitude}`
// Bitcoin (BIP21)
`bitcoin:${address}?amount=${btc}&label=${name}&message=${msg}`
// SEPA/EPC v002 (12 lines, LF-separated, trailing empty lines trimmed, ECL default M via override checkbox)
// Lines 10 (structured ref) and 11 (remittance text) are mutually exclusive
`BCD\n002\n1\nSCT\n${bic}\n${name}\n${iban}\nEUR${amount}\n${purpose}\n${structRef}\n${unstructRef}\n${info}`
// SEPA/EPC v001 (Legacy, same format but version 001, BIC required)
// BezahlCode (Legacy): bank://singlepaymentsepa?name=...&iban=...&bic=...&amount=10,00&reason=...
// PayPal.me (path-based, NO query params)
`https://paypal.me/${username}` // without amount
`https://paypal.me/${username}/${amount}` // with amount, auto currency
`https://paypal.me/${username}/${amount}${currency}` // with amount + currency (no separator!)
// PayPal Email (Legacy, query-parameter-based)
`https://www.paypal.com/cgi-bin/webscr?cmd=_xclick&business=${email}[&amount=${amount}][¤cy_code=${currency}][&item_name=${description}]`
// Calendar Event (iCal)
`BEGIN:VCALENDAR\nVERSION:2.0\nBEGIN:VEVENT\nSUMMARY:${title}\nDTSTART:${YYYYMMDDTHHmmss}\n...END:VEVENT\nEND:VCALENDAR`QR codes can be generated directly via GET parameters — handleQueryParams() in script.js.
checkSharedData() checks for type param first (parameter API), then falls back to share target handling. Parameter API does NOT clean the URL (stays shareable), share target does.
- Global:
type(required),size,error,outline,epc_override(defaulttrue, setfalseto disable SEPA EPC ECL override),plain(flag) - Per type: mapped via
fieldMapobject (e.g.content→text-content,ssid→wifi-ssid)
?plain flag adds body.plain class → CSS hides everything except QR code (fullscreen, white bg). Title set to "QR".
- Regular fields:
field.value = paramValue - Checkboxes:
field.checked = paramValue === "true" - Custom selects:
field.value = paramValue(setter handles trigger text + ARIA update)
The SW serves cached files instantly, then fetches fresh versions from the network in the background. For text assets (HTML, CSS, JS, JSON), it compares response bodies. If content changed, it sends postMessage({ type: "UPDATE_AVAILABLE" }) to all clients, which triggers an update toast.
- Content changes to existing files: NO change to sw.js needed. The stale-while-revalidate strategy detects changes automatically on every page load.
- New files added to the project: Add them to the
APP_SHELLarray in sw.js so they get precached for offline use. - SW logic changes (new routes, new handlers): Obviously update sw.js.
The EPC spec mandates error correction level M. For EPC formats (v002/v001), an "EPC override (Medium)" checkbox is shown above the ECL select. When checked (default), ECL is forced to M and the select is disabled. The user can uncheck it to choose a custom ECL at their own risk. BezahlCode uses the user's selected ECL without override. The updateEclState() function manages checkbox visibility and select disabled state. PayPal and all other types use the user's ECL selection directly.
Three formats supported via sepa-format dropdown:
- GiroCode (EPC v002) — default, BIC optional in EWR, 12-line LF-separated payload, max 331 bytes
- EPC v001 (Legacy) — BIC required, otherwise identical to v002
- BezahlCode (Legacy) —
bank://singlepaymentsepa?...URI format, amount uses comma decimal
The setupSepaForm() function handles dynamic UI behavior: reference type toggle (structured vs unstructured, mutually exclusive), BIC required/optional label, live byte counter, IBAN format validation, and amount range validation.
Two formats supported via paypal-format dropdown:
- PayPal.me — default, path-based URL (
https://paypal.me/{user}[/{amount}{currency}]), username alphanumeric max 20 chars, no description - PayPal Email (Legacy) — query-parameter URL via
cgi-bin/webscr, supports description (max 127 chars), URL length counter
Shared currency dropdown with 24 PayPal-supported currencies, locale currency pre-selected as default. Currencies sorted by locale: detected locale currency first (if not EUR/USD), then EUR, USD, rest alphabetically. Three currencies have 0 decimal places (JPY, HUF, TWD) — decimal input is blocked and validation enforced.
The setupPaypalForm() function handles: format toggle (username ↔ email label, description show/hide), locale-based currency default, amount decimal validation per currency, recipient validation (regex for username vs email), URL length counter.
// Navigate to the page
await page.goto('https://gottz.de/qr');
// Test dark mode
await page.emulateMedia({ colorScheme: 'dark' });
// Click dropdown (uses ARIA role)
await page.getByRole('combobox', { name: 'Type' }).click();
// Select option
await page.getByRole('option', { name: 'WiFi' }).click();Made by GottZ