Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

QR share

A “Show QR code” pill that stretches into a card with a real, scannable QR code whose dots pop in from the middle.

qr-share/qr-share.tsx
Live · interactive

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • Handing a link from a screen to a phone in the same room: an invite, a menu, a page to carry on reading.
  • Something a phone camera acts on by itself: a Wi-Fi network to join (WIFI:), a number to call (tel:), a mail to write (mailto:).
  • Next to a share action, where the code is wanted now and then and should stay folded into one pill until asked for.
  • Short payloads: up to 84 bytes at level M, 106 at level L. A short link scans faster from further away.

When not to use

  • Sharing to someone who isn’t there. Use the system share sheet, or a copy-link button on its own.
  • Long or changing data: the encoder stops at version 5, and a dense code is hard to scan off a screen. Shorten the link first.
  • Printed or exported codes. Draw a plain square-module code with a quiet zone, as every scanner expects.
  • A secret the whole room shouldn’t read. A code on screen is as public as the text it holds.

You can add this component with the shadcn CLI or by hand:

Into components/qr-share of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/qr-share.json

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

share-invite.tsx
import { QrShare } from './qr-share';// Up to 84 bytes at level M (106 at L).export default function ShareInvite() {  return (    <QrShare url="https://example.com/invite/rooftop-supper" level="M" autoplay={false} />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

Below are variations of this component, each live, with its code.

Guest Wi-Fi

A Wi-Fi network a phone camera offers to join: the card names it, and the button copies its password.

Phone number

A number to call, short enough at level L for the smallest code, 21 modules a side, with the largest dots.

RSVP by email

An email ready to send, with its address, subject and first line filled in; the button copies the address.

A share button that opens in place. The pill stretches into a card holding the link’s QR code, encoded right in the browser, with Copy link and a close button; the code’s dots pop in ring after ring from the middle. Close it, or press Escape, and the card folds back into the pill.

  • The QR code is encoded in the browser by qr.ts, with no library: UTF-8 byte mode, Reed–Solomon error correction in GF(256), the finder, timing and alignment patterns, the zigzag placement, all eight masks scored with the standard’s penalty rules, and the BCH format bits. It picks the smallest version that fits, from 1 to 5.
  • It is drawn as SVG (qr-code.tsx): round dots, with rounded finders and alignment pattern. The dots are grouped in rings by their distance from the centre and pop in one ring after another.
  • The pill becomes the card with FLIP. Both boxes are measured, and a plain shape springs its width, height, corners and colour between them, while the card’s face is revealed inside it by a clip-path that follows the same curve.
  • The surface keeps the card’s size whatever shows, so only the plain shape resizes, and the large card face fades without blur, both to save GPU memory.
  • Copy link writes to the clipboard and says “Copied” for a moment. The demo shows “Copied” without touching the clipboard, and never takes over a card the reader opened.
  • Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop; the code itself stays dark on a light plate in both schemes, so it always scans.
PropTypeDefaultDescription
urlstring'https://example.com/invite/rooftop-supper'What the code holds: a link, or a payload a phone camera acts on, such as WIFI:, tel: or mailto:. Up to 84 bytes at level M, 106 at L.
level'L' | 'M''M'The error correction: M recovers from about 15% damage; L from about 7%, and holds more.
captionstring—A line under the code saying what it holds, such as a network’s name, cut short if it runs too long. It also names the card and the code for screen readers, which are “Invitation link” and “QR code for” the payload without it.
copy{ label: string; value: string }{ label: 'link', value: url }What the copy button copies, and what it calls it on the button and in the announcements: the label “password” makes Copy password and “Password copied.” A link shows the link glyph, anything else a copy glyph.
defaultOpenbooleanfalseWhether it starts open, as the card, or folded into the pill.
defaultCopied'done' | 'failed'—Opens the card with the copy button saying Copied or Not copied, held until it is pressed again or the card closes. Turns the demo off.
onOpenChange(open: boolean) => void—Called when the reader opens or closes the card (not the demo).
onCopy(result: 'done' | 'failed', value: string) => void—Called when the reader presses the copy button, with whether the clipboard took it and what was copied.
autoplaybooleantrueWhile on screen and untouched, a demo pointer opens the card, copies the link and closes it.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—Extra class for the root, a stage of its own.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

