# Payment link share

> A dialog for sending a payment link: people orbit the link, the field copies it, and five tiles share it.

The sheet that opens when you share a payment link. Its grey header holds a tilted orbit where portraits drift around a link, sharp in front and blurred behind; under it, the link sits in a pill with a copy button, and five share tiles lift on hover. It opens over a dimmed list of recipients, the backdrop darkening while the sheet comes in from a blur.

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

Category: Feedback.

## Anatomy

1. **Dialog**: A white sheet on an 8.5 px rim with 35 px corners, 400 px wide where it fits; it comes in from a 7 px blur over the dimmed list.
2. **Header**: The grey panel that holds the orbit, the title and the line under it. Its art is drawn at 383 px and scaled as one picture.
3. **Link mark**: The disc at the orbit’s centre: two linked rounded bars, a little in front of the people behind and under those in front.
4. **Portrait**: One of seven people riding a tilted ellipse at 15° a second. Near ones are sharp and over the link; far ones shrink, fade and blur.
5. **Close button**: A round button in the header’s corner, dark on the light sheet and light on the dark one. It closes the sheet, as do Escape and a click on the backdrop.
6. **Link field**: A read-only pill with the link, selected whole on focus, and the copy button at its end.
7. **Copy button**: Two squares that give way to a check and a “Copied” tip for 1.6 s, while the field’s edge turns green.
8. **Share tiles**: Five buttons, each a coloured 44 px tile and a label. Without `onShare`, Email and Message open a draft and the others copy.

## When to use

- Handing a customer one link to pay: an invoice, a checkout or a deposit request sent outside the app.
- A share step that should feel like a moment, with people around the link saying who it is for.
- One link with a few ways to send it: copy, email, text and a handful of apps, all in one sheet.
- A dialog over a list the reader came from (recipients, invoices), so closing it puts them back in place.

## When not to use

- Sharing a page or a file in general. Use the system share sheet (`navigator.share`) on phones.
- A link that must be kept secret or expires in seconds. Use a one-time code or a QR shown in place.
- Asking for the payment itself. Use a checkout form; this sheet only sends the link.
- A plain “copied” moment in a busy toolbar. Use a copy button with a tooltip, not a dialog.
- More than five or six channels. Use a searchable list of contacts or apps; the tiles are one row.

## Installation

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

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

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 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 { PaymentLinkShare } from './payment-link-share';

