# 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.

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.

A React component by Bruno Delorence: https://brunodelorence.com/components/qr-share. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Motion.

## Anatomy

1. **Card**: Grows out of the pill on a spring, 620 ms: one plain shape stretches between the two while the card is revealed inside it.
2. **Plate**: A light square round the code in both schemes, so it scans the same at night. Its padding is the code’s quiet zone.
3. **Code**: Encoded in the browser at level `M` or `L`, in the smallest version that holds the text. Round dots pop in ring after ring from the middle.
4. **Finder**: The three corner squares, drawn rounded but whole so scanners still find them. They pop in first.
5. **Caption**: Optional: one line saying what the code holds. It names the card and the code for screen readers too.
6. **Copy button**: Copies the link, or the part of `copy` worth pasting, and says Copied in green for 1.8 s.
7. **Close button**: Folds the card back into the pill and puts the focus on it. Escape does the same.

## 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.

## Installation

Install it with the shadcn CLI, into components/qr-share of any React project:

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

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: 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

```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} />
  );
}
```

## States

- **Closed**: Folded into one pill in the middle of the card’s place, so opening moves nothing around it.
- **Open**: The code and Copy link. The dots pop in each time it opens, then hold still.
- **Copied**: The words roll up and Copied rolls in, in green; held here, it goes back after 1.8 s.
- **Not copied**: The clipboard refused (no permission, or an insecure page): the button says so, in the ink colour.
- **Wi-Fi network**: A `WIFI:` code with the network’s name as caption; Copy password copies only the password, with a copy glyph.
- **Long link**: 101 bytes: past what level `M` holds, so it takes level `L` and a version 5 code, with an alignment square.
- **Dark**: Set by `colorScheme`, or taken from the page. The plate stays light, a little dimmer than white.

## Examples

### Guest Wi-Fi

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

```tsx
import { QrShare } from './qr-share';

// In the name or the password, put a backslash before \ ; , : and ".
const guestNetwork = { name: 'Studio Guest', password: 'fig-tree-58' };

export function GuestWifi() {
  return (
    <QrShare
      url={`WIFI:T:WPA;S:${guestNetwork.name};P:${guestNetwork.password};;`}
      caption={guestNetwork.name}
      copy={{ label: 'password', value: guestNetwork.password }}
      defaultOpen
      autoplay={false}
    />
  );
}
```

### Phone number

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

```tsx
import { QrShare } from './qr-share';

export function PhoneNumber() {
  // 17 bytes: the most the smallest code holds at level L (14 at level M).
  return (
    <QrShare
      url="tel:+442079460321"
      level="L"
      caption="+44 20 7946 0321"
      copy={{ label: 'number', value: '+44 20 7946 0321' }}
      defaultOpen
      autoplay={false}
    />
  );
}
```

### RSVP by email

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

```tsx
import { QrShare } from './qr-share';

export function RsvpEmail() {
  // The subject and the body are URL-encoded: %20 is a space.
  return (
    <QrShare
      url="mailto:rsvp@example.com?subject=RSVP&body=Count%20me%20in"
      caption="rsvp@example.com"
      copy={{ label: 'address', value: 'rsvp@example.com' }}
      defaultOpen
      autoplay={false}
    />
  );
}
```

### Long link

The invitation link with a guest’s details: too long for level M, it takes level L and the largest code, 37 modules a side.

```tsx
import { QrShare } from './qr-share';

// 101 bytes: more than level M holds (84), so it takes level L (up to 106).
const guestLink =
  'https://example.com/invite/rooftop-supper?guest=7f3a9c2e&table=terrace&plus=1&from=qr-card&lang=en-GB';

export function LongLink() {
  return <QrShare url={guestLink} level="L" defaultOpen autoplay={false} />;
}
```

## How it works

- 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.

## API reference

