Add a QRCode component

Aperta
#1,693 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
5/5
Tempo stimato
Più di una settimana
Idoneità per principianti
15/100
Tipo di issue
Funzionalità
Chiarezza
Abbastanza chiara
Stato di attività
Attiva
Stack tecnologico
react, tailwindcss, typescript
Ambito
frontend

Direzione di ricerca

Nothing is actionable until maintainers answer the two questions in the thread: whether a QRCode component belongs in the library at all, and whether the flat prop shape is acceptable. If approved, work lands in packages/ui/src/components/QRCode/ with registration in src/index.ts, src/theme/index.ts and src/types/index.ts, a subpath entry in packages/ui/rollup.config.js, a regenerated schema.json, plus apps/web docs and apps/storybook/src/QRCode.stories.tsx. Read the fork's preview builds and the 18 tests before touching anything; done means those files exist and lint, format:check, typecheck and build pass.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

  • I have searched the Issues to see if this bug has already been reported
  • I have tested the latest version

Summary

Flowbite has a QR code component,
added in themesberg/flowbite#1109, where the examples are static SVG pasted into
the page. Flowbite React has no equivalent, so anyone who needs to turn a value
into a symbol reaches outside the design system for it.

I would like to contribute <QRCode>, matching the Flowbite page and going
past it on styling, which the static SVG examples there cannot offer.

It is written and passing the repo's checks, but I have not pushed the branch or
opened a PR. Two things are yours to decide first: whether you want it at all,
and whether the prop shape suits you. Both are spelled out below, and answering
them is enough for me to open the PR.

It is deployed from that branch already, so it can be tried before any code is
read:

Both are preview builds of a fork on my own account, not anything official.

import { QRCode } from "flowbite-react";

<QRCode value="https://flowbite-react.com" />
<QRCode value="..." dotStyle="rounded" cornerStyle="extra-rounded" cornerColor="#14b8a6" />
<QRCode value="..." logo={{ src: "/logo.svg", size: 0.6, radius: 1, hideDots: true }} />
Prop Type Default
value string required
size number 256
margin number 4
dotStyle "square" | "circle" | "rounded" "square"
dotColor string "currentColor"
cornerStyle "square" | "rounded" | "extra-rounded" | "circle" "square"
cornerColor string follows dotColor
cornerDotStyle "square" | "rounded" | "circle" follows cornerStyle
cornerDotColor string follows dotColor
backgroundColor string "transparent"
errorCorrectionLevel "L" | "M" | "Q" | "H" "M", or raised to fit logo.size
logo { src, size?, margin?, radius?, aspectRatio?, hideDots? } none
title string QR code: {value}

Theme key qrCode, one root.base slot. Defaulting the colours to
currentColor on a transparent background means the symbol follows the text
colour, so dark mode works through the existing theme with no extra prop, and a
utility class such as text-purple-700 recolours it.

The component is a plain render: useId and a memo, no effects, no state,
nothing to measure. It server renders without a DOM.

Context

QR codes show up in ordinary product work rather than in a niche: two factor
enrolment, payment strings, share and invite links, event tickets, restaurant
menus, device pairing. The Flowbite page lists much the same set.

Beyond parity, the styling is the part people currently leave the design system
for. A symbol that can take the brand colour, rounded modules and a centre logo
is the common ask, and the usual answer, qr-code-styling, weighs more than
this whole proposal adds and cannot server render.

What it costs

Importing QRCode next to a component already in use adds 8,904 bytes gzip, and
a bundle that never imports it gains nothing: esbuild, minified, gzip -9, React
external.

The geometry comes from @ttsalpha/qrcode/core, which I wrote, so take the
rest of this section with that in mind: MIT, zero runtime dependencies, ESM and
CJS, no React and no DOM, 8,185 bytes gzip on its own. Flowbite React keeps the
rendering, so the theme, the twMerge handling and the markup stay yours.

Library minified gzip
qrcode.react 4.2.0 16,631 6,253
@ttsalpha/qrcode/core 3.1.0 18,301 8,185
react-qr-code 2.2.0 24,789 9,129
qr-code-styling 1.9.2 48,499 14,857

To be straight about it: qrcode.react is smaller and would stay smaller. It
draws square modules in one colour and nothing else, so it cannot cover what
this proposal is for. Among the libraries that can style a symbol, this is the
smallest.

