From 1f265341733870b591ff1daf63940e6b52dc3f56 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:32:39 +0000 Subject: [PATCH 1/7] feat: add frameSettings for decorative frames with a call-to-action label Adds banner-bottom, banner-top, ticket, bubble and border frame styles. Downloads keep the frame's aspect ratio and offset the raster logo. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016VzuX63Eot8RnJ2BF48sUW --- .../react-qr-code/src/components/frame.tsx | 145 ++++++++++++++++++ packages/react-qr-code/src/constants.ts | 4 + packages/react-qr-code/src/hooks/use-ids.tsx | 3 +- packages/react-qr-code/src/react-qr-code.tsx | 48 ++++-- packages/react-qr-code/src/types/lib.ts | 33 ++++ packages/react-qr-code/src/types/utils.ts | 2 + packages/react-qr-code/src/utils/download.ts | 30 ++-- packages/react-qr-code/src/utils/frame.ts | 83 ++++++++++ 8 files changed, 326 insertions(+), 22 deletions(-) create mode 100644 packages/react-qr-code/src/components/frame.tsx create mode 100644 packages/react-qr-code/src/utils/frame.ts diff --git a/packages/react-qr-code/src/components/frame.tsx b/packages/react-qr-code/src/components/frame.tsx new file mode 100644 index 00000000..224ec654 --- /dev/null +++ b/packages/react-qr-code/src/components/frame.tsx @@ -0,0 +1,145 @@ +import { DEFAULT_FRAME_COLOR, DEFAULT_FRAME_TEXT } from '../constants' +import type { FrameSettings } from '../types/lib' +import { type FrameLayout, isFilledFrame } from '../utils/frame' + +interface FrameProps { + settings: FrameSettings + layout: FrameLayout + maskId: string +} + +// Rough average glyph width relative to font size for bold sans-serif fonts. +const GLYPH_WIDTH_RATIO = 0.6 + +export const FrameBack = ({ settings, layout, maskId }: FrameProps) => { + const color = settings.color ?? DEFAULT_FRAME_COLOR + const { width, height, radius, padding, strokeWidth, labelHeight } = layout + + switch (settings.style) { + case 'banner-bottom': + case 'banner-top': + return ( + + ) + case 'ticket': { + const notchRadius = padding * 0.8 + return ( + <> + + + + + + + + + + ) + } + case 'bubble': { + const bubbleY = height - labelHeight + const pointerSize = padding * 1.1 + const cx = width / 2 + return ( + + + + + + ) + } + case 'border': + return ( + + ) + default: + return null + } +} + +export const FrameLabel = ({ settings, layout }: Omit) => { + const color = settings.color ?? DEFAULT_FRAME_COLOR + const text = settings.text ?? DEFAULT_FRAME_TEXT + const textColor = + settings.textColor ?? + (isFilledFrame(settings.style) || settings.style === 'bubble' ? '#FFFFFF' : color) + const { width, padding, labelHeight, labelY } = layout + + const fontSize = labelHeight * 0.5 + const maxTextWidth = width - padding * 3 + const overflows = text.length * fontSize * GLYPH_WIDTH_RATIO > maxTextWidth + + return ( + <> + {settings.style === 'ticket' && ( + + )} + {text && ( + + {text} + + )} + + ) +} diff --git a/packages/react-qr-code/src/constants.ts b/packages/react-qr-code/src/constants.ts index 8dd46c67..63bebc2a 100644 --- a/packages/react-qr-code/src/constants.ts +++ b/packages/react-qr-code/src/constants.ts @@ -39,6 +39,10 @@ export const DEFAULT_FILENAME = 'react-qr-code' export const GRADIENT_ID = 'react-qr-code-gradient' export const BG_GRADIENT_ID = 'react-qr-code-bg-gradient' +export const FRAME_MASK_ID = 'react-qr-code-frame-mask' + +export const DEFAULT_FRAME_TEXT = 'Scan me!' +export const DEFAULT_FRAME_COLOR = '#000000' // This is *very* rough estimate of max amount of QRCode allowed to be covered. // It is "wrong" in a lot of ways (area is a terrible way to estimate, it diff --git a/packages/react-qr-code/src/hooks/use-ids.tsx b/packages/react-qr-code/src/hooks/use-ids.tsx index 76aa257f..e9cde972 100644 --- a/packages/react-qr-code/src/hooks/use-ids.tsx +++ b/packages/react-qr-code/src/hooks/use-ids.tsx @@ -1,6 +1,6 @@ import { useId } from 'react' -import { BG_GRADIENT_ID, GRADIENT_ID } from '../constants' +import { BG_GRADIENT_ID, FRAME_MASK_ID, GRADIENT_ID } from '../constants' export const useIds = () => { const uuid = useId() @@ -8,5 +8,6 @@ export const useIds = () => { return { gradientId: `${GRADIENT_ID}-${uuid}`, bgGradientId: `${BG_GRADIENT_ID}-${uuid}`, + frameMaskId: `${FRAME_MASK_ID}-${uuid}`, } } diff --git a/packages/react-qr-code/src/react-qr-code.tsx b/packages/react-qr-code/src/react-qr-code.tsx index 751793e3..d811e622 100644 --- a/packages/react-qr-code/src/react-qr-code.tsx +++ b/packages/react-qr-code/src/react-qr-code.tsx @@ -4,8 +4,10 @@ import { Background } from './components/background' import { DataModules } from './components/data-modules' import { FinderPatternsInner } from './components/finder-patterns-inner' import { FinderPatternsOuter } from './components/finder-patterns-outer' +import { FrameBack, FrameLabel } from './components/frame' import { Gradient } from './components/gradient' import { + DEFAULT_BGCOLOR, DEFAULT_FILENAME, DEFAULT_LEVEL, DEFAULT_MINVERSION, @@ -15,6 +17,7 @@ import { useIds } from './hooks/use-ids' import { useQRCode } from './hooks/use-qr-code' import type { DownloadOptions, ReactQRCodeProps, ReactQRCodeRef } from './types/lib' import { downloadRaster, downloadSVG } from './utils/download' +import { getFrameLayout } from './utils/frame' import { excavateModules } from './utils/qr-code' const ReactQRCode = forwardRef((props, ref) => { @@ -31,11 +34,12 @@ const ReactQRCode = forwardRef((props, ref) => finderPatternInnerSettings, dataModulesSettings, imageSettings, + frameSettings, svgProps, } = props const svgRef = useRef(null) - const { gradientId, bgGradientId } = useIds() + const { gradientId, bgGradientId, frameMaskId } = useIds() const { margin, cells, numCells, calculatedImageSettings } = useQRCode({ value, level, @@ -46,6 +50,10 @@ const ReactQRCode = forwardRef((props, ref) => size, }) + const frameLayout = frameSettings ? getFrameLayout(frameSettings.style, numCells) : null + const viewBoxWidth = frameLayout?.width ?? numCells + const viewBoxHeight = frameLayout?.height ?? numCells + useImperativeHandle(ref, () => ({ svg: svgRef.current, download: ({ @@ -68,6 +76,7 @@ const ReactQRCode = forwardRef((props, ref) => size, numCells, margin, + frameLayout, }) } }, @@ -102,19 +111,12 @@ const ReactQRCode = forwardRef((props, ref) => gradientId, } - return ( - + const qrCode = ( + <> @@ -122,6 +124,28 @@ const ReactQRCode = forwardRef((props, ref) => {image} + + ) + + return ( + + {frameSettings && frameLayout ? ( + <> + + {qrCode} + + + ) : ( + qrCode + )} ) }) diff --git a/packages/react-qr-code/src/types/lib.ts b/packages/react-qr-code/src/types/lib.ts index 4da18f01..72d79328 100644 --- a/packages/react-qr-code/src/types/lib.ts +++ b/packages/react-qr-code/src/types/lib.ts @@ -115,6 +115,34 @@ export interface FinderPatternInnerSettings { style?: FinderPatternInnerStyle } +export type FrameStyle = 'banner-bottom' | 'banner-top' | 'ticket' | 'bubble' | 'border' + +export interface FrameSettings { + /** + * The frame style. + */ + style: FrameStyle + /** + * The label rendered in the frame. + * @defaultValue 'Scan me!' + */ + text?: string + /** + * The frame color. + * @defaultValue #000000 + */ + color?: string + /** + * The label color. Defaults to white on filled frames and to `color` on outlined ones. + */ + textColor?: string + /** + * The label font family. + * @defaultValue sans-serif + */ + fontFamily?: string +} + export type DownloadFileFormat = 'svg' | 'png' | 'jpeg' export interface DownloadOptions { name?: string @@ -193,6 +221,11 @@ export interface ReactQRCodeProps { * The settings for the embedded image. */ imageSettings?: ImageSettings + /** + * Draws a decorative frame with a call-to-action label around the QR code. + * `size` applies to the frame width; the height grows to fit the label. + */ + frameSettings?: FrameSettings /** * Optional props to pass to the SVG element. */ diff --git a/packages/react-qr-code/src/types/utils.ts b/packages/react-qr-code/src/types/utils.ts index 3068d7e9..2491e424 100644 --- a/packages/react-qr-code/src/types/utils.ts +++ b/packages/react-qr-code/src/types/utils.ts @@ -1,5 +1,6 @@ import { type RefObject } from 'react' +import type { FrameLayout } from '../utils/frame' import type { CrossOrigin, DataModulesSettings, @@ -64,6 +65,7 @@ export interface DownloadRasterProps { size: number numCells: number margin: number + frameLayout: FrameLayout | null } export interface CalculatedImageSettings { diff --git a/packages/react-qr-code/src/utils/download.ts b/packages/react-qr-code/src/utils/download.ts index 228ecd79..7f68763a 100644 --- a/packages/react-qr-code/src/utils/download.ts +++ b/packages/react-qr-code/src/utils/download.ts @@ -1,11 +1,17 @@ import type { DownloadRasterProps, DownloadSVGProps } from '../types/utils' +// Frames make the SVG non-square, so scale the height by the viewBox aspect ratio. +const getFileHeight = (svg: SVGSVGElement, fileSize: number) => { + const { width, height } = svg.viewBox.baseVal ?? {} + return width && height ? Math.round((fileSize * height) / width) : fileSize +} + export const downloadSVG = ({ svgRef, fileSize, fileName }: DownloadSVGProps) => { if (!svgRef.current) return const clonedSvg = svgRef.current.cloneNode(true) as SVGSVGElement clonedSvg.setAttribute('width', fileSize.toString()) - clonedSvg.setAttribute('height', fileSize.toString()) + clonedSvg.setAttribute('height', getFileHeight(svgRef.current, fileSize).toString()) const serializer = new XMLSerializer() const svgBlob = new Blob([serializer.serializeToString(clonedSvg)], { @@ -31,6 +37,7 @@ export const downloadRaster = ({ size, numCells, margin, + frameLayout, }: DownloadRasterProps) => { if (!svgRef.current) return @@ -39,7 +46,8 @@ export const downloadRaster = ({ if (!ctx) return canvas.width = fileSize - canvas.height = fileSize + const fileHeight = getFileHeight(svgRef.current, fileSize) + canvas.height = fileHeight const svgData = new XMLSerializer().serializeToString(svgRef.current) const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }) @@ -50,7 +58,7 @@ export const downloadRaster = ({ qrImg.src = svgUrl qrImg.onload = () => { - ctx.drawImage(qrImg, 0, 0, fileSize, fileSize) + ctx.drawImage(qrImg, 0, 0, fileSize, fileHeight) URL.revokeObjectURL(svgUrl) if (imageSettings?.src && calculatedImageSettings) { @@ -59,16 +67,20 @@ export const downloadRaster = ({ logoImg.src = imageSettings.src logoImg.onload = () => { + const viewBoxWidth = frameLayout?.width ?? numCells + const offsetX = frameLayout?.qrX ?? 0 + const offsetY = frameLayout?.qrY ?? 0 const ratio = fileSize / size - const scale = numCells / fileSize + const scale = viewBoxWidth / fileSize + const qrSize = numCells / scale - const logoSize = imageSettings.width * ratio + const logoSize = imageSettings.width * ratio * (numCells / viewBoxWidth) const logoX = imageSettings.x - ? (calculatedImageSettings.x + margin) / scale - : (fileSize - logoSize) / 2 + ? (calculatedImageSettings.x + margin + offsetX) / scale + : offsetX / scale + (qrSize - logoSize) / 2 const logoY = imageSettings.y - ? (calculatedImageSettings.y + margin) / scale - : (fileSize - logoSize) / 2 + ? (calculatedImageSettings.y + margin + offsetY) / scale + : offsetY / scale + (qrSize - logoSize) / 2 ctx.drawImage(logoImg, logoX, logoY, logoSize, logoSize) const imageType = fileFormat === 'png' ? 'image/png' : 'image/jpeg' diff --git a/packages/react-qr-code/src/utils/frame.ts b/packages/react-qr-code/src/utils/frame.ts new file mode 100644 index 00000000..a810d59a --- /dev/null +++ b/packages/react-qr-code/src/utils/frame.ts @@ -0,0 +1,83 @@ +import type { FrameStyle } from '../types/lib' + +export interface FrameLayout { + width: number + height: number + qrX: number + qrY: number + padding: number + radius: number + labelY: number + labelHeight: number + // Y position of the ticket divider. + dividerY: number + // Stroke width for outlined styles. + strokeWidth: number +} + +const FILLED_STYLES: FrameStyle[] = ['banner-bottom', 'banner-top', 'ticket'] + +export const isFilledFrame = (style: FrameStyle) => FILLED_STYLES.includes(style) + +/** + * Computes the frame geometry, in module units, around a QR code of `numCells`. + */ +export const getFrameLayout = (style: FrameStyle, numCells: number): FrameLayout => { + const padding = numCells * 0.07 + const radius = numCells * 0.08 + const labelHeight = numCells * 0.24 + const strokeWidth = padding * 0.45 + const width = numCells + padding * 2 + + const base = { + width, + qrX: padding, + qrY: padding, + padding, + radius, + labelHeight, + dividerY: 0, + strokeWidth, + } + + switch (style) { + case 'banner-top': + return { + ...base, + qrY: labelHeight, + height: labelHeight + numCells + padding, + labelY: labelHeight / 2, + } + case 'ticket': { + const dividerY = numCells + padding * 1.6 + return { + ...base, + height: dividerY + labelHeight, + labelY: dividerY + labelHeight / 2, + dividerY, + } + } + case 'bubble': { + const gap = padding * 1.4 + const bubbleY = width + gap + return { + ...base, + height: bubbleY + labelHeight, + labelY: bubbleY + labelHeight / 2, + } + } + case 'border': + return { + ...base, + height: width + labelHeight, + labelY: width + labelHeight / 2, + } + case 'banner-bottom': + default: + return { + ...base, + height: numCells + padding + labelHeight, + labelY: numCells + padding + labelHeight / 2, + } + } +} From f8cf5a6abd5175cdba0f50699b3a94580330a8bd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:58:32 +0000 Subject: [PATCH 2/7] feat: frame docs, demo, quiet zone and label-less frames - Keep a light panel with a 2-module minimum quiet zone between the code and the frame so codes with a small margin stay scannable. - Collapse the label area when text is an empty string. - Add FrameSettings API page, Frame example page, a Frame section in the interactive demo, llms.txt entries, README feature and a changeset. - Add unit tests for frame layout, rendering and download sizing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016VzuX63Eot8RnJ2BF48sUW --- .changeset/qr-code-frames.md | 5 + README.md | 1 + apps/docs/public/llms-full.txt | 30 +++ apps/docs/public/llms.txt | 1 + apps/docs/src/app/examples/frame/page.tsx | 43 +++++ apps/docs/src/app/frame-settings/page.tsx | 62 +++++++ .../docs/src/app/react-qr-code-props/page.tsx | 13 ++ apps/docs/src/app/sitemap.ts | 2 + apps/docs/src/components/demo/demo.tsx | 14 ++ .../src/components/demo/frame-settings.tsx | 113 +++++++++++ apps/docs/src/components/demos/frame.tsx | 113 +++++++++++ apps/docs/src/components/prop-type-tag.tsx | 2 + apps/docs/src/config/navigation.ts | 8 + apps/docs/src/types/props.ts | 2 + packages/react-qr-code/README.md | 1 + .../react-qr-code/src/components/frame.tsx | 59 +++--- packages/react-qr-code/src/constants.ts | 2 + .../react-qr-code/src/react-qr-code.test.tsx | 175 ++++++++++++++++++ packages/react-qr-code/src/react-qr-code.tsx | 16 +- packages/react-qr-code/src/types/lib.ts | 7 +- .../react-qr-code/src/utils/download.test.ts | 22 +++ packages/react-qr-code/src/utils/download.ts | 2 +- .../react-qr-code/src/utils/frame.test.ts | 91 +++++++++ packages/react-qr-code/src/utils/frame.ts | 63 +++++-- 24 files changed, 792 insertions(+), 55 deletions(-) create mode 100644 .changeset/qr-code-frames.md create mode 100644 apps/docs/src/app/examples/frame/page.tsx create mode 100644 apps/docs/src/app/frame-settings/page.tsx create mode 100644 apps/docs/src/components/demo/frame-settings.tsx create mode 100644 apps/docs/src/components/demos/frame.tsx create mode 100644 packages/react-qr-code/src/utils/download.test.ts create mode 100644 packages/react-qr-code/src/utils/frame.test.ts diff --git a/.changeset/qr-code-frames.md b/.changeset/qr-code-frames.md new file mode 100644 index 00000000..fe6f64a0 --- /dev/null +++ b/.changeset/qr-code-frames.md @@ -0,0 +1,5 @@ +--- +'@lglab/react-qr-code': minor +--- + +Add `frameSettings` to draw a frame with a call-to-action label around the QR code. Available styles: `banner-bottom`, `banner-top`, `ticket`, `bubble` and `border`. diff --git a/README.md b/README.md index dd12d08b..20b3ea8b 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A highly customizable and lightweight QR code generator for React applications. ## Quick Features - **Highly Customizable** - Style the finder patterns, modules, and colors exactly how you want. +- **Frames** - Wrap the QR code in a frame with a call-to-action label like "Scan me!". - **Performance Optimized** - Generates QR codes efficiently without sacrificing quality. - **SVG-Based Rendering** - Crisp and scalable output for web and print - **Developer-Friendly** - Built with TypeScript, easy to use, and well-documented. diff --git a/apps/docs/public/llms-full.txt b/apps/docs/public/llms-full.txt index 7f2fbd94..2c13fdcc 100644 --- a/apps/docs/public/llms-full.txt +++ b/apps/docs/public/llms-full.txt @@ -24,6 +24,7 @@ The main component exported by the library. | `finderPatternOuterSettings` | `FinderPatternOuterSettings` | - | Configuration for outer finder patterns. | | `finderPatternInnerSettings` | `FinderPatternInnerSettings` | - | Configuration for inner finder patterns. | | `imageSettings` | `ImageSettings` | - | Settings for an embedded logo or image. | +| `frameSettings` | `FrameSettings` | - | Draws a frame with a call-to-action label around the QR code. `size` then applies to the frame width; the height grows to fit the label. | | `svgProps` | `React.SVGProps` | - | Additional props passed to the root SVG element. | --- @@ -75,6 +76,21 @@ Includes all `FinderPatternOuterStyle` plus: `'diamond'`, `'star'`, `'heart'`, ` | `opacity` | `number` | `1` | Image opacity (0-1). | | `crossOrigin` | `CrossOrigin` | - | CORS setting for the image. | +### FrameSettings + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `style` | `FrameStyle` | - | **Required.** Style of the frame. | +| `text` | `string` | `'Scan me!'` | Label rendered in the frame. Long labels are condensed to fit. An empty string hides it and the frame wraps the code evenly. | +| `color` | `string` | `'#000000'` | Frame color. | +| `textColor` | `string` | - | Label color. Defaults to white on filled frames and to `color` on `'border'`. | +| `fontFamily` | `string` | `'sans-serif'` | Label font family. | + +**Available Styles (`FrameStyle`):** +`'banner-bottom'`, `'banner-top'`, `'ticket'`, `'bubble'`, `'border'` + +When a frame is set and no `background` is passed, the QR code gets a white background so it stays scannable on dark frames. + ### GradientSettings | Property | Type | Default | Description | @@ -137,3 +153,17 @@ import { ReactQRCode } from '@lglab/react-qr-code'; /> ``` +### Frame +```tsx + +``` + diff --git a/apps/docs/public/llms.txt b/apps/docs/public/llms.txt index efd13a25..09e77872 100644 --- a/apps/docs/public/llms.txt +++ b/apps/docs/public/llms.txt @@ -17,6 +17,7 @@ This site serves as the central hub for the `@lglab/react-qr-code` library, prov - [Finder Pattern Outer Settings](https://reactqrcode.com/finder-pattern-outer-settings) - [Gradient Settings](https://reactqrcode.com/gradient-settings) - [Image Settings](https://reactqrcode.com/image-settings) +- [Frame Settings](https://reactqrcode.com/frame-settings) - [Examples](https://reactqrcode.com/examples) ## External Links diff --git a/apps/docs/src/app/examples/frame/page.tsx b/apps/docs/src/app/examples/frame/page.tsx new file mode 100644 index 00000000..eb542b49 --- /dev/null +++ b/apps/docs/src/app/examples/frame/page.tsx @@ -0,0 +1,43 @@ +import type { Metadata } from 'next' +import Link from 'next/link' + +import { + DemoCustom, + DemoStyles, + customCodeBlock, + stylesCodeBlock, +} from '@/components/demos/frame' +import { PageHeading } from '@/components/page-heading' +import { ExampleTabs } from '@/components/ui/example-tabs' +import { TypographyH3, TypographyP } from '@/components/ui/typography' + +export const metadata: Metadata = { + title: 'Frame Example', + description: + 'Example showing how to add a frame with a "Scan me!" call-to-action label around QR codes using the FrameSettings options in @lglab/react-qr-code.', +} + +export default function Page() { + return ( + <> + + + The @lglab/react-qr-code library can draw a frame with a call-to-action label + around your QR codes, making them easier to spot on print and screens. Frames are + part of the SVG, so they are included when you download the QR code. See{' '} + + Frame Settings + {' '} + api reference for more information. + + + Styles + } /> + +
+ + Custom label and colors + } /> + + ) +} diff --git a/apps/docs/src/app/frame-settings/page.tsx b/apps/docs/src/app/frame-settings/page.tsx new file mode 100644 index 00000000..2ae1decd --- /dev/null +++ b/apps/docs/src/app/frame-settings/page.tsx @@ -0,0 +1,62 @@ +import type { Metadata } from 'next' + +import { PageHeading } from '@/components/page-heading' +import { PropsTable } from '@/components/props-table' +import { TypographyP } from '@/components/ui/typography' +import type { Prop } from '@/types/props' + +export const metadata: Metadata = { + title: 'FrameSettings', + description: + 'Configuration options for FrameSettings in @lglab/react-qr-code, used to draw a frame with a call-to-action label such as "Scan me!" around QR codes.', +} + +const props: Prop[] = [ + { + name: 'style', + type: 'FrameStyle', + description: 'The style of the frame.', + required: true, + possibleValues: ['banner-bottom', 'banner-top', 'border', 'bubble', 'ticket'], + }, + { + name: 'text', + type: 'string', + description: + 'The label rendered in the frame. Long labels are condensed to fit the frame width. Pass an empty string to hide the label, the frame then wraps the QR Code evenly.', + defaultValue: 'Scan me!', + }, + { + name: 'color', + type: 'string', + description: 'The frame color.', + defaultValue: '#000000', + }, + { + name: 'textColor', + type: 'string', + description: + 'The label color. Defaults to white on filled frames (banner-bottom, banner-top, ticket, bubble) and to the frame color on border.', + }, + { + name: 'fontFamily', + type: 'string', + description: 'The label font family.', + defaultValue: 'sans-serif', + }, +] + +export default function Page() { + return ( + <> + + + These are the properties you can use to draw a frame with a call-to-action label + around the QR Code. When a frame is set, the size prop applies to the frame width + and the height grows to fit the label. The QR Code gets a white background by + default so it stays scannable on dark frames; pass a background to override it. + + + + ) +} diff --git a/apps/docs/src/app/react-qr-code-props/page.tsx b/apps/docs/src/app/react-qr-code-props/page.tsx index 2ccf5252..23d9c2d3 100644 --- a/apps/docs/src/app/react-qr-code-props/page.tsx +++ b/apps/docs/src/app/react-qr-code-props/page.tsx @@ -105,6 +105,19 @@ const props: Prop[] = [ ), }, + { + name: 'frameSettings', + type: 'FrameSettings', + description: ( + <> + Draws a frame with a call-to-action label around the QR code. When set, size + applies to the frame width and the height grows to fit the label. See{' '} + + FrameSettings + + + ), + }, { name: 'level', type: 'ErrorCorrectionLevel', diff --git a/apps/docs/src/app/sitemap.ts b/apps/docs/src/app/sitemap.ts index 1778be58..b7f68b0d 100644 --- a/apps/docs/src/app/sitemap.ts +++ b/apps/docs/src/app/sitemap.ts @@ -17,12 +17,14 @@ const PAGES = [ { path: '/finder-pattern-inner-settings', priority: 0.7 }, { path: '/gradient-settings', priority: 0.7 }, { path: '/image-settings', priority: 0.7 }, + { path: '/frame-settings', priority: 0.7 }, { path: '/ref-api', priority: 0.7 }, { path: '/examples/basic', priority: 0.7 }, { path: '/examples/data-modules', priority: 0.7 }, { path: '/examples/finder-patterns', priority: 0.7 }, { path: '/examples/background', priority: 0.7 }, { path: '/examples/image', priority: 0.7 }, + { path: '/examples/frame', priority: 0.7 }, { path: '/examples/download', priority: 0.7 }, ] diff --git a/apps/docs/src/components/demo/demo.tsx b/apps/docs/src/components/demo/demo.tsx index 5e0f42ca..3fb164f0 100644 --- a/apps/docs/src/components/demo/demo.tsx +++ b/apps/docs/src/components/demo/demo.tsx @@ -1,6 +1,7 @@ 'use client' import { + type FrameSettings, ReactQRCode, type ReactQRCodeProps, type ReactQRCodeRef, @@ -19,6 +20,7 @@ import { Colors } from './colors' import { DataModules } from './data-modules' import { FinderPatternInner } from './finder-pattern-inner' import { FinderPatternOuter } from './finder-pattern-outer' +import { FrameSettingsDemo } from './frame-settings' import { ImageSettingsDemo } from './image-settings' import { MainSettings } from './main-settings' @@ -32,6 +34,12 @@ export const defaultImageSettings = { y: undefined, } +export const defaultFrameSettings: FrameSettings = { + style: 'ticket', + text: 'Scan me!', + color: '#000000', +} + export const defaultBgColor = '#FFFFFF' export const defaultBgGradient = { type: 'linear', @@ -120,6 +128,12 @@ export const Demo = () => { + + Frame + + + +
diff --git a/apps/docs/src/components/demo/frame-settings.tsx b/apps/docs/src/components/demo/frame-settings.tsx new file mode 100644 index 00000000..22ddea47 --- /dev/null +++ b/apps/docs/src/components/demo/frame-settings.tsx @@ -0,0 +1,113 @@ +import { + type FrameSettings, + type FrameStyle, + type ReactQRCodeProps, +} from '@lglab/react-qr-code' +import { type Dispatch, useState } from 'react' + +import { FormCheckbox, FormField } from '@/components/ui/form-fields' +import { Input } from '@/components/ui/input' + +import { Button } from '../ui/button' +import { ColorPicker } from '../ui/color-picker' +import { defaultFrameSettings } from './demo' + +interface FrameSettingsDemoProps { + qrProps: ReactQRCodeProps + setQrProps: Dispatch> +} + +const styles: FrameStyle[] = ['banner-bottom', 'banner-top', 'border', 'bubble', 'ticket'] + +export const FrameSettingsDemo = ({ qrProps, setQrProps }: FrameSettingsDemoProps) => { + const [includeFrame, setIncludeFrame] = useState(!!qrProps.frameSettings) + const [customTextColor, setCustomTextColor] = useState( + !!qrProps.frameSettings?.textColor, + ) + + const onValueChange = (key: keyof FrameSettings, value: string | undefined) => { + setQrProps((prevProps) => ({ + ...prevProps, + frameSettings: { + ...(prevProps.frameSettings ?? defaultFrameSettings), + [key]: value, + } as FrameSettings, + })) + } + + const onIncludeFrameChange = (checked: boolean) => { + setIncludeFrame(checked) + setQrProps((prevProps) => ({ + ...prevProps, + frameSettings: checked ? defaultFrameSettings : undefined, + })) + } + + const onCustomTextColorChange = (checked: boolean) => { + setCustomTextColor(checked) + onValueChange('textColor', checked ? '#FFFFFF' : undefined) + } + + return ( + <> + onIncludeFrameChange(checked as boolean)} + /> + +
+ {styles.map((style) => ( + + ))} +
+
+ + onValueChange('text', e.target.value)} + /> + + + onValueChange('color', value)} + /> + + onCustomTextColorChange(checked as boolean)} + /> + + onValueChange('textColor', value)} + /> + + + onValueChange('fontFamily', e.target.value || undefined)} + /> + + + ) +} diff --git a/apps/docs/src/components/demos/frame.tsx b/apps/docs/src/components/demos/frame.tsx new file mode 100644 index 00000000..7092c120 --- /dev/null +++ b/apps/docs/src/components/demos/frame.tsx @@ -0,0 +1,113 @@ +'use client' + +import { type FrameStyle, ReactQRCode } from '@lglab/react-qr-code' + +const styles: FrameStyle[] = ['banner-bottom', 'banner-top', 'ticket', 'bubble', 'border'] + +export const stylesCodeBlock = ` +import { type FrameStyle, ReactQRCode } from '@lglab/react-qr-code' + +const styles: FrameStyle[] = ['banner-bottom', 'banner-top', 'ticket', 'bubble', 'border'] + +export const Demo = () => { + return ( +
+ {styles.map((style) => ( + + ))} +
+ ) +} +` + +export const DemoStyles = () => { + return ( +
+ {styles.map((style) => ( + + ))} +
+ ) +} + +export const customCodeBlock = ` +import { ReactQRCode } from '@lglab/react-qr-code' + +export const Demo = () => { + return ( +
+ + +
+ ) +} +` + +export const DemoCustom = () => { + return ( +
+ + +
+ ) +} diff --git a/apps/docs/src/components/prop-type-tag.tsx b/apps/docs/src/components/prop-type-tag.tsx index d491aaf8..0b9002b1 100644 --- a/apps/docs/src/components/prop-type-tag.tsx +++ b/apps/docs/src/components/prop-type-tag.tsx @@ -30,6 +30,8 @@ export const PropTypeTag = ({ type }: { type: PropType }) => { '(options: DownloadOptions) => void': 'bg-purple-100 text-purple-800', 'React.RefObject': 'bg-purple-100 text-purple-800', ImageSettings: 'bg-purple-100 text-purple-800', + FrameSettings: 'bg-purple-100 text-purple-800', + FrameStyle: 'bg-purple-100 text-purple-800', } return ( diff --git a/apps/docs/src/config/navigation.ts b/apps/docs/src/config/navigation.ts index 32b79a68..650a66d9 100644 --- a/apps/docs/src/config/navigation.ts +++ b/apps/docs/src/config/navigation.ts @@ -55,6 +55,10 @@ export const mainNav: MenuItemProps[] = [ title: 'ImageSettings', url: '/image-settings', }, + { + title: 'FrameSettings', + url: '/frame-settings', + }, { title: 'ReactQRCodeRef', url: '/ref-api', @@ -85,6 +89,10 @@ export const mainNav: MenuItemProps[] = [ title: 'Image', url: '/examples/image', }, + { + title: 'Frame', + url: '/examples/frame', + }, { title: 'Download', url: '/examples/download', diff --git a/apps/docs/src/types/props.ts b/apps/docs/src/types/props.ts index 36563930..3dc711a2 100644 --- a/apps/docs/src/types/props.ts +++ b/apps/docs/src/types/props.ts @@ -27,6 +27,8 @@ export type PropType = | 'React.RefObject' | 'SVGSVGElement' | 'ImageSettings' + | 'FrameSettings' + | 'FrameStyle' export interface Prop { name: string diff --git a/packages/react-qr-code/README.md b/packages/react-qr-code/README.md index 51b49b3e..ebc75dd2 100644 --- a/packages/react-qr-code/README.md +++ b/packages/react-qr-code/README.md @@ -10,6 +10,7 @@ A highly customizable and lightweight QR code generator for React applications. ## Quick Features - **Highly Customizable** - Style the finder patterns, modules, and colors exactly how you want. +- **Frames** - Wrap the QR code in a frame with a call-to-action label like "Scan me!". - **Performance Optimized** - Generates QR codes efficiently without sacrificing quality. - **SVG-Based Rendering** - Crisp and scalable output for web and print - **Developer-Friendly** - Built with TypeScript, easy to use, and well-documented. diff --git a/packages/react-qr-code/src/components/frame.tsx b/packages/react-qr-code/src/components/frame.tsx index 224ec654..2931a693 100644 --- a/packages/react-qr-code/src/components/frame.tsx +++ b/packages/react-qr-code/src/components/frame.tsx @@ -15,19 +15,30 @@ export const FrameBack = ({ settings, layout, maskId }: FrameProps) => { const color = settings.color ?? DEFAULT_FRAME_COLOR const { width, height, radius, padding, strokeWidth, labelHeight } = layout + const outline = ( + + ) + const filled = ( + + ) + const hasLabel = labelHeight > 0 + switch (settings.style) { case 'banner-bottom': case 'banner-top': - return ( - - ) + return filled case 'ticket': { + if (!hasLabel) return filled const notchRadius = padding * 0.8 return ( <> @@ -50,21 +61,13 @@ export const FrameBack = ({ settings, layout, maskId }: FrameProps) => { ) } case 'bubble': { + if (!hasLabel) return outline const bubbleY = height - labelHeight const pointerSize = padding * 1.1 const cx = width / 2 return ( - - + + {outline} { ) } case 'border': - return ( - - ) + return outline default: return null } @@ -112,7 +103,7 @@ export const FrameLabel = ({ settings, layout }: Omit) => return ( <> - {settings.style === 'ticket' && ( + {settings.style === 'ticket' && text && ( { }) }) + describe('Frame settings', () => { + const styles: FrameStyle[] = [ + 'banner-bottom', + 'banner-top', + 'ticket', + 'bubble', + 'border', + ] + + it('renders no frame when not provided', () => { + render() + + expect(screen.queryByTestId('frame')).not.toBeInTheDocument() + expect(screen.queryByTestId('frame-label')).not.toBeInTheDocument() + }) + + it.each(styles)('renders the %s frame with the default label', (style) => { + render() + + expect(screen.getByTestId('frame')).toBeInTheDocument() + expect(screen.getByTestId('frame-label')).toHaveTextContent(DEFAULT_FRAME_TEXT) + }) + + it.each(styles)('keeps the width and grows the height for %s', (style) => { + render() + + const svg = screen.getByRole('img') + const [, , vbWidth, vbHeight] = svg.getAttribute('viewBox')!.split(' ').map(Number) + expect(svg).toHaveAttribute('width', '200') + expect(Number(svg.getAttribute('height'))).toBeCloseTo((200 * vbHeight) / vbWidth) + expect(vbHeight).toBeGreaterThan(vbWidth) + }) + + it('renders a custom label and colors', () => { + render( + , + ) + + expect(screen.getByTestId('frame')).toHaveAttribute('fill', '#0b4d3c') + const label = screen.getByTestId('frame-label') + expect(label).toHaveTextContent('Menu') + expect(label).toHaveAttribute('fill', '#ffcc00') + expect(label).toHaveAttribute('font-family', 'serif') + }) + + it('uses the frame color for the label of outlined frames', () => { + render( + , + ) + + expect(screen.getByTestId('frame')).toHaveAttribute('stroke', '#c40000') + expect(screen.getByTestId('frame-label')).toHaveAttribute('fill', '#c40000') + }) + + it.each(styles)( + 'renders a square %s frame without a label when text is empty', + (style) => { + const { container } = render( + , + ) + + const [, , vbWidth, vbHeight] = screen + .getByRole('img') + .getAttribute('viewBox')! + .split(' ') + .map(Number) + expect(vbHeight).toBeCloseTo(vbWidth) + expect(screen.getByTestId('frame')).toBeInTheDocument() + expect(screen.queryByTestId('frame-label')).not.toBeInTheDocument() + expect(container.querySelector('mask')).not.toBeInTheDocument() + expect(container.querySelector('line')).not.toBeInTheDocument() + }, + ) + + it('fits long labels to the frame width', () => { + render( + , + ) + + const label = screen.getByTestId('frame-label') + expect(label).toHaveAttribute('textLength') + expect(label).toHaveAttribute('lengthAdjust', 'spacingAndGlyphs') + }) + + it('renders a white panel behind the code by default', () => { + render() + + expect(screen.getByTestId('frame-panel')).toHaveAttribute('fill', DEFAULT_BGCOLOR) + expect(screen.queryByTestId('background')).not.toBeInTheDocument() + }) + + it('uses a solid background for the panel', () => { + render( + , + ) + + expect(screen.getByTestId('frame-panel')).toHaveAttribute('fill', '#eeeeee') + expect(screen.getByTestId('background')).toHaveAttribute('fill', '#eeeeee') + }) + + it('keeps a quiet zone around codes without a margin', () => { + render( + , + ) + + const panel = screen.getByTestId('frame-panel') + const [, x, y] = screen + .getByTestId('data-modules') + .closest('g')! + .getAttribute('transform')! + .match(/translate\(([\d.]+) ([\d.]+)\)/)! + .map(Number) + expect(x - Number(panel.getAttribute('x'))).toBeCloseTo(FRAME_MIN_QUIET_ZONE) + expect(y - Number(panel.getAttribute('y'))).toBeCloseTo(FRAME_MIN_QUIET_ZONE) + }) + + it('offsets the code inside the frame', () => { + render() + + const group = screen.getByTestId('data-modules').closest('g') + expect(group?.getAttribute('transform')).toMatch(/^translate\(/) + }) + + it('uses unique mask ids for ticket frames', () => { + const { container } = render( + <> + + + , + ) + + const ids = [...container.querySelectorAll('mask')].map((mask) => mask.id) + expect(ids).toHaveLength(2) + expect(new Set(ids).size).toBe(2) + }) + + it('passes the frame layout to raster downloads', () => { + const ref = createRef() + render() + + ref.current?.download({ format: 'png', size: 300 }) + expect(vi.mocked(downloadRaster)).toHaveBeenLastCalledWith( + expect.objectContaining({ + frameLayout: expect.objectContaining({ qrX: expect.any(Number) }), + }), + ) + }) + }) + describe('ref functionality', () => { it('provides access to SVG element', () => { const ref = createRef() diff --git a/packages/react-qr-code/src/react-qr-code.tsx b/packages/react-qr-code/src/react-qr-code.tsx index d811e622..4b05192a 100644 --- a/packages/react-qr-code/src/react-qr-code.tsx +++ b/packages/react-qr-code/src/react-qr-code.tsx @@ -50,7 +50,9 @@ const ReactQRCode = forwardRef((props, ref) => size, }) - const frameLayout = frameSettings ? getFrameLayout(frameSettings.style, numCells) : null + const frameLayout = frameSettings + ? getFrameLayout(frameSettings.style, numCells, margin, frameSettings.text !== '') + : null const viewBoxWidth = frameLayout?.width ?? numCells const viewBoxHeight = frameLayout?.height ?? numCells @@ -115,8 +117,7 @@ const ReactQRCode = forwardRef((props, ref) => <> @@ -140,6 +141,15 @@ const ReactQRCode = forwardRef((props, ref) => {frameSettings && frameLayout ? ( <> + {qrCode} diff --git a/packages/react-qr-code/src/types/lib.ts b/packages/react-qr-code/src/types/lib.ts index 72d79328..88f05c47 100644 --- a/packages/react-qr-code/src/types/lib.ts +++ b/packages/react-qr-code/src/types/lib.ts @@ -123,7 +123,8 @@ export interface FrameSettings { */ style: FrameStyle /** - * The label rendered in the frame. + * The label rendered in the frame. Long labels are condensed to fit the frame + * width. An empty string hides the label and the frame wraps the code evenly. * @defaultValue 'Scan me!' */ text?: string @@ -223,7 +224,9 @@ export interface ReactQRCodeProps { imageSettings?: ImageSettings /** * Draws a decorative frame with a call-to-action label around the QR code. - * `size` applies to the frame width; the height grows to fit the label. + * `size` applies to the frame width; the height grows to fit the label. The + * code sits on a light panel (the `background` color, white by default) with + * a quiet zone of at least 2 modules so it stays scannable. */ frameSettings?: FrameSettings /** diff --git a/packages/react-qr-code/src/utils/download.test.ts b/packages/react-qr-code/src/utils/download.test.ts new file mode 100644 index 00000000..640c5092 --- /dev/null +++ b/packages/react-qr-code/src/utils/download.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' + +import { getFileHeight } from './download' + +const svgWithViewBox = (width: number, height: number) => + ({ viewBox: { baseVal: { width, height } } }) as unknown as SVGSVGElement + +describe('getFileHeight', () => { + it('keeps square codes square', () => { + expect(getFileHeight(svgWithViewBox(33, 33), 500)).toBe(500) + }) + + it('scales the height by the frame aspect ratio', () => { + expect(getFileHeight(svgWithViewBox(40, 50), 400)).toBe(500) + }) + + it('falls back to the file size without a viewBox', () => { + expect( + getFileHeight({ viewBox: { baseVal: null } } as unknown as SVGSVGElement, 300), + ).toBe(300) + }) +}) diff --git a/packages/react-qr-code/src/utils/download.ts b/packages/react-qr-code/src/utils/download.ts index 7f68763a..3eb0ffa2 100644 --- a/packages/react-qr-code/src/utils/download.ts +++ b/packages/react-qr-code/src/utils/download.ts @@ -1,7 +1,7 @@ import type { DownloadRasterProps, DownloadSVGProps } from '../types/utils' // Frames make the SVG non-square, so scale the height by the viewBox aspect ratio. -const getFileHeight = (svg: SVGSVGElement, fileSize: number) => { +export const getFileHeight = (svg: SVGSVGElement, fileSize: number) => { const { width, height } = svg.viewBox.baseVal ?? {} return width && height ? Math.round((fileSize * height) / width) : fileSize } diff --git a/packages/react-qr-code/src/utils/frame.test.ts b/packages/react-qr-code/src/utils/frame.test.ts new file mode 100644 index 00000000..589ea4f4 --- /dev/null +++ b/packages/react-qr-code/src/utils/frame.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' + +import { FRAME_MIN_QUIET_ZONE } from '../constants' +import type { FrameStyle } from '../types/lib' +import { getFrameLayout, isFilledFrame } from './frame' + +const NUM_CELLS = 33 +const MARGIN = 4 +const styles: FrameStyle[] = ['banner-bottom', 'banner-top', 'ticket', 'bubble', 'border'] + +describe('getFrameLayout', () => { + it.each(styles)('fits the code and the label inside the %s frame', (style) => { + const layout = getFrameLayout(style, NUM_CELLS, MARGIN) + + expect(layout.width).toBeCloseTo(NUM_CELLS + layout.padding * 2) + expect(layout.qrX + NUM_CELLS).toBeLessThanOrEqual(layout.width) + expect(layout.qrY + NUM_CELLS).toBeLessThanOrEqual(layout.height) + expect(layout.labelY + layout.labelHeight / 2).toBeLessThanOrEqual( + layout.height + 1e-9, + ) + }) + + it('puts the label above the code for banner-top', () => { + const layout = getFrameLayout('banner-top', NUM_CELLS, MARGIN) + + expect(layout.labelY).toBeLessThan(layout.qrY) + }) + + it.each(['banner-bottom', 'ticket', 'bubble', 'border'] as FrameStyle[])( + 'puts the label below the code for %s', + (style) => { + const layout = getFrameLayout(style, NUM_CELLS, MARGIN) + + expect(layout.labelY - layout.labelHeight / 2).toBeGreaterThanOrEqual( + layout.qrY + NUM_CELLS, + ) + }, + ) + + it('places the ticket divider between the code and the label', () => { + const layout = getFrameLayout('ticket', NUM_CELLS, MARGIN) + + expect(layout.dividerY).toBeGreaterThan(layout.qrY + NUM_CELLS) + expect(layout.dividerY).toBeLessThan(layout.labelY) + }) + + it('places the code inside the panel', () => { + const layout = getFrameLayout('ticket', NUM_CELLS, MARGIN) + + expect(layout.panelSize).toBe(NUM_CELLS) + expect(layout.qrX).toBe(layout.panelX) + expect(layout.qrY).toBe(layout.panelY) + }) + + it.each([0, 1])('adds a quiet zone around codes with a margin of %s', (margin) => { + const layout = getFrameLayout('ticket', NUM_CELLS, margin) + const quietZone = FRAME_MIN_QUIET_ZONE - margin + + expect(layout.panelSize).toBe(NUM_CELLS + quietZone * 2) + expect(layout.qrX - layout.panelX).toBeCloseTo(quietZone) + expect(layout.qrY - layout.panelY).toBeCloseTo(quietZone) + }) + + it.each(styles)('collapses the %s frame without a label', (style) => { + const layout = getFrameLayout(style, NUM_CELLS, MARGIN, false) + + expect(layout.labelHeight).toBe(0) + expect(layout.height).toBeCloseTo(layout.width) + expect(layout.qrY).toBeCloseTo(layout.padding) + }) + + it('scales with the number of cells', () => { + const small = getFrameLayout('ticket', 21, MARGIN) + const large = getFrameLayout('ticket', 42, MARGIN) + + expect(large.width / small.width).toBeCloseTo(2) + expect(large.height / small.height).toBeCloseTo(2) + }) +}) + +describe('isFilledFrame', () => { + it.each([ + ['banner-bottom', true], + ['banner-top', true], + ['ticket', true], + ['bubble', false], + ['border', false], + ] as [FrameStyle, boolean][])('%s -> %s', (style, expected) => { + expect(isFilledFrame(style)).toBe(expected) + }) +}) diff --git a/packages/react-qr-code/src/utils/frame.ts b/packages/react-qr-code/src/utils/frame.ts index a810d59a..be131fa1 100644 --- a/packages/react-qr-code/src/utils/frame.ts +++ b/packages/react-qr-code/src/utils/frame.ts @@ -1,10 +1,16 @@ +import { FRAME_MIN_QUIET_ZONE } from '../constants' import type { FrameStyle } from '../types/lib' export interface FrameLayout { width: number height: number + // Position of the QR code, inside the panel. qrX: number qrY: number + // Light panel behind the QR code that guarantees a quiet zone against the frame. + panelX: number + panelY: number + panelSize: number padding: number radius: number labelY: number @@ -19,20 +25,22 @@ const FILLED_STYLES: FrameStyle[] = ['banner-bottom', 'banner-top', 'ticket'] export const isFilledFrame = (style: FrameStyle) => FILLED_STYLES.includes(style) -/** - * Computes the frame geometry, in module units, around a QR code of `numCells`. - */ -export const getFrameLayout = (style: FrameStyle, numCells: number): FrameLayout => { - const padding = numCells * 0.07 - const radius = numCells * 0.08 - const labelHeight = numCells * 0.24 +const getPanelLayout = ( + style: FrameStyle, + panelSize: number, + hasLabel: boolean, +): Omit => { + const padding = panelSize * 0.07 + const radius = panelSize * 0.08 + const labelHeight = panelSize * 0.24 const strokeWidth = padding * 0.45 - const width = numCells + padding * 2 + const width = panelSize + padding * 2 const base = { width, - qrX: padding, - qrY: padding, + panelX: padding, + panelY: padding, + panelSize, padding, radius, labelHeight, @@ -40,16 +48,21 @@ export const getFrameLayout = (style: FrameStyle, numCells: number): FrameLayout strokeWidth, } + // Without a label every style collapses to an even frame around the code. + if (!hasLabel) { + return { ...base, labelHeight: 0, height: width, labelY: width } + } + switch (style) { case 'banner-top': return { ...base, - qrY: labelHeight, - height: labelHeight + numCells + padding, + panelY: labelHeight, + height: labelHeight + panelSize + padding, labelY: labelHeight / 2, } case 'ticket': { - const dividerY = numCells + padding * 1.6 + const dividerY = panelSize + padding * 1.6 return { ...base, height: dividerY + labelHeight, @@ -76,8 +89,28 @@ export const getFrameLayout = (style: FrameStyle, numCells: number): FrameLayout default: return { ...base, - height: numCells + padding + labelHeight, - labelY: numCells + padding + labelHeight / 2, + height: panelSize + padding + labelHeight, + labelY: panelSize + padding + labelHeight / 2, } } } + +/** + * Computes the frame geometry, in module units, around a QR code of `numCells` + * (margin included). + */ +export const getFrameLayout = ( + style: FrameStyle, + numCells: number, + margin: number, + hasLabel = true, +): FrameLayout => { + const layout = getPanelLayout( + style, + numCells + Math.max(0, FRAME_MIN_QUIET_ZONE - margin) * 2, + hasLabel, + ) + const quietZone = (layout.panelSize - numCells) / 2 + + return { ...layout, qrX: layout.panelX + quietZone, qrY: layout.panelY + quietZone } +} From 6557075cb0b6f39acd6a5747e0863c28fabff063 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:59:00 +0000 Subject: [PATCH 3/7] docs: describe the frame quiet zone panel Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016VzuX63Eot8RnJ2BF48sUW --- apps/docs/public/llms-full.txt | 2 +- apps/docs/src/app/frame-settings/page.tsx | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/docs/public/llms-full.txt b/apps/docs/public/llms-full.txt index 2c13fdcc..71b40eec 100644 --- a/apps/docs/public/llms-full.txt +++ b/apps/docs/public/llms-full.txt @@ -89,7 +89,7 @@ Includes all `FinderPatternOuterStyle` plus: `'diamond'`, `'star'`, `'heart'`, ` **Available Styles (`FrameStyle`):** `'banner-bottom'`, `'banner-top'`, `'ticket'`, `'bubble'`, `'border'` -When a frame is set and no `background` is passed, the QR code gets a white background so it stays scannable on dark frames. +When a frame is set, the QR code sits on a light panel (the `background` color when it is a string, white otherwise) with a quiet zone of at least 2 modules, so it stays scannable on dark frames. ### GradientSettings diff --git a/apps/docs/src/app/frame-settings/page.tsx b/apps/docs/src/app/frame-settings/page.tsx index 2ae1decd..78500c43 100644 --- a/apps/docs/src/app/frame-settings/page.tsx +++ b/apps/docs/src/app/frame-settings/page.tsx @@ -53,8 +53,9 @@ export default function Page() { These are the properties you can use to draw a frame with a call-to-action label around the QR Code. When a frame is set, the size prop applies to the frame width - and the height grows to fit the label. The QR Code gets a white background by - default so it stays scannable on dark frames; pass a background to override it. + and the height grows to fit the label. The QR Code sits on a light panel (the + background color, white by default) with a quiet zone of at least 2 modules, so it + stays scannable on dark frames. From 75f1a1dbdd9daeddbf557f64993cb7552274a38a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:11:11 +0000 Subject: [PATCH 4/7] fix(frame): draw the bubble and its pointer as one path Two overlapping shapes left a thin anti-aliasing seam where the pointer met the pill. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016VzuX63Eot8RnJ2BF48sUW --- .../react-qr-code/src/components/frame.tsx | 27 ++++++++++--------- .../react-qr-code/src/react-qr-code.test.tsx | 14 ++++++++++ 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/packages/react-qr-code/src/components/frame.tsx b/packages/react-qr-code/src/components/frame.tsx index 2931a693..88c7c758 100644 --- a/packages/react-qr-code/src/components/frame.tsx +++ b/packages/react-qr-code/src/components/frame.tsx @@ -62,23 +62,26 @@ export const FrameBack = ({ settings, layout, maskId }: FrameProps) => { } case 'bubble': { if (!hasLabel) return outline - const bubbleY = height - labelHeight + const top = height - labelHeight + const r = labelHeight / 2 const pointerSize = padding * 1.1 const cx = width / 2 + // Pill and pointer as a single path, so no anti-aliasing seam shows between them. + const bubble = [ + `M${r},${top}`, + `H${cx - pointerSize}`, + `L${cx},${top - pointerSize}`, + `L${cx + pointerSize},${top}`, + `H${width - r}`, + `A${r},${r} 0 0 1 ${width - r},${height}`, + `H${r}`, + `A${r},${r} 0 0 1 ${r},${top}`, + 'Z', + ].join(' ') return ( {outline} - - + ) } diff --git a/packages/react-qr-code/src/react-qr-code.test.tsx b/packages/react-qr-code/src/react-qr-code.test.tsx index ca1addf1..b964888f 100644 --- a/packages/react-qr-code/src/react-qr-code.test.tsx +++ b/packages/react-qr-code/src/react-qr-code.test.tsx @@ -351,6 +351,20 @@ describe('ReactQRCode', () => { expect(label).toHaveAttribute('font-family', 'serif') }) + it('draws the bubble and its pointer as a single path', () => { + const { container } = render( + , + ) + + const bubble = screen.getByTestId('frame-bubble') + expect(bubble).toHaveAttribute('fill', '#c40000') + expect(bubble.getAttribute('d')!.match(/M/g)).toHaveLength(1) + expect(container.querySelectorAll('path[fill="#c40000"]')).toHaveLength(1) + }) + it('uses the frame color for the label of outlined frames', () => { render( Date: Wed, 7 Oct 2026 07:16:20 +0000 Subject: [PATCH 5/7] docs: remove font family from the demo and explain it in FrameSettings Free-text font names in the demo mostly fall back silently, so the FrameSettings page now explains how fontFamily behaves on the page and in SVG/PNG/JPEG downloads instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016VzuX63Eot8RnJ2BF48sUW --- apps/docs/public/llms-full.txt | 2 +- apps/docs/src/app/frame-settings/page.tsx | 35 +++++++++++++++++-- .../src/components/demo/frame-settings.tsx | 9 ----- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/apps/docs/public/llms-full.txt b/apps/docs/public/llms-full.txt index 71b40eec..b081531c 100644 --- a/apps/docs/public/llms-full.txt +++ b/apps/docs/public/llms-full.txt @@ -84,7 +84,7 @@ Includes all `FinderPatternOuterStyle` plus: `'diamond'`, `'star'`, `'heart'`, ` | `text` | `string` | `'Scan me!'` | Label rendered in the frame. Long labels are condensed to fit. An empty string hides it and the frame wraps the code evenly. | | `color` | `string` | `'#000000'` | Frame color. | | `textColor` | `string` | - | Label color. Defaults to white on filled frames and to `color` on `'border'`. | -| `fontFamily` | `string` | `'sans-serif'` | Label font family. | +| `fontFamily` | `string` | `'sans-serif'` | Label font family (CSS font-family list). Web fonts loaded by the page work on screen, but downloads only keep the name: SVG files need the font installed where they are opened, and PNG/JPEG exports cannot use page web fonts, only fonts installed on the device. End the list with a generic family. | **Available Styles (`FrameStyle`):** `'banner-bottom'`, `'banner-top'`, `'ticket'`, `'bubble'`, `'border'` diff --git a/apps/docs/src/app/frame-settings/page.tsx b/apps/docs/src/app/frame-settings/page.tsx index 78500c43..4cee8df3 100644 --- a/apps/docs/src/app/frame-settings/page.tsx +++ b/apps/docs/src/app/frame-settings/page.tsx @@ -2,7 +2,11 @@ import type { Metadata } from 'next' import { PageHeading } from '@/components/page-heading' import { PropsTable } from '@/components/props-table' -import { TypographyP } from '@/components/ui/typography' +import { + TypographyH2, + TypographyInlineCode, + TypographyP, +} from '@/components/ui/typography' import type { Prop } from '@/types/props' export const metadata: Metadata = { @@ -41,7 +45,8 @@ const props: Prop[] = [ { name: 'fontFamily', type: 'string', - description: 'The label font family.', + description: + 'The label font family, as a CSS font-family value. See the font family section below for how it behaves in downloads.', defaultValue: 'sans-serif', }, ] @@ -58,6 +63,32 @@ export default function Page() { stays scannable on dark frames. + + Font family + + The fontFamily value is set as the font-family of the SVG label, so it accepts any + CSS font-family list such as{' '} + 'Georgia, serif'. The label + is always bold. Which font is actually used depends on where the QR Code is + displayed: + + + On the page, the label can use system fonts and any web font your page has already + loaded, for example through Google Fonts, next/font or @font-face, as long as the + name matches. + + + Downloads only keep the font name, not the font file. An SVG download shows the + font if it is installed where the file is opened. PNG and JPEG downloads are + rendered as an isolated image that cannot see your page's web fonts, so they + use the font only if it is installed on the user's device. + + + In every case a missing font falls back to the next font in the list, so always + end it with a generic family such as sans-serif or serif. For labels that must + look the same everywhere, prefer widely installed fonts like Arial, Helvetica, + Georgia or Verdana. + ) } diff --git a/apps/docs/src/components/demo/frame-settings.tsx b/apps/docs/src/components/demo/frame-settings.tsx index 22ddea47..38e512db 100644 --- a/apps/docs/src/components/demo/frame-settings.tsx +++ b/apps/docs/src/components/demo/frame-settings.tsx @@ -99,15 +99,6 @@ export const FrameSettingsDemo = ({ qrProps, setQrProps }: FrameSettingsDemoProp onChange={(value) => onValueChange('textColor', value)} /> - - onValueChange('fontFamily', e.target.value || undefined)} - /> - ) } From 67a772e1fcc202e722b5531472869245cad13fca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:25:36 +0000 Subject: [PATCH 6/7] docs: refresh the custom frame examples Use the site palette (indigo ticket, dark bubble with teal finder centres, teal gradient banner) instead of the serif red/green pair, and fix the textColor JSDoc: only border defaults the label to color. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016VzuX63Eot8RnJ2BF48sUW --- apps/docs/src/components/demos/frame.tsx | 132 +++++++++++++---------- packages/react-qr-code/src/types/lib.ts | 2 +- 2 files changed, 79 insertions(+), 55 deletions(-) diff --git a/apps/docs/src/components/demos/frame.tsx b/apps/docs/src/components/demos/frame.tsx index 7092c120..967c4bcd 100644 --- a/apps/docs/src/components/demos/frame.tsx +++ b/apps/docs/src/components/demos/frame.tsx @@ -1,6 +1,6 @@ 'use client' -import { type FrameStyle, ReactQRCode } from '@lglab/react-qr-code' +import { type FrameStyle, ReactQRCode, type ReactQRCodeProps } from '@lglab/react-qr-code' const styles: FrameStyle[] = ['banner-bottom', 'banner-top', 'ticket', 'bubble', 'border'] @@ -43,71 +43,95 @@ export const DemoStyles = () => { } export const customCodeBlock = ` -import { ReactQRCode } from '@lglab/react-qr-code' +import { ReactQRCode, type ReactQRCodeProps } from '@lglab/react-qr-code' + +const examples: Omit[] = [ + { + frameSettings: { style: 'ticket', text: 'View menu', color: '#4f46e5' }, + dataModulesSettings: { style: 'circle', color: '#4f46e5' }, + finderPatternOuterSettings: { style: 'rounded-lg', color: '#4f46e5' }, + finderPatternInnerSettings: { style: 'circle', color: '#4f46e5' }, + }, + { + frameSettings: { style: 'bubble', text: 'Follow us', color: '#18181b' }, + dataModulesSettings: { style: 'rounded', color: '#18181b' }, + finderPatternOuterSettings: { style: 'rounded-lg', color: '#18181b' }, + finderPatternInnerSettings: { style: 'rounded', color: '#00a3ab' }, + }, + { + frameSettings: { style: 'banner-top', text: 'Free Wi-Fi', color: '#0e7490' }, + gradient: { + type: 'linear', + rotation: 45, + stops: [ + { offset: '0%', color: '#0e7490' }, + { offset: '100%', color: '#2563eb' }, + ], + }, + dataModulesSettings: { style: 'leaf' }, + finderPatternOuterSettings: { style: 'leaf-lg' }, + finderPatternInnerSettings: { style: 'leaf' }, + }, +] export const Demo = () => { return (
- - + {examples.map((props, i) => ( + + ))}
) } ` +const examples: Omit[] = [ + { + frameSettings: { style: 'ticket', text: 'View menu', color: '#4f46e5' }, + dataModulesSettings: { style: 'circle', color: '#4f46e5' }, + finderPatternOuterSettings: { style: 'rounded-lg', color: '#4f46e5' }, + finderPatternInnerSettings: { style: 'circle', color: '#4f46e5' }, + }, + { + frameSettings: { style: 'bubble', text: 'Follow us', color: '#18181b' }, + dataModulesSettings: { style: 'rounded', color: '#18181b' }, + finderPatternOuterSettings: { style: 'rounded-lg', color: '#18181b' }, + finderPatternInnerSettings: { style: 'rounded', color: '#00a3ab' }, + }, + { + frameSettings: { style: 'banner-top', text: 'Free Wi-Fi', color: '#0e7490' }, + gradient: { + type: 'linear', + rotation: 45, + stops: [ + { offset: '0%', color: '#0e7490' }, + { offset: '100%', color: '#2563eb' }, + ], + }, + dataModulesSettings: { style: 'leaf' }, + finderPatternOuterSettings: { style: 'leaf-lg' }, + finderPatternInnerSettings: { style: 'leaf' }, + }, +] + export const DemoCustom = () => { return (
- - + {examples.map((props, i) => ( + + ))}
) } diff --git a/packages/react-qr-code/src/types/lib.ts b/packages/react-qr-code/src/types/lib.ts index 88f05c47..4b1de0f8 100644 --- a/packages/react-qr-code/src/types/lib.ts +++ b/packages/react-qr-code/src/types/lib.ts @@ -134,7 +134,7 @@ export interface FrameSettings { */ color?: string /** - * The label color. Defaults to white on filled frames and to `color` on outlined ones. + * The label color. Defaults to white, except for the `border` style, which uses `color`. */ textColor?: string /** From f8c39a1c7ac4ff89e127a61576fd1d84705a92d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:41:24 +0000 Subject: [PATCH 7/7] fix: white background for JPEG downloads JPEG has no alpha channel, so QR codes rendered without a background were exported with a black quiet zone and could not be scanned. Fill the canvas white before drawing JPEGs; PNG stays transparent. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016VzuX63Eot8RnJ2BF48sUW --- .changeset/jpeg-download-background.md | 5 ++ .../react-qr-code/src/utils/download.test.ts | 54 ++++++++++++++++++- packages/react-qr-code/src/utils/download.ts | 7 +++ 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 .changeset/jpeg-download-background.md diff --git a/.changeset/jpeg-download-background.md b/.changeset/jpeg-download-background.md new file mode 100644 index 00000000..17b9ae69 --- /dev/null +++ b/.changeset/jpeg-download-background.md @@ -0,0 +1,5 @@ +--- +'@lglab/react-qr-code': patch +--- + +Fix JPEG downloads of QR codes without a background: transparent areas were exported as black, making the code unscannable. JPEG downloads now use a white background; PNG downloads stay transparent. diff --git a/packages/react-qr-code/src/utils/download.test.ts b/packages/react-qr-code/src/utils/download.test.ts index 640c5092..c51c2f80 100644 --- a/packages/react-qr-code/src/utils/download.test.ts +++ b/packages/react-qr-code/src/utils/download.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' -import { getFileHeight } from './download' +import { DEFAULT_BGCOLOR } from '../constants' +import type { DownloadFileFormat } from '../types/lib' +import { downloadRaster, getFileHeight } from './download' const svgWithViewBox = (width: number, height: number) => ({ viewBox: { baseVal: { width, height } } }) as unknown as SVGSVGElement @@ -20,3 +22,51 @@ describe('getFileHeight', () => { ).toBe(300) }) }) + +describe('downloadRaster', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + const run = (fileFormat: DownloadFileFormat) => { + const ctx = { fillStyle: '', fillRect: vi.fn(), drawImage: vi.fn() } + const canvas = document.createElement('canvas') + vi.spyOn(canvas, 'getContext').mockReturnValue( + ctx as unknown as CanvasRenderingContext2D, + ) + const createElement = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => + tag === 'canvas' ? canvas : createElement(tag), + ) + URL.createObjectURL = vi.fn(() => 'blob:qr') + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + svg.setAttribute('viewBox', '0 0 33 33') + downloadRaster({ + svgRef: { current: svg }, + fileSize: 300, + fileName: 'qr', + fileFormat, + imageSettings: undefined, + calculatedImageSettings: null, + size: 128, + numCells: 33, + margin: 4, + frameLayout: null, + }) + return ctx + } + + it('fills a white background for jpeg, which has no transparency', () => { + const ctx = run('jpeg') + + expect(ctx.fillStyle).toBe(DEFAULT_BGCOLOR) + expect(ctx.fillRect).toHaveBeenCalledWith(0, 0, 300, 300) + }) + + it('keeps png transparent', () => { + const ctx = run('png') + + expect(ctx.fillRect).not.toHaveBeenCalled() + }) +}) diff --git a/packages/react-qr-code/src/utils/download.ts b/packages/react-qr-code/src/utils/download.ts index 3eb0ffa2..36337f02 100644 --- a/packages/react-qr-code/src/utils/download.ts +++ b/packages/react-qr-code/src/utils/download.ts @@ -1,3 +1,4 @@ +import { DEFAULT_BGCOLOR } from '../constants' import type { DownloadRasterProps, DownloadSVGProps } from '../types/utils' // Frames make the SVG non-square, so scale the height by the viewBox aspect ratio. @@ -49,6 +50,12 @@ export const downloadRaster = ({ const fileHeight = getFileHeight(svgRef.current, fileSize) canvas.height = fileHeight + // JPEG has no alpha channel, so transparent areas would come out black. + if (fileFormat === 'jpeg') { + ctx.fillStyle = DEFAULT_BGCOLOR + ctx.fillRect(0, 0, fileSize, fileHeight) + } + const svgData = new XMLSerializer().serializeToString(svgRef.current) const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }) const svgUrl = URL.createObjectURL(svgBlob)