- **url** (`string`, default `'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'`, default `'M'`): The error correction: M recovers from about 15% damage; L from about 7%, and holds more.
- **caption** (`string`): 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 }`, default `{ 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.
- **defaultOpen** (`boolean`, default `false`): Whether 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.
- **autoplay** (`boolean`, default `true`): While 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.
- **className** (`string`): Extra class for the root, a stage of its own.

## Accessibility

- “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.

## Source

### qr-share/qr-share.tsx

```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
 *   that holds something else (a network to join, a number to call), the button can copy the part
 *   worth pasting and say what it is: Copy password, with a copy glyph instead of the link's. An
 *   optional caption under the code says what it holds.
 * - While on screen and untouched, a ghost pointer opens the card, copies (without touching the
 *   clipboard) and closes it, but never over a card the reader opened. Under reduced motion it
 *   opens and closes at once.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets. The code stays dark on a light plate in
 *   both, so it scans the same; the moving shape takes the pill's and the card's tokens.
 */

/**
 * A spring with almost no overshoot, as a CSS linear() easing, for the shape. The stylesheet's
 * transitions use it too, through the --spring custom property.
 */
const SPRING =
  'linear(0, 0.04, 0.137, 0.262, 0.394, 0.523, 0.638, 0.737, 0.819, 0.883, 0.932, 0.968, 0.992, 1.008, 1.018, 1.022, 1.023, 1.022, 1.02, 1.017, 1.014, 1.011, 1.008, 1.006, 1.004, 1.003, 1)';
/** A livelier one, for the dots popping in. */
const BOUNCE =
  'linear(0, 0.097, 0.318, 0.578, 0.814, 0.993, 1.106, 1.156, 1.16, 1.133, 1.093, 1.051, 1.016, 0.992, 0.978, 0.973, 0.975, 0.981, 0.988, 0.994, 0.999, 1.002, 1.004, 1.004, 1.004, 1.003, 1)';

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/** Browsers without linear() easings get a close cubic curve. */
const easing = (curve: string) =>
  CSS.supports('transition-timing-function', curve) ? curve : 'cubic-bezier(0.3, 1.1, 0.4, 1)';

/** A shape's box: its size and its corners' radius, in px. */
type Shape = { width: number; height: number; radius: number };

type Copied = 'idle' | 'done' | 'failed';

export function QrShare({
  url = 'https://example.com/invite/rooftop-supper',
  level = 'M',
  caption,
  copy,
  defaultOpen = false,
  defaultCopied,
  autoplay = true,
  onOpenChange,
  onCopy,
  colorScheme,
  className = '',
}: {
  /**
   * The link to share, or other text a phone camera reads (a WIFI:, tel: or mailto: payload): up
   * to 84 bytes at level M, 106 at L.
   */
  url?: string;
  /** The QR code's error correction: M recovers from about 15% damage, L from 7%. */
  level?: Ecc;
  /**
   * A line under the code saying what it holds, such as a network's name; it names the card and
   * the code for screen readers too.
   */
  caption?: string;
  /**
   * What the copy button copies, and its name on the button and in the announcements: a label of
   * 'password' makes Copy password. By default, the link, named 'link'.
   */
  copy?: { label: string; value: string };
  /** Whether the card is open at first, rather than folded into the pill. */
  defaultOpen?: boolean;
  /**
   * Opens the card with the copy button saying Copied ('done') or Not copied ('failed'), held
   * until it is pressed again or the card closes; it turns the demo off.
   */
  defaultCopied?: 'done' | 'failed';
  /** While on screen and untouched, a demo pointer plays with it on its own. */
  autoplay?: boolean;
  /** When the reader opens or closes the card (not the demo). */
  onOpenChange?: (open: boolean) => void;
  /** When the reader presses the copy button: whether the clipboard took it, and what was copied. */
  onCopy?: (result: 'done' | 'failed', value: string) => void;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [open, setOpen] = useState(defaultOpen || !!defaultCopied);
  /** Counts the openings: the code pops in afresh each time (it is drawn from the first). */
  const [opens, setOpens] = useState(defaultOpen || defaultCopied ? 1 : 0);
  /** The card is the reader's: the demo waits for them to close it. */
  const [readersCard, setReadersCard] = useState(false);
  const [copied, setCopied] = useState<Copied>(defaultCopied ?? 'idle');
  const [message, setMessage] = useState('');
  const cardId = useId();

  const surface = useRef<HTMLDivElement>(null);
  const shape = useRef<HTMLSpanElement>(null);
  const pill = useRef<HTMLButtonElement>(null);
  const card = useRef<HTMLDivElement>(null);
  const copyButton = useRef<HTMLButtonElement>(null);
  const closeButton = useRef<HTMLButtonElement>(null);
  const pointer = useRef<SVGSVGElement>(null);
  /** The shape when it started to change, to animate from there. */
  const from = useRef<Shape | null>(null);
  /** The shape's move and the card's reveal, in progress. */
  const morph = useRef<Animation[]>([]);
  /** Where the focus goes once the surface has changed, if it was in it. */
  const focusNext = useRef<HTMLButtonElement | null>(null);
  const copyTimer = useRef(0);
  const stopDemo = useRef<(() => void) | null>(null);

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text}\u00a0` : text));

  /** The shape on screen now: the one moving, or the face at rest. In the surface's px. */
  const shapeNow = (): Shape | null => {
    const box = surface.current;
    const moving = box?.hasAttribute('data-moving');
    const element = moving ? shape.current : open ? card.current : pill.current;
    if (!box || !element) return null;
    const rect = element.getBoundingClientRect();
    // Screen pixels to the surface's own, should the stage sit in a scaled container.
    const scale = box.getBoundingClientRect().width / box.offsetWidth || 1;
    const height = rect.height / scale;
    // A pill's radius is set larger than it can be: the corners it draws are half its height.
    const radius = Math.min(parseFloat(getComputedStyle(element).borderTopLeftRadius), height / 2);
    return { width: rect.width / scale, height, radius };
  };

  /** Opens or closes the card; `byReader` keeps the demo away while the reader's card is open. */
  const toggle = (next: boolean, byReader = true) => {
    if (next === open) return;
    const box = surface.current;
    if (!reducedMotion()) from.current = shapeNow();
    const inside = box?.contains(document.activeElement) ?? false;
    focusNext.current = inside ? (next ? copyButton.current : pill.current) : null;
    setOpen(next);
    setReadersCard(next && byReader);
    if (next) setOpens((count) => count + 1);
    setCopied('idle');
    announce(next ? 'QR code shown.' : 'QR code hidden.');
    if (byReader) onOpenChange?.(next);
  };

  /** What Copy copies, and what the button and the announcements call it. */
  const { label: copyName, value: copyText } = copy ?? { label: 'link', value: url };

  /** Copies the link (or `copy`'s value); the demo only shows it, without touching the clipboard. */
  const copyNow = (forReal = true) => {
    const show = (result: Copied) => {
      setCopied(result);
      announce(
        result === 'done'
          ? `${copyName.charAt(0).toUpperCase()}${copyName.slice(1)} copied.`
          : `The ${copyName} could not be copied.`,
      );
      window.clearTimeout(copyTimer.current);
      copyTimer.current = window.setTimeout(() => setCopied('idle'), 1800);
    };
    if (!forReal) return show('done');
    const report = (result: 'done' | 'failed') => {
      show(result);
      onCopy?.(result, copyText);
    };
    if (!navigator.clipboard) return report('failed');
    navigator.clipboard.writeText(copyText).then(
      () => report('done'),
      () => report('failed'),
    );
  };

  // After a change: a plain shape goes from the old face's box to the new one's, the card shows
  // inside it, and the focus follows.
  useLayoutEffect(() => {
    const start = from.current;
    from.current = null;
    const box = surface.current;
    const face = open ? card.current : pill.current;
    const sheet = card.current;
    const moving = shape.current;
    if (start && box && face && sheet && moving) {
      morph.current.forEach((animation) => animation.cancel());
      const end: Shape = {
        width: face.offsetWidth,
        height: face.offsetHeight,
        radius: open
          ? parseFloat(getComputedStyle(sheet).borderTopLeftRadius)
          : face.offsetHeight / 2,
      };
      const colour = (isCard: boolean) => (isCard ? 'var(--card)' : 'var(--surface-2)');
      const frame = ({ width, height, radius }: Shape) => ({
        width: `${width}px`,
        height: `${height}px`,
        borderRadius: `${radius}px`,
      });
      // The card shows inside the shape: a clip of the shape's size, in its middle.
      const reveal = ({ width, height, radius }: Shape) => ({
        clipPath: `inset(${(sheet.offsetHeight - height) / 2}px ${(sheet.offsetWidth - width) / 2}px round ${radius}px)`,
      });
      const timing = { duration: 620, easing: easing(SPRING) };
      box.setAttribute('data-moving', '');
      const move = moving.animate(
        [
          { ...frame(start), backgroundColor: colour(!open) },
          { ...frame(end), backgroundColor: colour(open) },
        ],
        timing,
      );
      morph.current = [move, sheet.animate([reveal(start), reveal(end)], timing)];
      move.finished.then(
        () => box.removeAttribute('data-moving'),
        () => {},
      );
    }
    focusNext.current?.focus();
    focusNext.current = null;
    // `shapeNow` reads refs only; the change is all this depends on.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [open]);

  // Nothing keeps moving once the card is gone.
  useEffect(
    () => () => {
      morph.current.forEach((animation) => animation.cancel());
      window.clearTimeout(copyTimer.current);
    },
    [],
  );

  // The demo calls the handlers of the latest render.
  const actions = useRef({ toggle, copyNow, open });
  useLayoutEffect(() => {
    actions.current = { toggle, copyNow, open };
  });

  // The demo: a ghost pointer opens the card, copies the link and closes it; again while on screen
  // and untouched, but never over a card the reader opened.
  const demo = autoplay && !defaultCopied && playing && !(open && readersCard);
  useEffect(() => {
    const stage = root.current;
    const ghost = pointer.current;
    if (!demo || !stage || !ghost) return;
    const stop = runDemo(stage, ghost, async ({ wait, moveTo, press }) => {
      const act = () => actions.current;
      await wait(600);
      for (;;) {
        if (!act().open) {
          await moveTo(pill.current);
          await wait(200);
          await press(() => act().toggle(true, false));
          await wait(1900);
          await moveTo(copyButton.current);
          await wait(250);
          await press(() => act().copyNow(false));
          await wait(1500);
        }
        await moveTo(closeButton.current);
        await wait(250);
        await press(() => act().toggle(false, false));
        await wait(1500);
        await moveTo(stage, { x: 0.72, y: 0.84 });
        await wait(600);
      }
    });
    stopDemo.current = stop;
    return stop;
  }, [demo, root]);

  /** The reader takes over: the demo stops at once, and waits until they leave it alone. */
  const readerActs = () => {
    stopDemo.current?.();
    stopDemo.current = null;
    take();
  };

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      style={{ '--spring': SPRING, '--bounce': BOUNCE } as CSSProperties}
      onPointerDownCapture={readerActs}
      onKeyDownCapture={readerActs}
    >
      <div className={styles.scene}>
        <div
          ref={surface}
          className={styles.surface}
          data-open={open || undefined}
          onKeyDown={(event) => {
            if (event.key === 'Escape' && open) toggle(false);
          }}
        >
          <span ref={shape} className={styles.shape} aria-hidden="true" />
          <button
            ref={pill}
            data-part="pill"
            type="button"
            className={styles.pill}
            aria-expanded={open}
            aria-controls={cardId}
            inert={open}
            onClick={() => toggle(true)}
          >
            <QrGlyph />
            Show QR code
          </button>

          <div
            ref={card}
            id={cardId}
            data-part="card"
            className={styles.card}
            role="group"
            aria-label={caption ?? 'Invitation link'}
            inert={!open}
          >
            <div className={styles.panel} data-part="plate">
              {opens > 0 && (
                <QrCode
                  key={opens}
                  value={url}
                  level={level}
                  label={caption ? `QR code: ${caption}` : `QR code for ${url}`}
                />
              )}
            </div>
            {caption && (
              <p className={styles.caption} data-part="caption">
                {caption}
              </p>
            )}
            <div className={styles.actions}>
              <button
                ref={copyButton}
                type="button"
                className={styles.copy}
                data-part="copy"
                data-copied={copied === 'idle' ? undefined : copied}
                onClick={() => copyNow()}
              >
                <span className={styles.copyFace}>
                  {/^https?:/i.test(copyText) ? <LinkGlyph /> : <CopyGlyph />}
                  {`Copy ${copyName}`}
                </span>
                <span className={styles.copyFace} aria-hidden="true">
                  <CheckGlyph />
                  {copied === 'failed' ? 'Not copied' : 'Copied'}
                </span>
              </button>
              <button
                ref={closeButton}
                type="button"
                className={styles.close}
                data-part="close"
                aria-label="Hide QR code"
                onClick={() => toggle(false)}
              >
                <svg viewBox="0 0 20 20" aria-hidden="true">
                  <path d="m6 6 8 8m0-8-8 8" />
                </svg>
              </button>
            </div>
          </div>
        </div>
      </div>

      <GhostPointer ref={pointer} />
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

function QrGlyph() {
  return (
    <svg className={styles.glyph} viewBox="0 0 20 20" aria-hidden="true">
      <rect x="3" y="3" width="5.5" height="5.5" rx="1.4" />
      <rect x="11.5" y="3" width="5.5" height="5.5" rx="1.4" />
      <rect x="3" y="11.5" width="5.5" height="5.5" rx="1.4" />
      <path d="M11.5 11.5h2v2h-2zM15 15h2v2h-2zM15 11.5h2M11.5 15v2" />
    </svg>
  );
}

function LinkGlyph() {
  return (
    <svg className={styles.glyph} viewBox="0 0 20 20" aria-hidden="true">
      <path d="M8.6 11.4a3.2 3.2 0 0 0 4.5 0l2.6-2.6a3.2 3.2 0 0 0-4.5-4.5l-1.1 1.1" />
      <path d="M11.4 8.6a3.2 3.2 0 0 0-4.5 0l-2.6 2.6a3.2 3.2 0 0 0 4.5 4.5l1.1-1.1" />
    </svg>
  );
}

/** Two sheets, one over the other: for a copy of anything but a link. */
function CopyGlyph() {
  return (
    <svg className={styles.glyph} viewBox="0 0 20 20" aria-hidden="true">
      <rect x="7.25" y="7.25" width="9.25" height="9.25" rx="2.25" />
      <path d="M12.75 7.25v-1.5a2.25 2.25 0 0 0-2.25-2.25H5.75A2.25 2.25 0 0 0 3.5 5.75v4.75a2.25 2.25 0 0 0 2.25 2.25h1.5" />
    </svg>
  );
}

function CheckGlyph() {
  return (
    <svg className={styles.glyph} viewBox="0 0 20 20" aria-hidden="true">
      <path d="m5 10.5 3.25 3.25L15 7" />
    </svg>
  );
}
```

### qr-share/qr-share.module.css

```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;
  --code: #1c1c1e;
  /* The stage's edge; the hairline round the pill and the card, and round the plate and buttons. */
  --line: rgb(0 0 0 / 0.05);
  --edge: rgb(0 0 0 / 0.06);
  --control-edge: rgb(0 0 0 / 0.05);
  /* The pill and the card sit on their hairline, a shade right under them and a soft drop. */
  --shadow-near: rgb(0 0 0 / 0.05);
  --shadow: rgb(0 0 0 / 0.16);
  --lift: 0 0 0 0.5px var(--edge), 0 1px 2px var(--shadow-near), 0 8px 22px -10px var(--shadow);
  /* The same on both schemes: the accent, the system's blue of the focus rings, and the demo's
     pointer, which looks like the system's. */
  --accent: #0a84ff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  position: relative;
  container-type: inline-size;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  -webkit-user-select: none;
  user-select: none;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#efeff3, #121214);
    --surface-2: light-dark(#fff, #242427);
    --card: light-dark(#f4f4f7, #1b1b1e);
    --hover: light-dark(#fafafc, #2c2c30);
    --ink: light-dark(#1c1c1e, #ececef);
    --ink-muted: light-dark(#8e8e93, #8e8e95);
    --green: light-dark(#30a46c, #4cc38a);
    --plate: light-dark(#fff, #e6e6ea);
    --line: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.06));
    --edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.08));
    --control-edge: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.06));
    --shadow-near: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.3));
    --shadow: light-dark(rgb(0 0 0 / 0.16), rgb(0 0 0 / 0.6));
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}
/* The buttons start from the stage's text; :where() keeps this below every class. */
:where(.root) button {
  margin: 0;
  font: inherit;
  color: inherit;
}