On the risk of taking it on: 13 releases since May 2026 under semver with a
Keep a Changelog history, no runtime dependencies so nothing transitive comes
with it, and the component imports the /core subpath only, which carries
neither React nor DOM code. The whole call surface is one file,
QRCode/engine.ts, re-exporting buildQR and its types, so if the dependency
ever has to change the rest of the component does not.

The styling is not decoration that quietly stops scanning: the 20 variants
covering every style the docs show are rendered, rasterised and decoded back,
including a logo at the maximum safe size and the inverted symbol dark mode
produces.

Render speed, the numbers in full and the scripts behind them are at the end.

What the PR would contain

  • packages/ui/src/components/QRCode/ with component, theme, barrel and 18 tests
  • Registration in src/index.ts, src/theme/index.ts, src/types/index.ts
  • The dependency in packages/ui/package.json and bun.lock, a three line
    lockfile diff
  • One line in packages/ui/rollup.config.js, adding the subpath to external
    alongside the entries already there such as klona/json
  • packages/ui/schema.json, regenerated
  • apps/web docs page, six examples, sidebar entry
  • apps/storybook/src/QRCode.stories.tsx
  • A changeset

It passes lint, format:check, typecheck and build. The test suite passes
too, apart from one DarkThemeToggle test that already fails on a clean
checkout of main, so it is not something this adds.

The two decisions:

  • Is a QR code component something you want in Flowbite React at all, or would
    you rather it stayed outside the library?
  • Does the prop shape suit you? It is flat, with logo as the only object, and
    the theme has a single root.base slot. I can reshape either.

Measurements

The numbers, and the scripts that produced them

Run on an Apple M4. renderToString, React 19.2.0 in its production build,
median of 3 rounds of 2000 renders:

Library renders/s
proposed <QRCode> 12,776
proposed <QRCode>, styled 9,426
qrcode.react 4.2.0 2,868
react-qr-code 2.2.0 1,721

qr-code-styling 1.9.2 is absent because it needs a DOM and does not survive
renderToString.

Bundle, esbuild, minified, gzip -9, React external:

Bundle gzip
Progress 15,437
Progress + QRCode 24,341
delta 8,904

Separately, and measuring the library rather than the component proposed here,
there is a published benchmark of the encoder on CI: ubuntu-24.04, Node
v24.21.0, ECL pinned to M, median of 3 runs,
https://qrcode.ttsalpha.com/benchmark.

Library renders/s
@ttsalpha/qrcode, toSVGString 7,817
qrcode, headless 4,527
qrcode.react 1,688
react-qr-code 991

It is worth quoting only as calibration between two different machines. That
runner is about 1.7x slower than mine on qrcode.react and react-qr-code, and
once that is taken out the proposed component lands where toSVGString lands
rather than where the library's own React component lands, which is what a
render with no effects and no state should do.

Run against a built packages/ui. React, qrcode.react, react-qr-code and
qr-code-styling are installed in a throwaway folder next to the repo.

render-bench.mjs

// Measures the path flowbite-react would actually ship: buildQR geometry
// rendered by a flowbite-react component, against the React QR libraries a
// maintainer would reach for instead. Server rendering, so the numbers are not
// clouded by jsdom.
import { createElement } from "react";
import { renderToString } from "react-dom/server";
import { QRCode } from "../flowbite-react/packages/ui/dist/index.js";
import { QRCodeSVG } from "qrcode.react";
import ReactQRCode from "react-qr-code";

const ITERATIONS = 2000;
const ROUNDS = 3;

const subjects = [
  {
    name: "flowbite-react QRCode",
    render: (value) => renderToString(createElement(QRCode, { value })),
  },
  {
    name: "flowbite-react QRCode (styled)",
    render: (value) =>
      renderToString(
        createElement(QRCode, {
          value,
          dotStyle: "rounded",
          cornerStyle: "extra-rounded",
          cornerColor: "#14b8a6",
        }),
      ),
  },
  {
    name: "qrcode.react",
    render: (value) => renderToString(createElement(QRCodeSVG, { value })),
  },
  {
    name: "react-qr-code",
    render: (value) => renderToString(createElement(ReactQRCode, { value })),
  },
];

function time(subject) {
  for (let i = 0; i < 200; i++)
    subject.render(`https://flowbite-react.com/warmup/${i}`);
  const start = process.hrtime.bigint();
  for (let i = 0; i < ITERATIONS; i++)
    subject.render(`https://flowbite-react.com/docs/${i}`);
  return Number(process.hrtime.bigint() - start) / 1e6;
}

