A single-page web app that turns any URL into a scannable QR code you can download or share, built for the devChallenges.io QR code generator challenge.
The brief: take a clean, design-spec'd layout (input → result), implement it in React, and add light/dark theming on top. The goal is a polished, pixel match of the provided design that is responsive, accessible, themable, and fully tested.
It is also a small playground for a handful of modern web techniques:
- A canvas-rendered QR generated locally — no third-party API call, the page works offline
- Design tokens in CSS custom properties, remapped per theme so neither React state nor JS touches color logic at runtime
- Native browser APIs for file download (
a[download]+toDataURL) and clipboard sharing (navigator.clipboard.writeText) - Vitest + Testing Library with
@vitest/coverage-v8thresholds set to 100% so coverage regressions fail the build
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ QRCODE │ │ QRCODE │
│ │ │ │
│ ┌────────────────┬──────┐ │ │ ┌──────────────┐ │
│ │ Enter an url │ QR… │ │ ---> │ │ ▓▓▓ ▓▓ ▓▓▓ │ │
│ └────────────────┴──────┘ │ submit │ │ ▓ █ ▓██▓ ▓▓ │ │
│ │ │ │ ▓▓▓ ▓ ▓ ▓ ▓ │ │
│ │ │ └──────────────┘ │
│ │ │ Download Share │
└──────────────────────────────┘ └──────────────────────────────┘
empty / initial state result state
The toggle in the top-right switches between dark and light themes; the choice persists across reloads.
- Paste a URL, click QR code, get a scannable QR rendered on a
<canvas> - Download the QR as
qr-code.png - Share copies the original URL to the clipboard with a transient "Copied!" / error status
- Light and dark themes driven by a
data-themeattribute on<html>; selection is persisted under theqr-themelocalStoragekey - Responsive layouts at mobile (≤640px), tablet, and desktop breakpoints
- Accessible: real
<button>/<input>semantics, labelled controls,aria-pressedon the toggle,role="status"+aria-live="polite"for share feedback
| Concern | Choice |
|---|---|
| Build / dev | Vite 8 with @vitejs/plugin-react |
| UI | React 19 + TypeScript (strict, verbatimModuleSyntax) |
| Styling | Plain CSS with custom-property design tokens — no UI framework |
| QR rendering | qrcode (canvas-based) |
| Testing | Vitest + Testing Library + jsdom |
| Coverage | @vitest/coverage-v8 with 100% thresholds |
| Lint / format | ESLint (flat config) + Prettier |
| Font | Outfit from Google Fonts |
npm install
npm run dev # http://localhost:5173Build for production:
npm run build # → dist/
npm run preview # serve dist/ locally| Command | Description |
|---|---|
npm run dev |
Start the Vite dev server with HMR |
npm run build |
Type-check (tsc -b) and produce a production build in dist/ |
npm run preview |
Serve the built dist/ for local verification |
npm run lint |
Run ESLint over the project |
npm run format / npm run format:check |
Run Prettier (write / check-only) |
npm test |
Run the Vitest suite once |
npm run test:coverage |
Run the suite with v8 coverage (fails below 100%) |
The UI is a two-view state machine driven by a single piece of state in
src/App.tsx:
url: string | null
null ──── submit ───▶ string
▲ │
└────── reset ───────────┘
null → <UrlForm onSubmit={setUrl} />
string → <QRDisplay url={url} /> + "Generate another"
When QRDisplay mounts, it reads the active design tokens
(--color-dark, --color-paper) via getComputedStyle and asks the
qrcode library to paint into the <canvas> ref. Because the colors come
from CSS variables, the QR follows the theme without any per-theme branch in
TS.
- Download creates a hidden
<a>, setshref = canvas.toDataURL('image/png'), setsdownload = 'qr-code.png', and calls.click()on it. - Share calls
navigator.clipboard.writeText(url)and flips a small status label betweenidle/copied/error, resetting back toidleafter 2s viasetTimeout.
src/
├── App.tsx # holds url state, swaps form ↔ display
├── App.css
├── index.css # design tokens + theme variables
├── main.tsx # mounts <App /> in StrictMode
└── components/
├── Header/ # QRCODE logo (full / compact)
│ ├── Header.tsx
│ ├── Header.css
│ └── Header.test.tsx
├── ThemeToggle/ # fixed top-right light/dark switch
├── UrlForm/ # URL input pill + submit button
└── QRDisplay/ # canvas QR + halo + Download/Share
Each component follows the same <Name>.tsx / <Name>.css /
<Name>.test.tsx folder layout.
The five design-spec colors are defined once on :root:
--color-light: #F2F5F9
--color-primary: #263FA9
--color-dark: #030616
--color-paper: #FAFAF9
--color-overlay: #F8FAFC1A
They are remapped to semantic aliases (--bg, --text, --button-bg,
--card-bg, --halo, …) under [data-theme='dark'] and
[data-theme='light'] selectors on <html>. Components only reference the
semantic aliases, so a theme switch is a single attribute write — no
re-render of the tree, no JS color logic.
The active theme is stored in localStorage under qr-theme and re-read on
mount.
- The URL input has a visible placeholder and an
aria-labelso screen readers always announce a name. - The QR canvas exposes its content via
role="img"and anaria-labelthat includes the URL. - The theme toggle is a real
<button>witharia-pressedreflecting the dark/light state and a context-awarearia-label. - The share status uses
role="status"+aria-live="polite"so screen readers announce "URL copied to clipboard" without stealing focus. - All interactive elements have visible
:focus-visibleoutlines.
npm test # one-shot
npm run test:coverage # one-shot with coverage report (html + text)Coverage is enforced at 100% for statements, branches, functions, and
lines via thresholds in vite.config.ts. Test files (*.test.tsx) live
next to their components and are excluded from the production build through
tsconfig.app.json.
The test suite covers:
- Component rendering (logos, labels, button states)
- The URL form's submit/disable/trim behavior
- The theme toggle's default, persisted, and click-through states
- Download triggers an anchor
.click()with adata:image/pnghref - Share copies the URL and resets status after the timeout (fake timers)
- Share's error path renders the failure status
- QR generation failures are swallowed without breaking the UI
- The
Appshell transitions between form and result views
- Challenge brief and design: devChallenges.io
- Built by @mcastig with help from Claude Code