.scene {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: max(380px, 56cqi);
  padding: 2em 16px;
  font-size: clamp(14px, 9.5px + 1.1cqi, 20px);
}

/* The surface: as large as the card, with room round it for the shadow, whatever shows, and
   clipped to itself, so its size never changes. At rest, the pill (in its middle) and the card
   draw their own backgrounds; while they change, the shape draws one, moving between theirs. */
.surface {
  position: relative;
  display: grid;
  padding: 0.4em 0.9em 1.4em;
  overflow: hidden;
  overflow: clip;
  pointer-events: none;
}
.shape {
  position: absolute;
  inset: 0.4em 0.9em 1.4em;
  width: 0;
  height: 0;
  margin: auto;
  visibility: hidden;
  box-shadow: var(--lift);
}
.surface[data-moving] .shape {
  visibility: visible;
}
.surface[data-moving] .pill,
.surface[data-moving] .card {
  background: transparent;
  box-shadow: none;
}

/* The pill's face, in the middle of the card's place. */
.pill {
  position: absolute;
  inset: 0.4em 0.9em 1.4em;
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  width: fit-content;
  height: 2.75em;
  margin: auto;
  padding: 0 1.25em 0 0.9em;
  border: 0;
  border-radius: 999px;
  background: var(--surface-2);
  box-shadow: var(--lift);
  font-weight: 600;
  letter-spacing: -0.01em;
  white-space: nowrap;
  cursor: pointer;
  pointer-events: auto;
  transition:
    opacity 0.22s ease,
    filter 0.22s ease,
    scale 0.4s var(--spring);
}
.pill:hover {
  background: var(--hover);
}
.pill:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}
.surface[data-open] .pill {
  opacity: 0;
  filter: blur(4px);
  scale: 0.8;
  pointer-events: none;
}
.surface:not([data-open]) .pill {
  transition-delay: 0.22s;
}

