# QR

URL: https://sola.ysz.life/projects/qr

> Styled QR codes, with a decoder attached

- Type: Personal project
- Period: Apr 2026
- Live: https://qr.ysz.life
- Source: private
- Stack: Next.js App Router, React, TypeScript, Tailwind CSS, shadcn/ui, qr-code-styling, qr-scanner, Radix UI

qr.ysz.life is a QR code generator for styled codes: dot shapes, colours and gradients, and a logo in the middle. Because styling is what breaks a code, it also decodes exported images to check that they still scan.

## A QR code generator with a scan test

[qr.ysz.life](https://qr.ysz.life) makes QR codes in the browser. You type a link or any text, style the code and download it. Styling is also what stops a code from scanning, so the header has a Scan Test that reads an exported image back with a real decoder.

![The generator in its light theme: typing https://ysz.life into the empty field turns the faded placeholder code solid, and the code redraws with every keystroke](https://sola.ysz.life/projects/qr/hero.webp)
*The code redraws with every keystroke.*
[Video](https://sola.ysz.life/projects/qr/hero.mp4)

### Shapes and dot styles

The Style tab decides how the code is drawn. Its modules, the small squares a QR code is made of, can stay square or become dots, rounded runs or angled shapes, and the three corner squares and the dots inside them take styles of their own. The circle shape sets the code in a round field and fills the space around it with dots copied from the code's own pattern. A reset button next to the tabs puts every setting back and keeps the text.

![The Style tab: the shape switches from Square to Circle, then the Dot Style menu opens and Dots is picked, then the Corner Square Style menu opens and Dot is picked, and the code redraws after each choice](https://sola.ysz.life/projects/qr/styles.webp)
*Circle, then dots, then round corner squares.*
[Video](https://sola.ysz.life/projects/qr/styles.mp4)

### Colours and gradients

Foreground and background take any colour, and the corner squares can have one of their own. The background can also be transparent, for a code that sits on a photo or a coloured page. Both layers take a gradient, linear at any angle or radial from the centre.

![The Colors tab: the Gradient switch turns on and the code shades from navy to violet, the gradient end becomes orange, and dragging the angle slider from 45 to 255 degrees turns the gradient around the code](https://sola.ysz.life/projects/qr/colors.webp)
*A gradient, turned by its angle slider.*
[Video](https://sola.ysz.life/projects/qr/colors.mp4)

### A logo in the middle

A logo can be dropped onto the Style tab or picked from disk, and a slider sets its size. Dropping one raises the error correction to H, the level that can rebuild the most missing data, because the modules under the logo are gone and the scanner has to restore them. Lower it again and the Settings tab warns that a logo wants the high level.

![The Settings tab shows error correction at Quartile (25%); on the Style tab an image is dropped onto the dashed logo field, the leaf logo appears in the middle of the code, and back on Settings the level now reads High (30%)](https://sola.ysz.life/projects/qr/logo.webp)
*Dropping a logo raises the level from Q to H.*
[Video](https://sola.ysz.life/projects/qr/logo.mp4)

### Exporting and recent codes

A code downloads as PNG, SVG, JPEG or WebP, or goes to the clipboard as a PNG. The Settings tab sets the export size, from 128 to 512 pixels, and the margin around the code. Every download or copy also lands in a Recent list under the buttons, with a thumbnail. It keeps the last ten codes in the browser, and clicking one restores its text and every setting.

![The generator with a leaf logo in a navy code for https://ysz.life, and below the export buttons the Recent list open with two entries, https://ysz.life and https://kinoa.to, each with a thumbnail and the date](https://sola.ysz.life/projects/qr/recent.webp)
*The Recent list after two downloads.*

### Checking that a code scans

The Scan Test in the header takes an exported image and decodes it. A pass shows the text it read, with the link ready to open, and a fail says no code was found. In the clip the default navy code passes, then the same code in a mid grey fails, although on screen it looks like any other QR code.

![The Scan Test dialog decodes a dropped navy code and shows https://ysz.life; after the foreground is changed to #999999 the grey code is dropped in and the dialog reports that no QR code was found](https://sola.ysz.life/projects/qr/scan-test.webp)
*Navy passes, #999999 grey fails.*
[Video](https://sola.ysz.life/projects/qr/scan-test.mp4)

### Light and dark

The site follows the system theme, and the d key switches between light and dark. The preview keeps the code's own background colour in both, so it shows what the exported file will look like.

![A circular code with an orange gradient and a leaf logo; pressing d switches the page to the dark theme and back, while the code keeps its white background](https://sola.ysz.life/projects/qr/theme.webp)
*The d key switches the theme.*
[Video](https://sola.ysz.life/projects/qr/theme.mp4)

### How it's built

The site is a Next.js app with shadcn/ui on Radix and Tailwind. [qr-code-styling](https://github.com/kozakdenys/qr-code-styling) draws the code as an SVG, and [qr-scanner](https://github.com/nimiq/qr-scanner) decodes images for the Scan Test. Codes are made and decoded in the browser, so the text never leaves the page.

#### How big the logo can be

A QR code stores its data together with Reed–Solomon error-correction codewords, at one of four levels: L can restore about 7% of the codewords, M 15%, Q 25% and H 30%. A logo spends that allowance. qr-code-styling multiplies the size setting by the level's percentage and by the number of modules in the code, and treats the result as the most modules the logo may cover. It then clears the largest block under that number, an odd count across so it sits in the middle, and fits the logo into it.

The level also changes the code itself. For `https://ysz.life`, 16 bytes, at the default size setting of 0.35:

| Level | Recovers  | Symbol             | Room for the logo |
| ----- | --------- | ------------------ | ----------------- |
| L     | About 7%  | Version 1, 21 × 21 | 3 × 3 modules     |
| M     | About 15% | Version 2, 25 × 25 | 5 × 5 modules     |
| Q     | About 25% | Version 2, 25 × 25 | 7 × 7 modules     |
| H     | About 30% | Version 3, 29 × 29 | 9 × 9 modules     |

All four decoded in the Scan Test, because a lower level shrinks the logo to fit the smaller budget.

![Four codes for https://ysz.life with the same leaf logo at levels H, Q, M and L; the logo covers 9 × 9, 7 × 7, 5 × 5 and 3 × 3 modules as the code shrinks from version 3 to version 1](https://sola.ysz.life/projects/qr/error-correction-levels.webp)
*One logo and one size setting at H, Q, M and L.*

A logo that small defeats the purpose of adding one, so loading an image raises the level to H:

`components/qr-generator.tsx`

```tsx
const loadLogoFile = useCallback((file: File) => {
  if (!file.type.startsWith("image/")) return
  const reader = new FileReader()
  reader.onload = () => {
    setConfig((prev) => ({
      ...prev,
      logo: reader.result as string,
      errorLevel: prev.errorLevel !== "H" ? "H" : prev.errorLevel,
    }))
  }
  reader.readAsDataURL(file)
}, [])
```

#### Which colours still scan

Colour gets no such budget. qr-code-styling draws whatever it is given, and a scanner needs contrast between the modules and the background. I exported variations of the default code and ran each through the Scan Test:

| Code                                                                | Scan Test |
| ------------------------------------------------------------------- | --------- |
| Navy on white, the default                                          | Passes    |
| Navy on a transparent background                                    | Passes    |
| White on navy                                                       | Passes    |
| Grey `#888888` on white                                             | Passes    |
| Grey `#999999` on white, and anything lighter                       | Fails     |
| Yellow `#ffd400` on white                                           | Fails     |
| A gradient from navy to pale lavender `#e0e0ff`, corners still navy | Fails     |

The decoder is qr-scanner, which is built on jsQR, a JavaScript port of Google's ZXing library. It runs in a Web Worker, or uses the browser's own `BarcodeDetector` where one exists, and the dialog imports it on the first scan, so it adds nothing to the page until someone uses it. A pass is still one decoder's result, and phone cameras differ, so a code that fails here needs more contrast before it goes to print.

#### One render per change

The generator is loaded with `next/dynamic` and `ssr: false`, and qr-code-styling is imported after mount. Every change to the settings then builds a new instance and swaps its SVG into the preview. The library's `update()` merges new options into an existing instance, but its deep merge produced wrong output, so the effect rebuilds instead. That costs one fresh render per keystroke or slider step, for a code of at most 512 pixels.

Restoring a recent code spreads the saved settings over the current defaults, so a code saved before a newer option existed still restores, with the defaults filling in the fields it doesn't have.

![The generator in its dark theme with the Colors tab open: a circular code with a navy-to-orange gradient and a leaf logo on a white card](https://sola.ysz.life/projects/qr/styled-dark-desktop.webp)
*The dark theme.*

![The generator on a phone in the light theme: the title, the link field and the circular gradient code with its leaf logo](https://sola.ysz.life/projects/qr/styled-light-mobile.webp)
*On a phone.*

![The generator on a phone in the dark theme, scrolled to the export buttons and the Colors tab with the gradient settings](https://sola.ysz.life/projects/qr/styled-dark-mobile.webp)
*The controls on a phone.*
