Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Payment link share

New

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

payment-link-share/payment-link-share.tsx
Live · interactive

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

When to use

  • Handing a 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.

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

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

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

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

Usage

invoice-share.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)}    />  );}

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

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

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.

  • 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.
PropTypeDefaultDescription
linkstring'https://pay.example/6329103'The link to share, shown in the field and copied by its button.
titlestring'Share payment link'The dialog’s title.
descriptionstring'Send this link to your customer so they can complete their payment.'The line under the title.
defaultOpenbooleantrueOpens 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.
defaultCopiedbooleanfalseOpens 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.
pausedbooleanfalseHolds 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.
autoplaybooleantrueWhile 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.
classNamestring—Extra class for the root.

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

payment-link-share.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
payment-link-share.module.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);
avatars.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' },
share-icons.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">
demo-pointer.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>;
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • 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.