export function InvoiceShare({ url }: { url: string }) {
  return (
    <PaymentLinkShare
      link={url}
      autoplay={false}
      onShare={(channel) => console.log('Shared via', channel)}
    />
  );
}
```

## States

- **Open**: The sheet over the dimmed list, the people still where they start.
- **Closed**: The list of recipients with its Share link button; the dialog is inert and hidden.
- **Copied**: The check in place of the squares, the green edge and the tip over the button.
- **Orbiting**: The people ride the orbit while it is on screen; off screen and under reduced motion they stop.
- **Narrow**: A 360 px stage: the header’s art scales down as one picture, the list drops two columns and the tiles share even columns.
- **Own words and a long link**: Title and line set by props; a long link scrolls inside its field and keeps the copy button in place.
- **Dark**: A near-black sheet and header, a light close button and a lighter track.

## How it works

- The orbit is an ellipse seen at an angle: 170.5 by 59.5 px, turned 12° so it rises to the right, centred just behind the link. Seven portraits ride it at 15° a second, the near side moving left.
- Each frame places every portrait from its angle with one transform. On the far side it shrinks a little, fades and blurs, like a camera focused on the front, and slips under the link; on the near side it is sharp and passes over it.
- The track is three thin rings of the same ellipse, each a step further out and fainter, stroked with a gradient that fades towards the back.
- The header’s art is drawn at the dialog’s own 383 px and scaled as one picture when the dialog is narrower; the title, the text and the controls keep their size.
- Opening, the backdrop darkens over 230 ms on an ease-in-out while the sheet comes in from a 7 px blur over 160 ms, 120 ms behind it. Closing plays the same, faster.
- Copying swaps the two squares for a check that springs in, a Copied tip rises over the button and the field’s edge turns green for 1.6 s. Share tiles lift 2 px on hover and sink on press; the close button’s cross turns a quarter.
- While on screen and untouched, a demo pointer hovers each share tile, copies the link, closes the sheet and opens it again from the list. Any real input hands the stage to the reader.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme or the colorScheme prop; at night the sheet is near-black and the close button turns light.

## API reference

- **link** (`string`, default `'https://pay.example/6329103'`): The link to share, shown in the field and copied by its button.
- **title** (`string`, default `'Share payment link'`): The dialog’s title.
- **description** (`string`, default `'Send this link to your customer so they can complete their payment.'`): The line under the title.
- **defaultOpen** (`boolean`, default `true`): Opens the dialog the first time the stage comes on screen, so its entrance plays in view; otherwise the list’s Share link button opens it.
- **defaultCopied** (`boolean`, default `false`): Opens with the link shown as just copied (the check, the green edge and the Copied tip), held until the reader copies again or closes the dialog. Turns the demo off.
- **paused** (`boolean`, default `false`): Holds the portraits still where they start, as under reduced motion. Turns the demo off.
- **onShare** (`(channel: 'email' | 'message' | 'network' | 'post' | 'chat', link: string) => void`): Called when a share tile is pressed. Without it, Email and Message start a new email or text with the link, and the others copy it.
- **onCopy** (`(link: string) => void`): Called after the link is copied.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer hovers the share tiles, copies the link, closes the dialog and opens it again.
- **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.

## Accessibility

- The sheet is a dialog named by its title and described by its text. Opening it from the list moves focus into it; Escape, the close button or a click on the backdrop closes it and returns focus to the button that opened it.
- While it is open the list behind is inert, and while it is closed the dialog is inert and hidden from screen readers.
- The link is a read-only text field with a visible label, selected whole on focus. The copy button is named “Copy link”, then “Link copied”, and a polite live region announces the copy and every share.
- The share targets are a list of buttons, each named by its visible label. Every control shows a focus ring for the keyboard.
- The orbit, the portraits and the link picture are decorative (aria-hidden). Under reduced motion the orbit stands still, the dialog opens and closes without fading or blurring, and the demo never plays.

## Source

### payment-link-share/payment-link-share.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useEffect, useEffectEvent, useId, useRef, useState } from 'react';
import { PORTRAITS, PortraitArt } from './avatars';
import { DemoPointer, runDemo } from './demo-pointer';
import { CHANNELS, ShareGlyph, type ShareChannel } from './share-icons';
import { useAutoplay } from './use-autoplay';
import styles from './payment-link-share.module.css';

/*
 * Payment link share: the dialog that hands a customer a link to pay. Over a dimmed list of
 * recipients, a white sheet opens with a grey header where people ride a tilted orbit around a
 * link, then the link itself in a pill with a copy button, then five places to share it.
 *
 * - The orbit is an ellipse seen at an angle: 170.5 × 59.5 px, turned 12° so it rises to the
 *   right, centred behind the link. Seven portraits ride it at 15° a second, the front going left.
 *   Each frame places them by transform from their angle: on the far side they shrink a little,
 *   fade and blur (depth of field), and slip under the link; on the near side they are sharp and
 *   over it. The track is three thin rings of that ellipse, fading towards the back.
 * - The header's art is drawn at the dialog's own 383 px and scaled as one picture when the
 *   dialog is narrower; the text under it keeps its size.
 * - Opening: the backdrop darkens over 230 ms (ease-in-out) while the sheet comes in from a 7 px
 *   blur over 160 ms, 120 ms behind it. Closing plays the same, faster. Escape closes; the list
 *   behind is inert while the dialog is open.
 * - Copy: the two squares give way to a check and a "Copied" tip rises over the button for
 *   1.6 s. Share tiles lift on hover and sink on press.
 * - While on screen and untouched, a demo pointer hovers the share tiles, copies the link, closes
 *   the sheet and opens it again. Any real input hands the stage to the reader.
 * - The orbit stops off screen, in a hidden tab and under reduced motion (the people then stand
 *   still where they start).
 */

/** The art's own width: the header's width in a 400 px dialog. */
const ART_WIDTH = 383;
/** The orbit, in the art's px: centre, radii and tilt. */
const ORBIT = { x: 288, y: 58.65, rx: 170.5, ry: 59.5, tilt: (-12 * Math.PI) / 180 };
/** Degrees a second along the orbit; the near side moves left. */
const SPEED = 15;
/** Where each portrait starts on the orbit, in degrees (90 is nearest the viewer). */
const PHASES = [-157.5, -109.5, -61.5, -11.5, 38.5, 79.5, 128];
/** A disc's size. */
const DISC = 46;
/** How long the copied state lasts. */
const COPIED_MS = 1600;

/** A point of the orbit and how near it is (1 nearest, -1 farthest). */
function onOrbit(degrees: number) {
  const angle = (degrees * Math.PI) / 180;
  const u = ORBIT.rx * Math.cos(angle);
  const v = ORBIT.ry * Math.sin(angle);
  const cos = Math.cos(ORBIT.tilt);
  const sin = Math.sin(ORBIT.tilt);
  return {
    x: ORBIT.x + u * cos - v * sin,
    y: ORBIT.y + u * sin + v * cos,
    near: Math.sin(angle),
  };
}

/** Writes a portrait's place, size, fade, blur and layer for an angle on the orbit. */
function place(disc: HTMLElement, degrees: number) {
  const { x, y, near } = onOrbit(degrees);
  const far = Math.max(0, -near);
  const scale = 0.975 + 0.025 * near;
  const blur = Math.max(0, far - 0.45) * 14;
  disc.style.transform = `translate(${(x - DISC / 2).toFixed(2)}px, ${(y - DISC / 2).toFixed(2)}px) scale(${scale.toFixed(3)})`;
  disc.style.opacity = (1 - Math.max(0, far - 0.3) * 0.45).toFixed(3);
  disc.style.filter = blur > 0.05 ? `blur(${blur.toFixed(2)}px)` : 'none';
  disc.style.zIndex = near > 0 ? '3' : '1';
}

/** Three rings of the orbit, a step further out each, fading towards the back. */
const RINGS = [
  { grow: 0, alpha: 0.21 },
  { grow: 6, alpha: 0.09 },
  { grow: 12, alpha: 0.045 },
];

export function PaymentLinkShare({
  link = 'https://pay.example/6329103',
  title = 'Share payment link',
  description = 'Send this link to your customer so they can complete their payment.',
  defaultOpen = true,
  defaultCopied = false,
  paused = false,
  onShare,
  onCopy,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The link to share. */
  link?: string;
  /** The dialog's title. */
  title?: string;
  /** The line under the title. */
  description?: string;
  /** Opens the dialog as soon as the stage comes on screen; otherwise the page's button does. */
  defaultOpen?: boolean;
  /** Opens with the link shown as just copied (check, green edge and tip), held until the reader
   * acts; turns the demo off. */
  defaultCopied?: boolean;
  /** Holds the people still where they start, as under reduced motion; turns the demo off. */
  paused?: boolean;
  /**
   * Called when a share tile is pressed. Without it, Email and Message open a new email or text
   * with the link, and the others copy it.
   */
  onShare?: (channel: ShareChannel, link: string) => void;
  /** Called after the link is copied. */
  onCopy?: (link: string) => void;
  /** While on screen and untouched, a demo pointer hovers, copies, closes and reopens. */
  autoplay?: boolean;
  /** 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>(5000);
  const [open, setOpen] = useState(false);
  const [copied, setCopied] = useState(defaultCopied);
  const [message, setMessage] = useState('');
  const [onScreen, setOnScreen] = useState(false);
  const header = useRef<HTMLDivElement>(null);
  const discs = useRef<(HTMLSpanElement | null)[]>([]);
  const dialog = useRef<HTMLDivElement>(null);
  const opener = useRef<HTMLButtonElement>(null);
  const closer = useRef<HTMLButtonElement>(null);
  const copier = useRef<HTMLButtonElement>(null);
  const tiles = useRef<(HTMLButtonElement | null)[]>([]);
  const pointer = useRef<HTMLSpanElement>(null);
  const copiedTimer = useRef(0);
  const opened = useRef(false);
  const titleId = useId();
  const textId = useId();

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

  // On screen, and the tab visible: the orbit turns only then.
  useEffect(() => {
    const element = root.current;
    if (!element) return;
    let inView = false;
    const update = () => setOnScreen(inView && !document.hidden);
    const observer = new IntersectionObserver(([entry]) => {
      inView = entry.isIntersecting;
      update();
    });
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
    };
  }, [root]);

  // The first time the stage is seen, the dialog opens, so its entrance plays in view.
  useEffect(() => {
    if (!onScreen || opened.current) return;
    opened.current = true;
    if (!defaultOpen) return;
    const timer = window.setTimeout(() => setOpen(true), 280);
    return () => window.clearTimeout(timer);
  }, [onScreen, defaultOpen]);

  // The art scales with the header: one factor, --k, for the picture and the room it takes.
  useEffect(() => {
    const box = header.current;
    if (!box) return;
    const observer = new ResizeObserver(() => {
      box.style.setProperty('--k', String(Math.min(1, box.clientWidth / ART_WIDTH)));
    });
    observer.observe(box);
    return () => observer.disconnect();
  }, []);

  // The orbit: each frame places the portraits from the time it has run.
  useEffect(() => {
    const items = discs.current.filter((disc): disc is HTMLSpanElement => !!disc);
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)');
    let frame = 0;
    let last = 0;
    let turned = 0;
    const draw = () => items.forEach((disc, i) => place(disc, PHASES[i] + turned));
    const tick = (now: number) => {
      if (last) turned = (turned + ((now - last) / 1000) * SPEED) % 360;
      last = now;
      draw();
      frame = requestAnimationFrame(tick);
    };
    const start = () => {
      cancelAnimationFrame(frame);
      last = 0;
      if (onScreen && open && !reduced.matches && !paused) frame = requestAnimationFrame(tick);
      else draw();
    };
    start();
    reduced.addEventListener('change', start);
    return () => {
      cancelAnimationFrame(frame);
      reduced.removeEventListener('change', start);
    };
  }, [onScreen, open, paused]);

  useEffect(() => () => window.clearTimeout(copiedTimer.current), []);

  /** Where focus goes once the dialog has opened or closed (it can't move into inert content). */
  const pendingFocus = useRef(false);
  useEffect(() => {
    if (!pendingFocus.current) return;
    pendingFocus.current = false;
    if (open) dialog.current?.focus({ preventScroll: true });
    else opener.current?.focus({ preventScroll: true });
  }, [open]);

  const show = (focus: boolean) => {
    pendingFocus.current = focus;
    setOpen(true);
  };
  const hide = (focus: boolean) => {
    pendingFocus.current = focus;
    setOpen(false);
    setCopied(false);
  };

  const copy = async (announcement = 'Link copied') => {
    try {
      await navigator.clipboard?.writeText(link);
    } catch {
      // A page without clipboard access still shows the feedback; the link stays selectable.
    }
    setCopied(true);
    window.clearTimeout(copiedTimer.current);
    copiedTimer.current = window.setTimeout(() => setCopied(false), COPIED_MS);
    announce(announcement);
    onCopy?.(link);
  };

  const share = (channel: ShareChannel, label: string) => {
    if (onShare) {
      onShare(channel, link);
      announce(`Sharing to ${label}`);
      return;
    }
    if (channel === 'email')
      window.location.href = `mailto:?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(link)}`;
    else if (channel === 'message') window.location.href = `sms:?&body=${encodeURIComponent(link)}`;
    else void copy(`Link copied, ready to paste in ${label}`);
  };

  // The demo's hands: hover and press are attributes the stylesheet reads as it would :hover
  // and :active, and a press then does what a click does.
  const demoAct = useEffectEvent((action: 'open' | 'close' | 'copy') => {
    if (action === 'open') show(false);
    else if (action === 'close') hide(false);
    else void copy();
  });
  const isOpen = useEffectEvent(() => open);
  const demo = autoplay && playing && !defaultCopied && !paused;
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    let hovered: HTMLElement | null = null;
    const hover = (element: HTMLElement | null) => {
      if (hovered) delete hovered.dataset.hover;
      hovered = element;
      if (element) element.dataset.hover = '';
    };
    /** Where an element's middle is, in the stage's own px. */
    const middleOf = (element: HTMLElement, dx = 0, dy = 0) => {
      const frame = stage.getBoundingClientRect();
      const scale = frame.width / stage.offsetWidth || 1;
      const rect = element.getBoundingClientRect();
      return {
        x: (rect.left + rect.width / 2 - frame.left) / scale + dx,
        y: (rect.top + rect.height / 2 - frame.top) / scale + dy,
      };
    };
    const stop = runDemo(stage, arrow, async ({ wait, glide, glyph }) => {
      const press = async (element: HTMLElement, action: 'open' | 'close' | 'copy') => {
        element.dataset.press = '';
        await wait(130);
        delete element.dataset.press;
        demoAct(action);
      };
      const visit = async (element: HTMLElement | null, ms: number, hold: number) => {
        if (!element) return;
        await glide(middleOf(element, 2, 3), ms);
        glyph('hand');
        hover(element);
        await wait(hold);
      };
      const leave = () => {
        hover(null);
        glyph('arrow');
      };
      await wait(900);
      for (;;) {
        if (!isOpen()) {
          await visit(opener.current, 800, 380);
          if (opener.current) await press(opener.current, 'open');
          leave();
          await wait(1300);
        }
        for (const tile of tiles.current) await visit(tile, 420, 300);
        leave();
        await visit(copier.current, 620, 340);
        if (copier.current) await press(copier.current, 'copy');
        await wait(900);
        leave();
        if (header.current) await glide(middleOf(header.current, -40, -30), 760);
        await wait(1500);
        await visit(closer.current, 640, 380);
        if (closer.current) await press(closer.current, 'close');
        leave();
        await wait(1100);
      }
    });
    return () => {
      stop();
      hover(null);
    };
  }, [demo, root]);

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      data-open={open}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onPointerMove={(event) => {
        if (event.pointerType !== 'touch') take();
      }}
    >
      <div className={styles.page} data-part="page" inert={open}>
        <div className={styles.pageHead}>
          <div>
            <p className={styles.pageTitle}>Recipients</p>
            <p className={styles.pageNote}>Parties you can pay · 14 recipients</p>
          </div>
          <button
            ref={opener}
            type="button"
            className={styles.opener}
            data-part="open-button"
            onClick={() => show(true)}
            aria-haspopup="dialog"
          >
            Share link
          </button>
        </div>
        <div className={styles.table} aria-hidden="true">
          <div className={`${styles.row} ${styles.headRow}`}>
            <span>Name</span>
            <span>Country</span>
            <span className={styles.method}>Method</span>
            <span className={styles.account}>Account</span>
          </div>
          {ROWS.map((row, i) => (
            <div key={i} className={styles.row} data-hot={i === 1 || undefined}>
              <span className={styles.who}>
                <span className={styles.badge} data-kind={row.kind} />
                {row.name}
              </span>
              <span className={styles.stack}>
                <b>{row.currency}</b>
                {row.country}
              </span>
              <span className={styles.method}>{row.method}</span>
              <span className={`${styles.stack} ${styles.account}`}>
                <b>{row.account}</b>
                {row.type}
              </span>
            </div>
          ))}
        </div>
      </div>

      <div
        className={styles.scrim}
        data-part="backdrop"
        aria-hidden="true"
        onClick={() => hide(true)}
      />

      <div
        ref={dialog}
        className={styles.dialog}
        data-part="dialog"
        role="dialog"
        aria-labelledby={titleId}
        aria-describedby={textId}
        aria-hidden={!open}
        inert={!open}
        tabIndex={-1}
        onKeyDown={(event) => {
          if (event.key === 'Escape') {
            event.stopPropagation();
            hide(true);
          }
        }}
      >
        <div ref={header} className={styles.header} data-part="header">
          <div className={styles.art} data-part="orbit" aria-hidden="true">
            <svg className={styles.track} viewBox={`0 0 ${ART_WIDTH} 226`} focusable="false">
              <defs>
                <linearGradient
                  id={`${titleId}fade`}
                  x1="0"
                  y1="0"
                  x2="0"
                  y2="130"
                  gradientUnits="userSpaceOnUse"
                >
                  <stop offset="0" style={{ stopColor: 'var(--track)' }} stopOpacity="0.12" />
                  <stop offset="0.55" style={{ stopColor: 'var(--track)' }} stopOpacity="0.55" />
                  <stop offset="1" style={{ stopColor: 'var(--track)' }} stopOpacity="1" />
                </linearGradient>
              </defs>
              <g
                transform={`rotate(-12 ${ORBIT.x} ${ORBIT.y})`}
                fill="none"
                stroke={`url(#${titleId}fade)`}
              >
                {RINGS.map(({ grow, alpha }) => (
                  <ellipse
                    key={grow}
                    cx={ORBIT.x}
                    cy={ORBIT.y}
                    rx={ORBIT.rx + grow}
                    ry={ORBIT.ry + grow * 0.35}
                    strokeOpacity={alpha / 0.21}
                    vectorEffect="non-scaling-stroke"
                  />
                ))}
              </g>
            </svg>
            <span className={styles.linkDisc} data-part="link-disc">
              <svg className={styles.linkGlyph} viewBox="0 0 24 24" focusable="false">
                <g transform="rotate(45 12 12)" fill="none" strokeWidth="1.5">
                  <rect
                    className={styles.linkBack}
                    x="1.2"
                    y="8.4"
                    width="11.4"
                    height="7.2"
                    rx="3.6"
                  />
                  <rect
                    className={styles.linkFront}
                    x="11.4"
                    y="8.4"
                    width="11.4"
                    height="7.2"
                    rx="3.6"
                  />
                </g>
              </svg>
            </span>
            {PORTRAITS.map((portrait, i) => (
              <span
                key={i}
                ref={(element) => {
                  discs.current[i] = element;
                }}
                className={styles.disc}
                data-part="person"
              >
                <PortraitArt portrait={portrait} className={styles.portrait} />
              </span>
            ))}
          </div>
          <button
            ref={closer}
            type="button"
            className={styles.close}
            data-part="close"
            aria-label="Close"
            onClick={() => hide(true)}
          >
            <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
              <path d="m3.2 3.2 9.6 9.6m0-9.6-9.6 9.6" />
            </svg>
          </button>
          <h2 id={titleId} className={styles.title}>
            {title}
          </h2>
          <p id={textId} className={styles.text}>
            {description}
          </p>
        </div>

        <div className={styles.body}>
          <label className={styles.label} htmlFor={`${titleId}link`}>
            Share your link
          </label>
          <div className={styles.field} data-part="link-field" data-copied={copied}>
            <input
              id={`${titleId}link`}
              className={styles.input}
              value={link}
              readOnly
              spellCheck={false}
              onFocus={(event) => event.currentTarget.select()}
            />
            <button
              ref={copier}
              type="button"
              className={styles.copy}
              data-part="copy"
              data-copied={copied}
              aria-label={copied ? 'Link copied' : 'Copy link'}
              onClick={() => void copy()}
            >
              <span className={styles.squares} aria-hidden="true">
                <span className={styles.squareBack} />
                <span className={styles.squareFront} />
              </span>
              <svg
                className={styles.check}
                viewBox="0 0 16 16"
                aria-hidden="true"
                focusable="false"
              >
                <path d="m3.5 8.4 2.9 2.9 6.1-6.6" />
              </svg>
              <span className={styles.tip} aria-hidden="true">
                Copied
              </span>
            </button>
          </div>
          <p className={styles.label} id={`${titleId}to`}>
            Share to
          </p>
          <ul className={styles.targets} aria-labelledby={`${titleId}to`} data-part="share-targets">
            {CHANNELS.map(({ id, label }, i) => (
              <li key={id}>
                <button
                  ref={(element) => {
                    tiles.current[i] = element;
                  }}
                  type="button"
                  className={styles.target}
                  data-part="share-target"
                  onClick={() => share(id, label)}
                >
                  <span className={styles.tile} data-channel={id}>
                    <ShareGlyph channel={id} className={styles.glyph} />
                  </span>
                  <span className={styles.targetLabel}>{label}</span>
                </button>
              </li>
            ))}
          </ul>
        </div>
      </div>

      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer
          ref={pointer}
          className={styles.pointer}
          arrowClass={styles.arrow}
          handClass={styles.hand}
        />
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

/** The list behind the dialog: invented people and companies. */
const ROWS: {
  name: string;
  kind: 'company' | 'person';
  currency: string;
  country: string;
  method: string;
  account: string;
  type: string;
}[] = [
  {
    name: 'Halvard Freight AS',
    kind: 'company',
    currency: 'USD',
    country: 'United States',
    method: 'Wire',
    account: 'US31…4410',
    type: 'Routing',
  },
  {
    name: 'Lucía Marín',
    kind: 'person',
    currency: 'JPY',
    country: 'Japan',
    method: 'Local',
    account: '0418…2275',
    type: 'Account number',
  },
  {
    name: 'Westbrook Supply',
    kind: 'company',
    currency: 'CAD',
    country: 'Canada',
    method: 'Book transfer',
    account: 'CA72…0931',
    type: 'Transit',
  },
  {
    name: 'Kessel Werkzeug GmbH',
    kind: 'company',
    currency: 'EUR',
    country: 'Germany',
    method: 'SEPA',
    account: 'DE19…6620',
    type: 'IBAN',
  },
  {
    name: 'Tomás Riera',
    kind: 'person',
    currency: 'MXN',
    country: 'Mexico',
    method: 'Local',
    account: '6120…3348',
    type: 'CLABE',
  },
  {
    name: 'Orla Studio Ltda',
    kind: 'company',
    currency: 'BRL',
    country: 'Brazil',
    method: 'SWIFT',
    account: 'BR07…5512',
    type: 'IBAN',
  },
  {
    name: 'Saarni Design Oy',
    kind: 'company',
    currency: 'EUR',
    country: 'Finland',
    method: 'SEPA',
    account: 'FI44…0187',
    type: 'IBAN',
  },
  {
    name: 'Amara Okafor',
    kind: 'person',
    currency: 'ZAR',
    country: 'South Africa',
    method: 'Wire',
    account: '2290…7714',
    type: 'Account number',
  },
  {
    name: 'Tidewater Goods',
    kind: 'company',
    currency: 'AUD',
    country: 'Australia',
    method: 'Local',
    account: '0623…9051',
    type: 'BSB',
  },
];
```

### payment-link-share/payment-link-share.module.css

```css
/* Payment link share (payment-link-share.tsx): a list of recipients on a warm off-white page,
   dimmed under a white dialog. The dialog is 400 px wide where it fits, and keeps its measures
   (8.5 px rim, 35 px corners, 15 px type) when narrower; only the header's art scales, by --k.

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

   Memory. The stage is one layout boundary, sized from outside. The portraits move by transforms
   (and a blur on the far side) written once per frame; the dialog's entrance is opacity and a
   filter, played by the compositor. */

.root {
  /* The page behind, its words, and the dim over it. */
  --page: #f5f5f2;
  --page-ink: #1b1b1c;
  --page-muted: #707072;
  --page-hot: #ebebe8;
  --badge: #e6e6e3;
  --scrim: rgb(0 4 11 / 0.515);
  /* The dialog, its header, and its words. */
  --sheet: #fff;
  --head: #f3f3f1;
  --glow: rgb(255 255 255 / 0.55);
  --ink: #000;
  --muted: #44464a;
  /* The orbit's discs, their edge and lift, and the track. */
  --disc: #f6f6f5;
  --disc-edge: rgb(0 0 0 / 0.07);
  --disc-shadow: rgb(0 0 0 / 0.07);
  --track: rgb(0 0 0 / 0.21);
  --link-front: #111;
  --link-back: #9b9b9b;
  /* The close button. */
  --close: #0c0c0c;
  --close-hover: #2a2a2a;
  --close-ink: #fff;
  /* The link field and its copy button. */
  --field-edge: #e8e8e8;
  --field-hover: #d6d6d6;
  --square-front: #0b0b0d;
  --square-back: #979797;
  --done: #17a34a;
  --tip: #111;
  --tip-ink: #fff;
  --sheet-shadow: rgb(0 0 0 / 0.18);
  --button: #111;
  --button-ink: #fff;
  /* The same on both schemes: the focus ring, and the demo's pointer, which looks like the
     system's. */
  --focus: #387bff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  position: relative;
  container-type: inline-size;
  contain: layout paint;
  width: 100%;
  min-height: 580px;
  height: 580px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--page);
  color: var(--page-ink);
  font-family: Inter, system-ui, sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --page: light-dark(#f5f5f2, #111113);
    --page-ink: light-dark(#1b1b1c, #e9e9ec);
    --page-muted: light-dark(#707072, #8d8d93);
    --page-hot: light-dark(#ebebe8, #1c1c1f);
    --badge: light-dark(#e6e6e3, #26262a);
    --scrim: light-dark(rgb(0 4 11 / 0.515), rgb(0 0 0 / 0.62));
    --sheet: light-dark(#fff, #18181b);
    --head: light-dark(#f3f3f1, #222226);
    --glow: light-dark(rgb(255 255 255 / 0.55), rgb(255 255 255 / 0.035));
    --ink: light-dark(#000, #f4f4f5);
    --muted: light-dark(#44464a, #a3a3aa);
    --disc: light-dark(#f6f6f5, #2b2b30);
    --disc-edge: light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.08));
    --disc-shadow: light-dark(rgb(0 0 0 / 0.07), rgb(0 0 0 / 0.35));
    --track: light-dark(rgb(0 0 0 / 0.21), rgb(255 255 255 / 0.2));
    --link-front: light-dark(#111, #f4f4f5);
    --link-back: light-dark(#9b9b9b, #6e6e75);
    --close: light-dark(#0c0c0c, #f4f4f5);
    --close-hover: light-dark(#2a2a2a, #d9d9de);
    --close-ink: light-dark(#fff, #111);
    --field-edge: light-dark(#e8e8e8, #303035);
    --field-hover: light-dark(#d6d6d6, #45454c);
    --square-front: light-dark(#0b0b0d, #f4f4f5);
    --square-back: light-dark(#979797, #6b6b72);
    --done: light-dark(#17a34a, #3ccf74);
    --tip: light-dark(#111, #f4f4f5);
    --tip-ink: light-dark(#fff, #111);
    --sheet-shadow: light-dark(rgb(0 0 0 / 0.18), rgb(0 0 0 / 0.5));
    --button: light-dark(#111, #f4f4f5);
    --button-ink: light-dark(#fff, #111);
  }
}
/* 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 page behind ---- */
.page {
  position: absolute;
  inset: 0;
  padding: 26px clamp(16px, 5cqi, 40px);
  font-size: 13px;
}
.pageHead > div {
  min-width: 0;
}
.pageHead {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 18px;
}
.pageTitle {
  margin: 0;
  font-size: 17px;
  font-weight: 500;
  letter-spacing: -0.01em;
}
.pageNote {
  margin: 3px 0 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  color: var(--page-muted);
}
.opener {
  flex: none;
  height: 34px;
  padding: 0 14px;
  border: 0;
  border-radius: 17px;
  background: var(--button);
  color: var(--button-ink);
  font:
    500 13px/1 Inter,
    system-ui,
    sans-serif;
  cursor: pointer;
  transition:
    transform 0.15s ease,
    opacity 0.15s ease;
}
.opener:hover,
.opener[data-hover] {
  opacity: 0.86;
}
.opener:active,
.opener[data-press] {
  transform: scale(0.96);
}
.opener:focus-visible,
.close:focus-visible,
.copy:focus-visible,
.target:focus-visible,
.input:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.table {
  display: grid;
}
.row {
  display: grid;
  grid-template-columns: 1.5fr 1fr 0.8fr 1fr;
  align-items: center;
  gap: 12px;
  min-height: 52px;
  padding: 0 10px;
  border-radius: 14px;
}
.row[data-hot] {
  background: var(--page-hot);
}
.headRow {
  min-height: 30px;
  color: var(--page-muted);
}
.who {
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 0;
  font-weight: 500;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.badge {
  flex: none;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: var(--badge);
}
.badge[data-kind='person'] {
  background:
    radial-gradient(circle at 50% 36%, var(--page-muted) 0 3.2px, transparent 3.6px),
    radial-gradient(ellipse 7px 5px at 50% 82%, var(--page-muted) 0 99%, transparent), var(--badge);
}
.badge[data-kind='company'] {
  background:
    linear-gradient(var(--page-muted), var(--page-muted)) 50% 55% / 9px 11px no-repeat,
    var(--badge);
}
.stack {
  display: grid;
  gap: 2px;
  color: var(--page-muted);
  white-space: nowrap;
}
.stack b {
  color: var(--page-ink);
  font-weight: 500;
}
.method {
  white-space: nowrap;
}
@container (max-width: 640px) {
  .row {
    grid-template-columns: 1.4fr 1fr;
  }
  .method,
  .account {
    display: none;
  }
}

/* ---- The dim ---- */
.scrim {
  position: absolute;
  inset: 0;
  background: var(--scrim);
  opacity: 0;
  visibility: hidden;
  transition:
    opacity 0.18s ease-in,
    visibility 0s linear 0.18s;
}
.root[data-open='true'] .scrim {
  opacity: 1;
  visibility: visible;
  transition:
    opacity 0.23s cubic-bezier(0.42, 0, 0.58, 1) 0.015s,
    visibility 0s;
}

/* ---- The dialog ---- */
.dialog {
  position: absolute;
  top: 50%;
  left: 50%;
  width: min(400px, 100% - 24px);
  padding: 8.5px 8.5px 18px;
  border-radius: 35px;
  background: var(--sheet);
  color: var(--ink);
  box-shadow: 0 24px 64px -12px var(--sheet-shadow);
  translate: -50% -50%;
  opacity: 0;
  filter: blur(7px);
  visibility: hidden;
  outline: none;
  transition:
    opacity 0.14s ease-in,
    filter 0.14s ease-in,
    visibility 0s linear 0.14s;
}
.root[data-open='true'] .dialog {
  opacity: 1;
  filter: blur(0);
  visibility: visible;
  transition:
    opacity 0.16s cubic-bezier(0.2, 0.3, 0.5, 1) 0.118s,
    filter 0.18s cubic-bezier(0.2, 0.3, 0.5, 1) 0.118s,
    visibility 0s;
}

.header {
  --k: 1;
  position: relative;
  overflow: hidden;
  overflow: clip;
  padding: calc(143.6px * var(--k)) 12px 9.65px;
  border-radius: 26.5px;
  background: radial-gradient(70% 60% at 62% 42%, var(--glow), transparent 70%), var(--head);
}
.art {
  position: absolute;
  top: 0;
  left: 0;
  width: 383px;
  height: 226px;
  transform: scale(var(--k));
  transform-origin: 0 0;
  pointer-events: none;
}
.track {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.linkDisc,
.disc {
  position: absolute;
  top: 0;
  left: 0;
  width: 46px;
  height: 46px;
  border-radius: 50%;
  background: var(--disc);
  box-shadow:
    0 0 0 1px var(--disc-edge),
    0 1.5px 2px var(--disc-shadow),
    0 5px 12px -2px var(--disc-shadow);
}
.linkDisc {
  z-index: 2;
  display: grid;
  place-items: center;
  transform: translate(253.9px, 38.15px);
}
.linkGlyph {
  width: 28px;
  height: 28px;
  overflow: visible;
}
.linkBack {
  stroke: var(--link-back);
  filter: blur(0.35px);
}
.linkFront {
  stroke: var(--link-front);
}
.disc {
  display: grid;
  place-items: center;
  will-change: transform;
  transform-origin: 50% 50%;
}
.portrait {
  display: block;
  width: 31px;
  height: 31px;
  border-radius: 50%;
}

.close {
  position: absolute;
  top: 4.3px;
  right: 8.9px;
  z-index: 4;
  display: grid;
  place-items: center;
  width: 39px;
  height: 39px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--close);
  color: var(--close-ink);
  cursor: pointer;
  transition:
    background-color 0.15s ease,
    transform 0.15s ease;
}
.close svg {
  width: 15px;
  height: 15px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  transition: transform 0.25s cubic-bezier(0.3, 0.6, 0.3, 1);
}
.close:hover,
.close[data-hover] {
  background: var(--close-hover);
}
.close:hover svg,
.close[data-hover] svg {
  transform: rotate(90deg);
}
.close:active,
.close[data-press] {
  transform: scale(0.92);
}

.title {
  position: relative;
  margin: 0.7px 0 0;
  font-size: 14.4px;
  font-weight: 500;
  line-height: 20px;
  letter-spacing: 0;
}
.text {
  position: relative;
  max-width: 282px;
  margin: 7px 0 0;
  color: var(--muted);
  font-size: 14.75px;
  line-height: 23px;
}

.body {
  padding: 0 15.5px;
}
.label {
  display: block;
  margin: 26.6px 0 0;
  font-size: 12.7px;
  font-weight: 500;
  line-height: 16px;
}
.field {
  position: relative;
  display: flex;
  align-items: center;
  height: 39.6px;
  margin-top: 7.05px;
  border: 1px solid var(--field-edge);
  border-radius: 20px;
  transition: border-color 0.15s ease;
}
.field:hover {
  border-color: var(--field-hover);
}
.field[data-copied='true'] {
  border-color: color-mix(in srgb, var(--done) 45%, var(--field-edge));
}
.input {
  flex: 1;
  min-width: 0;
  height: 100%;
  padding: 0 0 0 16px;
  border: 0;
  border-radius: 20px;
  background: none;
  color: var(--muted);
  font:
    400 14.6px/1 Inter,
    system-ui,
    sans-serif;
  text-overflow: ellipsis;
}
.copy {
  position: relative;
  display: grid;
  place-items: center;
  flex: none;
  width: 38px;
  height: 38px;
  margin-right: 5px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  cursor: pointer;
}
.copy > * {
  grid-area: 1 / 1;
}
.squares {
  position: relative;
  width: 13.1px;
  height: 13.4px;
  transition:
    opacity 0.16s ease,
    transform 0.2s cubic-bezier(0.3, 0.6, 0.3, 1),
    filter 0.16s ease;
}
.squareFront,
.squareBack {
  position: absolute;
  width: 8.6px;
  height: 8.6px;
  border-radius: 2.2px;
}
.squareFront {
  top: 0;
  left: 0;
  background: var(--square-front);
}
.squareBack {
  right: 0;
  bottom: 0;
  background: var(--square-back);
}
.copy:hover .squareBack,
.copy[data-hover] .squareBack {
  translate: 1px 1px;
}
.squareBack {
  transition: translate 0.18s ease;
}
.copy:active .squares,
.copy[data-press] .squares {
  transform: scale(0.85);
}
.check {
  width: 16px;
  height: 16px;
  fill: none;
  stroke: var(--done);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  opacity: 0;
  transform: scale(0.6);
  filter: blur(2px);
  transition:
    opacity 0.16s ease,
    transform 0.24s cubic-bezier(0.3, 1.4, 0.5, 1),
    filter 0.16s ease;
}
.copy[data-copied='true'] .squares {
  opacity: 0;
  transform: scale(0.6);
  filter: blur(2px);
}
.copy[data-copied='true'] .check {
  opacity: 1;
  transform: scale(1);
  filter: blur(0);
}
.tip {
  position: absolute;
  bottom: calc(100% + 4px);
  left: 50%;
  padding: 5px 9px;
  border-radius: 8px;
  background: var(--tip);
  color: var(--tip-ink);
  font-size: 12px;
  font-weight: 500;
  line-height: 1;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  translate: -50% 4px;
  transition:
    opacity 0.15s ease,
    translate 0.2s cubic-bezier(0.3, 0.6, 0.3, 1);
}
.copy[data-copied='true'] .tip {
  opacity: 1;
  translate: -50% 0;
}

.label + .targets,
.targets {
  display: flex;
  justify-content: space-between;
  margin: 7px 0 0;
  padding: 0;
  list-style: none;
}
.field + .label {
  margin-top: 17.9px;
}
.targets > li {
  display: flex;
  justify-content: center;
  width: max(44px, var(--slot));
}
.targets > li:nth-child(1),
.targets > li:nth-child(4) {
  --slot: 12.5%;
}
.targets > li:nth-child(2) {
  --slot: 16.36%;
}
.targets > li:nth-child(3) {
  --slot: 14.2%;
}
.targets > li:nth-child(5) {
  --slot: 17.67%;
}
.target {
  display: flex;
  flex-direction: column;
  align-items: center;
  min-width: 44px;
  padding: 0;
  border: 0;
  border-radius: 12px;
  background: none;
  color: var(--muted);
  font:
    400 13px/16px Inter,
    system-ui,
    sans-serif;
  cursor: pointer;
}
.tile {
  position: relative;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  overflow: hidden;
  border-radius: 10.5px;
  transition:
    transform 0.2s cubic-bezier(0.3, 0.6, 0.3, 1),
    box-shadow 0.2s ease;
}
.tile::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: inset 0 0 0 0.5px rgb(255 255 255 / 0.25);
  background: linear-gradient(rgb(255 255 255 / 0.05), transparent 55%);
}
.glyph {
  position: relative;
  width: 44px;
  height: 44px;
}
.tile[data-channel='email'] {
  background: linear-gradient(#139cf9, #086bf8);
}
.tile[data-channel='message'] {
  background: linear-gradient(#26f45c, #12e01c);
}
.tile[data-channel='network'] {
  background: linear-gradient(#0c5ec0, #0857b6);
}
.tile[data-channel='post'] {
  background: linear-gradient(#08090b, #06080c);
}
.tile[data-channel='chat'] {
  background: linear-gradient(#1df558, #12e822);
}
.targetLabel {
  margin-top: 7.3px;
  white-space: nowrap;
  transition: color 0.15s ease;
}
.target:hover .tile,
.target[data-hover] .tile {
  transform: translateY(-2px) scale(1.04);
  box-shadow: 0 6px 14px -6px rgb(0 0 0 / 0.35);
}
.target:hover .targetLabel,
.target[data-hover] .targetLabel {
  color: var(--ink);
}
.target:active .tile,
.target[data-press] .tile {
  transform: scale(0.94);
  transition-duration: 0.08s;
}

/* A narrow dialog: even columns, a size smaller, wrapping to a second row when five don't fit. */
@container (max-width: 440px) {
  .label + .targets,
  .targets {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(46px, 1fr));
    gap: 12px 3px;
  }
  .targets > li {
    width: auto;
  }
  .target {
    width: 100%;
  }
  .tile {
    width: min(44px, 100% - 6px);
    height: auto;
    aspect-ratio: 1;
  }
  .glyph {
    width: 100%;
    height: 100%;
  }
  .targetLabel {
    font-size: 11px;
  }
}

/* The demo pointer, over everything; one of its glyphs shows at a time. */
.pointerLayer {
  position: absolute;
  inset: 0;
  z-index: 10;
  pointer-events: none;
}
.pointer {
  width: 22px;
  height: 22px;
}
.hand {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .arrow {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .hand {
  visibility: visible;
}

.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .scrim,
  .dialog,
  .root[data-open='true'] .scrim,
  .root[data-open='true'] .dialog,
  .close,
  .close svg,
  .squares,
  .squareBack,
  .check,
  .tip,
  .tile {
    transition: none;
  }
  .dialog {
    filter: none;
  }
}
```

### payment-link-share/avatars.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * The people on the orbit: small flat portraits drawn in SVG, never photos. Each is a coloured
 * backdrop, shoulders in a shirt, a neck, a head and one of a few hairstyles, inside a 32-unit
 * circle that the disc around it clips.
 */

export type Portrait = {
  /** The backdrop behind the person. */
  sky: string;
  shirt: string;
  skin: string;
  hair: string;
  style: 'long' | 'bob' | 'short' | 'curly' | 'bun' | 'buzz';
};

export const PORTRAITS: Portrait[] = [
  { sky: '#e9dccd', shirt: '#efe2d3', skin: '#d9a789', hair: '#4a3328', style: 'long' },
  { sky: '#9fc4dd', shirt: '#7e7a55', skin: '#e2b598', hair: '#6b4630', style: 'bob' },
  { sky: '#d4d4d4', shirt: '#121212', skin: '#e8c0a6', hair: '#8a6a4c', style: 'short' },
  { sky: '#e6d2bf', shirt: '#4c8a8c', skin: '#b98262', hair: '#2a1d17', style: 'curly' },
  { sky: '#c9d6c3', shirt: '#d7d0c4', skin: '#8d5a3f', hair: '#16100d', style: 'bun' },
  { sky: '#dcd3e6', shirt: '#2f3d5c', skin: '#f0c9ae', hair: '#c79a62', style: 'buzz' },
  { sky: '#f0d9c7', shirt: '#b25b45', skin: '#c99273', hair: '#3a2a22', style: 'short' },
];

/** One portrait, filling its box (the stylesheet sizes it). */
export function PortraitArt({ portrait, className }: { portrait: Portrait; className?: string }) {
  const { sky, shirt, skin, hair, style } = portrait;
  return (
    <svg className={className} viewBox="0 0 32 32" aria-hidden="true" focusable="false">
      <rect width="32" height="32" fill={sky} />
      <g transform="translate(16 20) scale(1.2) translate(-16 -18.6)">
        {style === 'long' && <path d="M9 13c0-6 3.2-8.6 7-8.6s7 2.6 7 8.6v10H9z" fill={hair} />}
        {style === 'curly' && (
          <path
            d="M9.4 15.6c-1.6-1.2-1.3-3.9.3-4.6-.5-2.5 1.4-4.6 3.6-4.2.9-1.9 4.5-1.9 5.4 0 2.3-.4 4.1 1.7 3.6 4.2 1.7.7 1.9 3.4.3 4.6z"
            fill={hair}
          />
        )}
        {style === 'bun' && <circle cx="16" cy="5.2" r="2.9" fill={hair} />}
        <path d="M3.5 32c0-6.4 5.4-9.8 12.5-9.8s12.5 3.4 12.5 9.8z" fill={shirt} />
        <path d="M14 18.4h4v4.3c0 1.1-.9 1.9-2 1.9s-2-.8-2-1.9z" fill={skin} />
        <ellipse cx="16" cy="13.6" rx="4.9" ry="5.9" fill={skin} />
        {style === 'long' && (
          <path
            d="M10.6 13.4c.3-4.6 2.6-6.4 5.4-6.4 2.4 0 4.6 1.6 5.3 4.6-2.8-.2-5.4-1.4-6.6-3-1 1.9-2.4 3.6-4.1 4.8z"
            fill={hair}
          />
        )}
        {style === 'bob' && (
          <path
            d="M10.2 17.6c-.9-6.6 1.5-10.6 5.8-10.6s6.7 4 5.8 10.6c-.6-1.1-.9-3.2-1.1-5-1.6-.4-3.9-1.8-4.9-3.4-.8 1.8-2.6 3-4.5 3.4-.2 1.8-.5 3.9-1.1 5z"
            fill={hair}
          />
        )}
        {style === 'short' && (
          <path
            d="M11 12.6c-.4-4 1.8-6 5-6 3.3 0 5.4 1.9 5 6-.5-1.6-1.4-2.5-2.6-2.9-1.9.8-4.4.8-6.2 0-.7.5-1 1.5-1.2 2.9z"
            fill={hair}
          />
        )}
        {style === 'curly' && (
          <path
            d="M11 12.4c0-3.4 2.2-5.4 5-5.4s5 2 5 5.4c-1.2-1.4-2.4-2-3.4-2.2-.6.7-2.6.7-3.2 0-1 .2-2.2.8-3.4 2.2z"
            fill={hair}
          />
        )}
        {style === 'bun' && (
          <path
            d="M11.1 12.6c-.2-3.7 2-5.8 4.9-5.8s5.1 2.1 4.9 5.8c-.9-1.7-2.6-2.8-4.9-2.8s-4 1.1-4.9 2.8z"
            fill={hair}
          />
        )}
        {style === 'buzz' && (
          <path
            d="M11.2 12c.1-3.2 2.1-5 4.8-5s4.7 1.8 4.8 5c-1.4-.9-3-1.3-4.8-1.3s-3.4.4-4.8 1.3z"
            fill={hair}
          />
        )}
      </g>
    </svg>
  );
}
```

### payment-link-share/share-icons.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * The share targets' glyphs, drawn on a 44-unit square: neutral pictures of what each channel
 * does (a letter, a speech bubble, two people, a spark, a chat ring), never anyone's logo. The
 * tile under each glyph (its colour, its corners) comes from the stylesheet.
 */

import { useId } from 'react';

export type ShareChannel = 'email' | 'message' | 'network' | 'post' | 'chat';

export const CHANNELS: { id: ShareChannel; label: string }[] = [
  { id: 'email', label: 'Email' },
  { id: 'message', label: 'Message' },
  { id: 'network', label: 'Network' },
  { id: 'post', label: 'Post' },
  { id: 'chat', label: 'Chat' },
];

export function ShareGlyph({ channel, className }: { channel: ShareChannel; className?: string }) {
  const letter = `${useId()}letter`;
  return (
    <svg className={className} viewBox="0 0 44 44" aria-hidden="true" focusable="false">
      {channel === 'email' && (
        <>
          <defs>
            <linearGradient id={letter} x1="0" y1="0" x2="0" y2="1">
              <stop offset="0" stopColor="#fff" />
              <stop offset="1" stopColor="#d9ecff" />
            </linearGradient>
          </defs>
          <rect x="7.5" y="12" width="29" height="20.5" rx="3.6" fill={`url(#${letter})`} />
          <path
            d="M8.6 13.6 20 23.1c1.2 1 2.8 1 4 0l11.4-9.5"
            fill="none"
            stroke="#8cc4fb"
            strokeWidth="1.7"
            strokeLinejoin="round"
          />
          <path d="m8.8 31.2 9.6-8.4m16.8 8.4-9.6-8.4" stroke="#b9dcff" strokeWidth="1.3" />
        </>
      )}
      {channel === 'message' && (
        <path
          d="M22 9.6c-7.8 0-14 4.9-14 11 0 3.5 2.1 6.6 5.3 8.6-.3 1.9-1.3 3.6-2.8 4.8 2.9.1 5.4-.9 7.1-2.6 1.4.3 2.9.4 4.4.4 7.8 0 14-4.9 14-11.1S29.8 9.6 22 9.6z"
          fill="#fff"
        />
      )}
      {channel === 'network' && (
        <g fill="#fff">
          <circle cx="27.6" cy="15.4" r="3.9" opacity="0.62" />
          <path
            d="M21.4 29.5c.4-4.6 3.1-7.3 6.2-7.3 3.4 0 6.2 3 6.2 7.3 0 .8-.5 1.3-1.3 1.3H21.6z"
            opacity="0.62"
          />
          <circle cx="18.4" cy="16.2" r="4.9" />
          <path d="M9.8 32.2c0-5.7 3.8-9.2 8.6-9.2s8.6 3.5 8.6 9.2c0 .9-.6 1.5-1.5 1.5H11.3c-.9 0-1.5-.6-1.5-1.5z" />
        </g>
      )}
      {channel === 'post' && (
        <path
          d="M22 9.5c.7 6.3 3.9 9.5 10.5 10.3v1.4C25.9 22 22.7 25.2 22 31.5h-1.4c-.7-6.3-3.9-9.5-10.5-10.3v-1.4c6.6-.8 9.8-4 10.5-10.3z"
          fill="#fff"
          transform="translate(.7 1.5)"
        />
      )}
      {channel === 'chat' && (
        <>
          <path
            d="M22 9.4a12.6 12.6 0 0 0-10.9 18.9L9.4 34.6l6.5-1.7A12.6 12.6 0 1 0 22 9.4z"
            fill="none"
            stroke="#fff"
            strokeWidth="2.5"
            strokeLinejoin="round"
          />
          <g fill="#fff">
            <circle cx="16.8" cy="22" r="1.8" />
            <circle cx="22" cy="22" r="1.8" />
            <circle cx="27.2" cy="22" r="1.8" />
          </g>
        </>
      )}
    </svg>
  );
}
```

### payment-link-share/demo-pointer.tsx

```tsx
'use client';

import type { CSSProperties, Ref } from 'react';

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or
 * glide the pointer somewhere. 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.
 *
 * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame.
 * The points of the flight are handed to whoever wants to move along with it (the eye of the
 * badge, which plays its own animation on the same clock). The arrow and the hand are HTML boxes
 * that swap by their visibility: a change of style inside an SVG would lay the SVG out again.
 */

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

/** A glide's path and timing, for an animation that plays along with it. */
export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer to `to`, in the stage's own px, in `ms`; `along` gets the flight. */
  glide: (to: Point, ms: number, along?: (flight: Flight) => void) => Promise<void>;
  /** Shows the arrow, or the hand a link shows. */
  glyph: (name: 'arrow' | 'hand') => void;
};

/** What a stopped demo's pending step rejects with. */
class Stopped extends Error {}

/** Where the arrow's tip is, as shares of its box: the point the pointer stands on. */
const TIP: Point = { x: 0.15, y: 0.1 };

/** How many points a flight is drawn through. */
const SAMPLES = 24;

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  // The stylesheet sizes the pointer from the stage's width; its tip is where it stands.
  const tip = { x: pointer.offsetWidth * TIP.x, y: pointer.offsetHeight * TIP.y };
  const place = ({ x, y }: Point) => `translate(${x - tip.x}px, ${y - tip.y}px)`;
  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 glide: Demo['glide'] = (to, ms, along) => {
    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.18, y: (from.y + to.y) / 2 + dx * 0.18 };
    const points: Point[] = [];
    for (let i = 0; i <= SAMPLES; i += 1) {
      const t = i / SAMPLES;
      const u = 1 - t;
      points.push({
        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,
      });
    }
    const timing: KeyframeAnimationOptions = {
      duration: ms,
      easing: 'cubic-bezier(0.45, 0, 0.25, 1)',
    };
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(
      points.map((point) => ({ transform: place(point) })),
      timing,
    );
    along?.({ points, timing });
    return step(flight.finished);
  };

  const glyph: Demo['glyph'] = (name) => {
    pointer.dataset.glyph = name;
  };

  pointer.dataset.glyph = 'arrow';
  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, glide, glyph }).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';
  };
}

/**
 * The pointer's box: it moves, on a layer of its own. Its size comes from its class, its colours
 * from the stage's tokens (nda-badge.module.css).
 */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 2,
  display: 'block',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

/** One glyph over the other, each filling the box. */
const GLYPH: CSSProperties = { position: 'absolute', inset: 0, display: 'block' };
const DRAWING: CSSProperties = {
  display: 'block',
  width: '100%',
  height: '100%',
  overflow: 'visible',
};
/** The arrow is dark on a white edge, the hand white on a dark one, as the system draws them. */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };
const HAND: CSSProperties = { fill: 'var(--pointer-edge)', stroke: 'var(--pointer)' };

/**
 * The pointer the demo moves around: an arrow, or a pointing hand over the badge. It is
 * decorative: what it does shows on the badge itself. The stylesheet shows one glyph at a time,
 * from the box's data-glyph.
 */
export function DemoPointer({
  ref,
  className,
  arrowClass,
  handClass,
}: {
  ref: Ref<HTMLSpanElement>;
  className?: string;
  arrowClass?: string;
  handClass?: string;
}) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <span className={arrowClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          <path
            d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
            style={ARROW}
            strokeWidth="1.3"
            strokeLinejoin="round"
          />
        </svg>
      </span>
      <span className={handClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          {/* The tip of the finger sits where the arrow's tip is. */}
          <path
            d="M6.4 3.3c0-.8.6-1.4 1.3-1.4S9 2.5 9 3.3v5.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.7c.2-.4.6-.7 1.1-.7.7 0 1.2.5 1.2 1.2v3.5c0 2.8-1.9 4.9-4.6 4.9h-1.2c-1.8 0-3.1-.8-4.1-2.3l-2.6-3.9c-.4-.6-.2-1.3.4-1.7.6-.3 1.3-.2 1.7.4l.6.9z"
            style={HAND}
            strokeWidth="1.1"
            strokeLinejoin="round"
            transform="translate(-4.7 0.1)"
          />
        </svg>
      </span>
    </span>
  );
}
```

### payment-link-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 };
}
```