/* The card's face: the code, then Copy link and close. */
.card {
  display: flex;
  flex-direction: column;
  gap: 0.6em;
  width: 15.2em;
  padding: 0.6em;
  border-radius: 2em;
  background: var(--card);
  box-shadow: var(--lift);
  /* No blur on a face this large: it would cost the phone a lot of memory as it fades. */
  opacity: 0;
  scale: 0.9;
  /* Closed, it is not drawn at all, once it has faded out. */
  visibility: hidden;
  transition:
    opacity 0.2s ease,
    scale 0.4s var(--spring),
    visibility 0s linear 0.62s;
}
.surface[data-open] .card {
  opacity: 1;
  scale: 1;
  visibility: visible;
  pointer-events: auto;
  transition-duration: 0.32s, 0.6s, 0s;
  transition-delay: 0.08s, 0.08s, 0s;
}
.panel {
  aspect-ratio: 1;
  padding: 1.05em;
  border-radius: 1.45em;
  background: var(--plate);
  box-shadow: 0 0 0 0.5px var(--control-edge);
}
/* The caption, when there is one: what the code holds, on one line, cut short if it must be. */
.caption {
  margin: 0;
  padding: 0 0.75em;
  overflow: hidden;
  font-weight: 600;
  letter-spacing: -0.01em;
  line-height: 1.3;
  text-align: center;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The code: the finders pop in first, then the dots, ring after ring from the middle. */
.qr {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
  fill: var(--code);
}
.finderRing {
  fill: none;
  stroke: var(--code);
  stroke-width: 1;
}
.finder {
  animation: pop 0.55s var(--bounce) 0.08s backwards;
  transform-box: fill-box;
  transform-origin: center;
}
.qr > g:not(.finder) > circle {
  animation: pop 0.4s var(--bounce) backwards;
  animation-delay: calc(var(--ring) * 18ms + 140ms);
  transform-box: fill-box;
  transform-origin: center;
}

.actions {
  display: flex;
  gap: 0.6em;
}
.copy,
.close {
  height: 2.75em;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: var(--surface-2);
  box-shadow: 0 0 0 0.5px var(--control-edge);
  cursor: pointer;
  transition:
    background-color 0.2s ease,
    scale 0.3s var(--spring);
}
.copy:hover,
.close:hover {
  background: var(--hover);
}
.copy:active,
.close:active {
  scale: 0.95;
}
.copy:focus-visible,
.close:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.copy {
  display: grid;
  flex: 1;
  font-weight: 600;
  letter-spacing: -0.01em;
}
.copyFace {
  grid-area: 1 / 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.45em;
  transition:
    opacity 0.22s ease,
    filter 0.22s ease,
    translate 0.35s var(--spring);
}
.copyFace:last-child {
  opacity: 0;
  filter: blur(4px);
  translate: 0 0.5em;
}
.copy[data-copied] .copyFace:first-child {
  opacity: 0;
  filter: blur(4px);
  translate: 0 -0.5em;
}
.copy[data-copied] .copyFace:last-child {
  opacity: 1;
  filter: none;
  translate: none;
}
.copy[data-copied='done'] .copyFace:last-child {
  color: var(--green);
}
.close {
  display: grid;
  place-items: center;
  width: 2.75em;
  color: var(--ink-muted);
}
.close svg {
  width: 1.15em;
  height: 1.15em;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
}

.glyph {
  width: 1.15em;
  height: 1.15em;
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* For screen readers only: the announcements. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@keyframes pop {
  from {
    opacity: 0;
    scale: 0;
  }
}

/* Final states at once. */
@media (prefers-reduced-motion: reduce) {
  .root *,
  .root *::before,
  .root *::after {
    animation-duration: 0s !important;
    animation-delay: 0s !important;
    transition: none !important;
  }
}
```

### qr-share/qr.ts

```tsx
/*
 * 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. */
const BLOCKS: Record<Ecc, readonly { ec: number; data: readonly number[] }[]> = {
  L: [
    { ec: 7, data: [19] },
    { ec: 10, data: [34] },
    { ec: 15, data: [55] },
    { ec: 20, data: [80] },
    { ec: 26, data: [108] },
  ],
  M: [
    { ec: 10, data: [16] },
    { ec: 16, data: [28] },
    { ec: 26, data: [44] },
    { ec: 18, data: [32, 32] },
    { ec: 24, data: [43, 43] },
  ],
};

/** The two bits that name a level in the format information. */
const LEVEL_BITS: Record<Ecc, number> = { L: 1, M: 0 };

// GF(256), with the polynomial x⁸ + x⁴ + x³ + x² + 1: tables of powers of 2 and their logs.
const EXP = new Uint8Array(512);
const LOG = new Uint8Array(256);
for (let i = 0, x = 1; i < 255; i += 1) {
  EXP[i] = x;
  LOG[x] = i;
  x <<= 1;
  if (x & 0x100) x ^= 0x11d;
}
for (let i = 255; i < 512; i += 1) EXP[i] = EXP[i - 255];
const multiply = (a: number, b: number) => (a && b ? EXP[LOG[a] + LOG[b]] : 0);

/** The Reed–Solomon generator polynomial of degree `degree`, highest power first. */
function generator(degree: number) {
  let poly = [1];
  for (let i = 0; i < degree; i += 1) {
    const next = new Array<number>(poly.length + 1).fill(0);
    poly.forEach((coefficient, j) => {
      next[j] ^= coefficient;
      next[j + 1] ^= multiply(coefficient, EXP[i]);
    });
    poly = next;
  }
  return poly;
}

/** The error correction codewords for `data`: the remainder of its division by the generator. */
function correction(data: number[], degree: number) {
  const divisor = generator(degree);
  const remainder = new Array<number>(degree).fill(0);
  for (const byte of data) {
    const factor = byte ^ (remainder.shift() ?? 0);
    remainder.push(0);
    for (let i = 0; i < degree; i += 1) remainder[i] ^= multiply(divisor[i + 1], factor);
  }
  return remainder;
}

/** The format bits for a level and a mask: 5 bits of data, 10 of BCH code, then a fixed mask. */
function formatBits(ecc: Ecc, mask: number) {
  const data = (LEVEL_BITS[ecc] << 3) | mask;
  let remainder = data;
  for (let i = 0; i < 10; i += 1) remainder = (remainder << 1) ^ ((remainder >>> 9) * 0x537);
  return ((data << 10) | remainder) ^ 0x5412;
}

const MASKS: readonly ((x: number, y: number) => boolean)[] = [
  (x, y) => (x + y) % 2 === 0,
  (_, y) => y % 2 === 0,
  (x) => x % 3 === 0,
  (x, y) => (x + y) % 3 === 0,
  (x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
  (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
  (x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
  (x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
];

/** How much a finished symbol looks like it could confuse a reader: lower is better. */
function penalty(grid: boolean[][]) {
  const size = grid.length;
  let score = 0;
  const lines = [...grid, ...grid.map((_, x) => grid.map((row) => row[x]))];
  for (const line of lines) {
    // Runs of five or more modules of one colour.
    let run = 1;
    for (let i = 1; i <= size; i += 1) {
      if (i < size && line[i] === line[i - 1]) {
        run += 1;
        continue;
      }
      if (run >= 5) score += run - 2;
      run = 1;
    }
    // Patterns that look like a finder: dark, light, dark × 3, light, dark, beside four light.
    const text = line.map((dark) => (dark ? '1' : '0')).join('');
    for (const pattern of ['10111010000', '00001011101']) {
      for (let at = text.indexOf(pattern); at !== -1; at = text.indexOf(pattern, at + 1)) {
        score += 40;
      }
    }
  }
  // Blocks of 2 × 2 of one colour.
  for (let y = 0; y < size - 1; y += 1) {
    for (let x = 0; x < size - 1; x += 1) {
      const colour = grid[y][x];
      if (colour === grid[y][x + 1] && colour === grid[y + 1][x] && colour === grid[y + 1][x + 1]) {
        score += 3;
      }
    }
  }
  // Too much dark, or too much light.
  const dark = grid.flat().filter(Boolean).length;
  score += Math.floor(Math.abs((dark * 100) / (size * size) - 50) / 5) * 10;
  return score;
}

/** Encodes `text` in the smallest version (1 to 5) that holds it at level `ecc`. */
export function encode(text: string, ecc: Ecc = 'M'): QrMatrix {
  const bytes = new TextEncoder().encode(text);
  const index = BLOCKS[ecc].findIndex(
    ({ data }) => 4 + 8 + bytes.length * 8 <= data.reduce((sum, n) => sum + n, 0) * 8,
  );
  if (index < 0) throw new Error(`Too long for a version 5 QR code at level ${ecc}: ${text}`);
  const version = index + 1;
  const { ec, data: blockSizes } = BLOCKS[ecc][index];
  const capacity = blockSizes.reduce((sum, n) => sum + n, 0);

  // 1. The bits: byte mode (0100), the length on 8 bits, the bytes, a terminator, padding.
  const bits: number[] = [];
  const push = (value: number, length: number) => {
    for (let i = length - 1; i >= 0; i -= 1) bits.push((value >>> i) & 1);
  };
  push(0b0100, 4);
  push(bytes.length, 8);
  bytes.forEach((byte) => push(byte, 8));
  push(0, Math.min(4, capacity * 8 - bits.length));
  push(0, (8 - (bits.length % 8)) % 8);
  const codewords: number[] = [];
  for (let i = 0; i < bits.length; i += 8) {
    codewords.push(bits.slice(i, i + 8).reduce((byte, bit) => (byte << 1) | bit, 0));
  }
  for (let pad = 0xec; codewords.length < capacity; pad ^= 0xec ^ 0x11) codewords.push(pad);

  // 2. Each block with its error correction, then interleaved: data first, then correction.
  let offset = 0;
  const blocks = blockSizes.map((length) => {
    const data = codewords.slice(offset, (offset += length));
    return { data, ec: correction(data, ec) };
  });
  const sequence: number[] = [];
  for (let i = 0; i < Math.max(...blockSizes); i += 1) {
    blocks.forEach((block) => i < block.data.length && sequence.push(block.data[i]));
  }
  for (let i = 0; i < ec; i += 1) blocks.forEach((block) => sequence.push(block.ec[i]));

  // 3. The function patterns, marked as reserved so the data goes round them.
  const size = 17 + 4 * version;
  const base = Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
  const reserved = Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
  const fix = (x: number, y: number, dark: boolean) => {
    if (x < 0 || y < 0 || x >= size || y >= size) return;
    base[y][x] = dark;
    reserved[y][x] = true;
  };
  for (const [cx, cy] of [
    [3, 3],
    [size - 4, 3],
    [3, size - 4],
  ]) {
    // A finder: rings at distance 0–1 and 3 are dark; 2 and 4 (the separator) are light.
    for (let dy = -4; dy <= 4; dy += 1) {
      for (let dx = -4; dx <= 4; dx += 1) {
        const ring = Math.max(Math.abs(dx), Math.abs(dy));
        fix(cx + dx, cy + dy, ring !== 2 && ring !== 4);
      }
    }
  }
  for (let i = 8; i < size - 8; i += 1) {
    fix(i, 6, i % 2 === 0);
    fix(6, i, i % 2 === 0);
  }
  if (version > 1) {
    const at = size - 7;
    for (let dy = -2; dy <= 2; dy += 1) {
      for (let dx = -2; dx <= 2; dx += 1)
        fix(at + dx, at + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
    }
  }
  // The format bits' places (filled in once the mask is chosen), and the one dark module. Row and
  // column 6 belong to the timing lines.
  for (let i = 0; i < 9; i += 1) {
    if (i === 6) continue;
    fix(8, i, false);
    fix(i, 8, false);
  }
  for (let i = 0; i < 8; i += 1) {
    fix(size - 1 - i, 8, false);
    fix(8, size - 1 - i, false);
  }
  fix(8, size - 8, true);

  // 4. The bits, two columns at a time, up then down, from the right; column 6 is skipped.
  const stream = sequence.flatMap((byte) =>
    Array.from({ length: 8 }, (_, i) => (byte >>> (7 - i)) & 1),
  );
  let next = 0;
  for (let right = size - 1; right >= 1; right -= 2) {
    if (right === 6) right = 5;
    const upward = ((right + 1) & 2) === 0;
    for (let step = 0; step < size; step += 1) {
      const y = upward ? size - 1 - step : step;
      for (const x of [right, right - 1]) {
        if (reserved[y][x]) continue;
        // Past the last codeword, the remainder bits are light.
        base[y][x] = next < stream.length && stream[next] === 1;
        next += 1;
      }
    }
  }

  // 5. Every mask, with its format bits; the one with the lowest penalty wins.
  const masked = (mask: number) => {
    const grid = base.map((row, y) =>
      row.map((dark, x) => (reserved[y][x] ? dark : dark !== MASKS[mask](x, y))),
    );
    const format = formatBits(ecc, mask);
    const bit = (i: number) => ((format >>> i) & 1) === 1;
    for (let i = 0; i <= 5; i += 1) grid[i][8] = bit(i);
    grid[7][8] = bit(6);
    grid[8][8] = bit(7);
    grid[8][7] = bit(8);
    for (let i = 9; i < 15; i += 1) grid[8][14 - i] = bit(i);
    for (let i = 0; i < 8; i += 1) grid[8][size - 1 - i] = bit(i);
    for (let i = 8; i < 15; i += 1) grid[size - 15 + i][8] = bit(i);
    return grid;
  };
  let best = masked(0);
  let bestScore = penalty(best);
  for (let mask = 1; mask < 8; mask += 1) {
    const grid = masked(mask);
    const score = penalty(grid);
    if (score < bestScore) {
      best = grid;
      bestScore = score;
    }
  }
  return { version, size, dark: (x, y) => best[y]?.[x] ?? false };
}
```

### qr-share/qr-code.tsx

```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],
];

export function QrCode({
  value,
  level = 'M',
  label,
}: {
  value: string;
  level?: Ecc;
  label: string;
}) {
  const code = useMemo(() => encode(value, level), [value, level]);
  const { size, version } = code;
  const finders = finderCentres(size);
  /** The alignment pattern's centre (versions 2 and up). */
  const alignment = version > 1 ? size - 7 : null;

  const rings = useMemo(() => {
    const centre = (size - 1) / 2;
    const groups: [number, number][][] = [];
    for (let y = 0; y < size; y += 1) {
      for (let x = 0; x < size; x += 1) {
        if (!code.dark(x, y)) continue;
        const inFinder = finderCentres(size).some(
          ([fx, fy]) => Math.abs(x - fx) <= FINDER && Math.abs(y - fy) <= FINDER,
        );
        const inAlignment =
          alignment !== null &&
          Math.abs(x - alignment) <= ALIGNMENT &&
          Math.abs(y - alignment) <= ALIGNMENT;
        if (inFinder || inAlignment) continue;
        const ring = Math.round(Math.hypot(x - centre, y - centre) / 1.5);
        (groups[ring] ??= []).push([x, y]);
      }
    }
    return groups;
  }, [code, size, alignment]);

  return (
    <svg
      className={styles.qr}
      data-part="code"
      viewBox={`0 0 ${size} ${size}`}
      role="img"
      aria-label={label}
    >
      {finders.map(([x, y]) => (
        <g key={`${x}-${y}`} className={styles.finder} data-part="finder">
          <rect
            x={x - 2.5}
            y={y - 2.5}
            width="6"
            height="6"
            rx="1.8"
            className={styles.finderRing}
          />
          <rect x={x - 1} y={y - 1} width="3" height="3" rx="0.95" />
        </g>
      ))}
      {alignment !== null && (
        <g className={styles.finder} data-part="alignment">
          <rect
            x={alignment - 1.5}
            y={alignment - 1.5}
            width="4"
            height="4"
            rx="1.25"
            className={styles.finderRing}
          />
          <circle cx={alignment + 0.5} cy={alignment + 0.5} r="0.55" />
        </g>
      )}
      {rings.map((dots, ring) => (
        <g key={ring} data-part="ring" style={{ '--ring': ring } as CSSProperties}>
          {dots.map(([x, y]) => (
            <circle key={`${x}-${y}`} cx={x + 0.5} cy={y + 0.5} r="0.42" />
          ))}
        </g>
      ))}
    </svg>
  );
}
```

### qr-share/ghost-pointer.tsx

```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 {}

type Point = { x: number; y: number };

/** Where the arrow's tip is, in its 20 × 20 box. */
const TIP: Point = { x: 3, y: 2 };

const place = ({ x, y }: Point) => `translate(${x - TIP.x}px, ${y - TIP.y}px)`;

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: SVGSVGElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.8, y: stage.offsetHeight * 0.9 };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const frame = stage.getBoundingClientRect();
    // Screen pixels to the stage's own, should the stage sit in a scaled container.
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    const to = {
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    };
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(950, Math.max(360, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const press: Demo['press'] = async (action) => {
    const arrow = pointer.firstElementChild;
    const tap = arrow?.animate(
      [{ transform: 'none' }, { transform: 'scale(0.8)', offset: 0.35 }, { transform: 'none' }],
      { duration: 280, easing: 'ease-in-out' },
    );
    await wait(95);
    action();
    if (tap) await step(tap.finished);
  };

  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, moveTo, press }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    pointer.style.opacity = '0';
  };
}

/** Its colours are the stage's tokens (qr-share.module.css). */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 10,
  width: 22,
  height: 22,
  overflow: 'visible',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  fill: 'var(--pointer)',
  stroke: 'var(--pointer-edge)',
  strokeWidth: 1.3,
  strokeLinejoin: 'round',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
};

/** The arrow the demo moves around. It is decorative: the demo's effects are announced. */
export function GhostPointer({ ref }: { ref: Ref<SVGSVGElement> }) {
  return (
    <svg ref={ref} viewBox="0 0 20 20" style={POINTER} aria-hidden="true">
      <path
        d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
        style={{ transformBox: 'fill-box', transformOrigin: '0 0' }}
      />
    </svg>
  );
}
```

### qr-share/use-autoplay.ts

```tsx
'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;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```