qr-share.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties } from 'react';import { GhostPointer, runDemo } from './ghost-pointer';import { QrCode } from './qr-code';import type { Ecc } from './qr';import { useAutoplay } from './use-autoplay';import styles from './qr-share.module.css';/* * QR share: a "Show QR code" pill that opens into a card with the link's QR code, a Copy link * button and a close button, and folds back into the pill. * * - The pill's shape stretches into the card's (FLIP: the shapes are measured before and after *   the change, and a plain shape is animated between them on a spring). The card's face is *   revealed inside it as it grows (a clip-path that follows the shape), and the faces *   cross-fade with a touch of blur, so the card seems to grow out of the pill. * - The surface keeps one size, the card's (the pill sits in its middle): only the plain shape *   changes size, so a phone never has to make the faces' layers again while it moves. * - The QR code is encoded here, in the browser (qr.ts), and drawn as dots (qr-code.tsx); its *   dots pop in ring after ring from the middle each time the card opens. * - Copy link writes the link to the clipboard and says so on the button for a moment. For a code
qr-share.module.css
/* QR share (qr-share.tsx, qr-code.tsx). Sizes are in em: the stage sets the font-size from its   own width (a container query), so the card grows with the room it has. The surface shows the   pill or the card, after data-open, and has data-moving while it changes; --spring and   --bounce, the easings of the springy transitions, come from the component.   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes   the color-scheme it inherits from the page (light unless the page says otherwise), or the one   its colorScheme prop sets (data-color-scheme). The code itself stays dark on a light plate in   both, as scanners expect. */.root {  /* The stage; the pill and the card's buttons; the card. */  --surface: #efeff3;  --surface-2: #fff;  --card: #f4f4f7;  /* The pill and the buttons under the pointer. */  --hover: #fafafc;  /* The words and the glyphs; the close button's cross; Copied. */  --ink: #1c1c1e;  --ink-muted: #8e8e93;  --green: #30a46c;  /* The code's plate and its dots, light and dark on both schemes: a code reads best dark on light,     with a light margin round it. At night the plate is a little dimmer than white. */  --plate: #fff;
qr.ts
/* * A small QR code encoder: byte mode (UTF-8 text), error correction level L or M, versions 1 to * 5 (up to 106 bytes at L, 84 at M), which is plenty for a link. It follows the standard * (ISO/IEC 18004) step by step: * * 1. The data as bits: the mode, the length, the bytes, then padding up to the version's size. * 2. Reed–Solomon error correction for each block, computed in the field GF(256). * 3. The function patterns: the three finders, the timing lines, the alignment pattern. * 4. The data and error correction bits, placed in a zigzag from the bottom right corner. * 5. Each of the eight masks is tried, and the one that scores best (fewest look-alike *    patterns) is kept, with its format bits (level and mask, BCH-protected) beside the finders. */export type Ecc = 'L' | 'M';export type QrMatrix = {  version: number;  /** Modules per side. */  size: number;  /** Whether the module at column `x`, row `y` is dark. */  dark: (x: number, y: number) => boolean;};/** Per level, for versions 1 to 5: error correction codewords per block, data codewords per block. */
qr-code.tsx
'use client';import { useMemo, type CSSProperties } from 'react';import { encode, type Ecc } from './qr';import styles from './qr-share.module.css';/* * A QR code drawn as an SVG: round dots for the modules, and the three finders and the * alignment pattern as rounded squares, which readers still recognise. The dots are grouped in * rings by their distance from the centre, and each ring pops in a little after the one inside * it (the stylesheet staggers them on the --ring custom property). *//** Modules within this many of the finders' and the alignment pattern's centres are theirs. */const FINDER = 3;const ALIGNMENT = 2;/** The finders' centres, in the corners but the bottom right one. */const finderCentres = (size: number) => [  [3, 3],  [size - 4, 3],  [3, size - 4],];
ghost-pointer.tsx
'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps: * wait, glide the pointer onto a control, press it and call the component's own handler, as a * click would. Stopping the demo (the reader takes over, or it scrolls away) ends the step in * progress at once: its promise rejects, which ends the script right there, and the pointer * fades out. */export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */  moveTo: (target: Element | null | undefined, at?: { x: number; y: number }) => Promise<void>;  /** Presses and lets go; `action` runs as the pointer goes down. */  press: (action: () => void) => Promise<void>;};/** What a stopped demo's pending step rejects with. */class Stopped extends Error {}
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • “Show QR code” has aria-expanded and aria-controls, and the card is a labelled group.
  • Focus moves to Copy link on open and back to the pill on close; Escape closes the card.
  • The code is an image labelled with what it holds (“QR code for https://example.com/…”), or with its caption when it has one (“QR code: Studio Guest”), so a Wi-Fi password is not read out.
  • “QR code shown.” and “Link copied.” (or “The link could not be copied.”), with the copy prop’s word for “link”, are announced. Under reduced motion the card opens and closes at once, with no pop and no demo.