const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)];

console.log(
  `node ${process.version} | ${ITERATIONS} renders x ${ROUNDS} rounds, median\n`,
);
console.log(
  "library".padEnd(32) + "renders/s".padStart(12) + "ms/render".padStart(12),
);

for (const subject of subjects) {
  const ms = median(Array.from({ length: ROUNDS }, () => time(subject)));
  const perSecond = Math.round((ITERATIONS / ms) * 1000);
  console.log(
    subject.name.padEnd(32) +
      String(perSecond).padStart(12) +
      (ms / ITERATIONS).toFixed(3).padStart(12),
  );
}

run-render-bench.sh

#!/bin/bash
# flowbite-react's dist resolves `react` from the fork's node_modules while the
# bench resolves its own, which React rejects as two copies. Bundling with one
# alias for each collapses them, and the production define keeps the numbers off
# React's development paths.
set -e
cd "$(dirname "$0")"
HERE=$(pwd)
./node_modules/.bin/esbuild render-bench.mjs --bundle --platform=node --format=cjs \
  --define:process.env.NODE_ENV='"production"' \
  --alias:react="$HERE/node_modules/react" \
  --alias:react-dom="$HERE/node_modules/react-dom" \
  --outfile=out-render-bench.cjs --log-level=error
node out-render-bench.cjs

measure-delta.sh

#!/bin/bash
# What a flowbite-react consumer pays for QRCode: the gzip difference between a
# bundle that imports it and one that does not, plus a check that a consumer who
# leaves it alone pays nothing.
set -e
cd "$(dirname "$0")"
UI=../flowbite-react/packages/ui/dist/index.js

build() {
  echo "export { $2 } from \"$UI\";" > "tmp-$1.js"
  ./node_modules/.bin/esbuild "tmp-$1.js" --bundle --minify --format=esm \
    --external:react --external:react-dom --external:react/jsx-runtime \
    --outfile="out-$1.js" --log-level=error
  rm "tmp-$1.js"
}

build baseline "Progress"
build withqr "Progress, QRCode"

base_min=$(wc -c < out-baseline.js)
base_gz=$(gzip -9 -c out-baseline.js | wc -c)
qr_min=$(wc -c < out-withqr.js)
qr_gz=$(gzip -9 -c out-withqr.js | wc -c)

printf '%-28s %10s %10s\n' bundle minified gzip
printf '%-28s %10s %10s\n' "Progress" "$base_min" "$base_gz"
printf '%-28s %10s %10s\n' "Progress + QRCode" "$qr_min" "$qr_gz"
printf '%-28s %10s %10s\n' "delta" "$((qr_min - base_min))" "$((qr_gz - base_gz))"

if grep -q "flowbite-qr-code" out-baseline.js; then
  echo "tree-shaking: FAIL, QRCode reached a bundle that never imports it"
else
  echo "tree-shaking: ok, no QRCode code in the bundle that never imports it"
fi

measure-size.sh

#!/bin/bash
# Minified + gzip -9 size of each QR library, React kept external.
set -e
cd "$(dirname "$0")"
printf '%-22s %10s %10s\n' library minified gzip
for entry in libs/*.js; do
  name=$(basename "$entry" .js)
  ./node_modules/.bin/esbuild "$entry" --bundle --minify --format=esm \
    --external:react --external:react-dom --external:react/jsx-runtime \
    --outfile="out-lib-$name.js" --log-level=error
  min=$(wc -c < "out-lib-$name.js")
  gz=$(gzip -9 -c "out-lib-$name.js" | wc -c)
  printf '%-22s %10s %10s\n' "$name" "$min" "$gz"
done

scan-check.mjs

// Renders every documented variant, rasterises it and decodes it back. A style
// that a decoder cannot read has no business being in the docs.
import { mkdirSync, writeFileSync } from "node:fs";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { Resvg } from "@resvg/resvg-js";
import jsQR from "jsqr";
import { QRCode } from "../flowbite-react/packages/ui/dist/index.js";

const VALUE = "https://flowbite-react.com";
const LOGO =
  "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E" +
  "%3Crect width='48' height='48' rx='12' fill='%231a56db'/%3E" +
  "%3Cpath d='M15 24h18M24 15v18' stroke='white' stroke-width='5' stroke-linecap='round'/%3E%3C/svg%3E";

// `card` is what sits behind the symbol when it is drawn on a transparent
// background, which is how the docs render it: the page card shows through.
const LIGHT = "#ffffff";
const DARK = "#111827";

const variants = [
  ["default", {}],
  ["dot-square", { dotStyle: "square" }],
  ["dot-circle", { dotStyle: "circle" }],
  ["dot-rounded", { dotStyle: "rounded" }],
  ["corner-square", { cornerStyle: "square" }],
  ["corner-rounded", { cornerStyle: "rounded" }],
  ["corner-extra-rounded", { cornerStyle: "extra-rounded" }],
  ["corner-circle", { cornerStyle: "circle", cornerDotStyle: "circle" }],
  ["colors", { dotColor: "#1a56db", backgroundColor: "#eff6ff" }],
  [
    "colors-split",
    {
      dotStyle: "rounded",
      dotColor: "#111827",
      cornerColor: "#14b8a6",
      cornerDotColor: "#f43f5e",
    },
  ],
  ["ecl-L", { errorCorrectionLevel: "L" }],
  ["ecl-M", { errorCorrectionLevel: "M" }],
  ["ecl-Q", { errorCorrectionLevel: "Q" }],
  ["ecl-H", { errorCorrectionLevel: "H" }],
  ["logo-default", { logo: { src: LOGO } }],
  [
    "logo-max",
    { logo: { src: LOGO, size: 1, margin: 1, radius: 1, hideDots: true } },
  ],
  [
    "logo-styled",
    {
      logo: { src: LOGO, size: 0.6, margin: 1, radius: 1, hideDots: true },
      dotStyle: "rounded",
      cornerStyle: "extra-rounded",
    },
  ],
  // Dark mode: currentColor turns the modules light and the card stays dark, so
  // the symbol is inverted. Scanners have to cope with that for the theme story
  // in the docs to hold.
  ["dark-mode", { dotColor: "#ffffff" }, DARK],
  ["dark-mode-rounded", { dotColor: "#ffffff", dotStyle: "rounded" }, DARK],
  [
    "dark-mode-corners",
    { dotColor: "#ffffff", cornerColor: "#14b8a6", cornerDotColor: "#f43f5e" },
    DARK,
  ],
];

mkdirSync("scan-out", { recursive: true });

let failures = 0;
console.log("variant".padEnd(24) + "decoded".padStart(10));

for (const [name, props, card = LIGHT] of variants) {
  const svg = renderToStaticMarkup(
    createElement(QRCode, { value: VALUE, size: 512, ...props }),
  );
  writeFileSync(`scan-out/${name}.svg`, svg);

  const png = new Resvg(svg, {
    fitTo: { mode: "width", value: 512 },
    background: card,
  }).render();
  writeFileSync(`scan-out/${name}.png`, png.asPng());

  const { width, height, pixels } = png;
  const decoded = jsQR(Uint8ClampedArray.from(pixels), width, height);
  const ok = decoded?.data === VALUE;
  if (!ok) failures++;
  console.log(
    name.padEnd(24) +
      (ok ? "ok" : decoded ? "WRONG VALUE" : "FAILED").padStart(10),
  );
}

console.log(
  `\n${variants.length - failures}/${variants.length} variants decoded`,
);
process.exit(failures ? 1 : 0);

run-scan-check.sh

#!/bin/bash
# Same React duplication problem as the render bench, same fix.
set -e
cd "$(dirname "$0")"
HERE=$(pwd)
./node_modules/.bin/esbuild scan-check.mjs --bundle --platform=node --format=cjs \
  --define:process.env.NODE_ENV='"production"' \
  --alias:react="$HERE/node_modules/react" \
  --alias:react-dom="$HERE/node_modules/react-dom" \
  --external:@resvg/resvg-js \
  --outfile=out-scan-check.cjs --log-level=error
node out-scan-check.cjs

Every number in this issue comes from one of those, so please check them rather
than take my word for any of it.

Lingua principale
TypeScript
Stelle
2.1k
Fork
502
Merge medio
19h 16m
PR unite (30g)
2

Preparare l'ambiente

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di themesberg/flowbite-react

Tutte le issue di themesberg/flowbite-react

Issue simili

Altre issue su TypeScript

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.