# Prospect cards

> Four moments of a prospecting tool, live: an email found, a job alert, contacts coming in, a prospect delivered.

Four visuals of a prospecting tool, drawn in markup and played one after another on a stage of their own. An email is found while the search goes on, a job alert pops over a profile, contacts slide into a stack, and an envelope opens on a prospect while its company’s details fan out.

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

Category: Motion.

## Anatomy

1. **Stage**: A rounded panel holding one visual at a time. Every size inside is in em of a unit worked out from its width, up to 16 px.
2. **Person**: A round portrait beside a name over a role, the company in blue.
3. **Fields card**: The lookups, one row each, on a raised card with a hairline between rows.
4. **Found email**: Opens in its row when the email is found, 1.5 s into the moment, the address rising in first.
5. **Confidence score**: How sure the tool is, on a green tint. It pops in last.
6. **Search bar**: A fading bar and a spinner, still looking up the phone number. They sweep a few times while the moment plays, then rest.
7. **Dots**: Tabs that pick a visual; arrows, Home and End move between them. The current one is a wider pill that fills while the cycle waits on it.

## When to use

- A landing page or a feature tour that shows what a data product does, one moment at a time.
- Three to five small product moments that read without words: a value found, an alert, an import, a delivery.
- A hero or a section beside a short headline, where the visual explains and the copy sells.
- Pictures of the product that must keep their proportions from a phone to a wide screen, and follow light and dark.

## When not to use

- The real product UI. These are drawings of moments: nothing in them can be edited or clicked through.
- Content the reader must not miss: the cycle moves on by itself. Put it in the copy instead.
- More than five moments, or moments that need a caption to make sense. Use a feature list with screenshots.
- A page that already moves a lot: one cycling demo per screen is plenty.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/prospect-cards.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 { ProspectCards } from './prospect-cards';

export function ProductTour() {
  // Opens on the envelope; the dots bring in the others, each with its moment.
  return <ProspectCards defaultVisual="deliver" autoplay={false} />;
}
```

## States

- **Contact enrichment**: The email found at 98%, the phone number still being looked up. Shown at rest.
- **Job alert**: A profile with a “Promoted last month” tag, the alert bubble popped over it.
- **Import**: Two contacts slid onto a stack over a blank card, the address book’s pill on top.
- **Prospect delivery**: The prospect out of its envelope, the company’s details and three small cards fanned out around it.
- **Dark**: `colorScheme="dark"`: an ink-blue stage with dark cards. Faces and company marks keep their colours.

## How it works

- Each visual is plain HTML and SVG sized in em, from a unit the scene works out of its own width with a container query. The compositions keep their proportions from a phone to a wide screen.
- A visual plays its moment as it comes in: CSS animations on transforms and opacity, each with its own delay and a backwards fill, from the moment’s first state to the visual at rest. Shown without playing (the server’s frame, reduced motion), it is simply at rest.
- The prospect rises out of a frame that hides whatever is below the envelope’s opening, behind a pocket drawn in SVG. The glow is two radial gradients, never a blur, and the flap is the only piece turned in 3D, while it opens.
- Turns are flat at rest and no visual fades as a whole, so a visual at rest holds no layer of its own on a phone. The spinner and the shimmer run a few times and stop.
- The dots are tabs. The current one fills while the cycle waits on it; the cycle runs only on screen and stops at the reader’s first touch.
- Cards, text and the envelope are light-dark() tokens on the root, the envelope’s SVG pocket and flap included, so they follow the page’s color-scheme, or the colorScheme prop; the faces, the companies’ marks on their plates, the envelope’s glow and the hand keep their colours.

## API reference

- **defaultVisual** (`'enrich' | 'alert' | 'import' | 'deliver'`, default `'enrich'`): The visual shown first, at rest.
- **onVisualChange** (`(visual: 'enrich' | 'alert' | 'import' | 'deliver') => void`): Called when a new visual comes on stage, picked by the reader or by the cycle.
- **autoplay** (`boolean`, default `true`): While on screen, the visuals take turns every 4.6 s, each playing its moment. The cycle stops for good at the reader’s first touch.
- **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, the stage.

## Accessibility

- Each visual is a figure whose caption, for screen readers, tells who is shown, at which company, and what was found.
- The dots are a tablist of four named tabs controlling the panel. Left and Right move and wrap, Home and End jump, only the current tab is in the tab order, and each dot has a finger-sized hit area.
- The cycle stops for good as soon as the reader presses, types or focuses anything on the stage, so nothing changes under a keyboard or screen-reader user.
- Under reduced motion there is no cycle, and a chosen visual appears at rest.

## Source

### prospect-cards/prospect-cards.tsx

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

'use client';

import {
  useEffect,
  useId,
  useRef,
  useState,
  type ComponentType,
  type CSSProperties,
  type KeyboardEvent,
} from 'react';
import { Avatar } from './avatars';
import {
  AtIcon,
  AwardIcon,
  ChevronIcon,
  CopyIcon,
  HandIcon,
  KiteMark,
  LoaflyMark,
  MossbyteMark,
  PhoneIcon,
  SourceMark,
  SparkleIcon,
  StarIcon,
  VeltoMark,
} from './marks';
import { COMPANY, FOUND_EMAIL, HELEN, NORA, PROSPECT_ID, TESSA, type Contact } from './prospects';
import { useAutoplay } from './use-autoplay';
import styles from './prospect-cards.module.css';

/*
 * Prospect cards: four moments of a prospecting tool, drawn in HTML and played one after another.
 * A contact's email is found while a bar searches on; a job alert pops over a profile; contacts
 * slide into a stack on their way in from an address book; a prospect comes out of an envelope
 * with its company's details fanning out around it.
 *
 * - Each visual is plain markup, sized in em from one unit that follows the stage's width (a
 *   container query), so it keeps its proportions from a phone to a wide screen.
 * - A visual plays its moment when it comes in: CSS animations on transforms and opacity, each
 *   with its own delay, from the first state of the moment to the visual at rest. Shown without
 *   playing (on the server's frame, under reduced motion), it is simply at rest.
 * - Turns are flat (2D) and glows are gradients, never blurs: at rest, nothing needs a layer of
 *   its own. The envelope's flap is the one piece that turns in 3D, while it opens.
 * - The visuals take turns, each building itself in with its moment; nothing fades as a whole, as
 *   layers that size, made at every turn, would weigh on a phone. The dots under the stage are
 *   tabs; the current one fills while the cycle waits on it.
 * - The cycle is the demo: it runs while the stage is on screen and stops for good at the reader's
 *   first touch. Each moment plays once, so nothing moves at rest.
 * - Light or dark: every colour of the cards and the stage is a light-dark() token in the CSS, the
 *   envelope's included, so the scheme is the page's color-scheme, or the one the colorScheme prop
 *   sets. The faces and the companies' marks keep their colours.
 */

export type VisualId = 'enrich' | 'alert' | 'import' | 'deliver';

const VISUALS: { id: VisualId; label: string; alt: string }[] = [
  {
    id: 'enrich',
    label: 'Contact enrichment',
    alt: `A contact card for ${NORA.name}, ${NORA.role} at ${NORA.company}. Her email, ${FOUND_EMAIL.address}, has been found with ${FOUND_EMAIL.score}% confidence while her phone number is still being looked up.`,
  },
  {
    id: 'alert',
    label: 'Job alert',
    alt: `${NORA.name}’s profile, ${NORA.role} at ${NORA.company}, with a new job alert: promoted last month.`,
  },
  {
    id: 'import',
    label: 'Import',
    alt: `Two contacts coming in from an address book: ${TESSA.name}, ${TESSA.role} at ${TESSA.company}, and ${HELEN.name}, ${HELEN.role} at ${HELEN.company}.`,
  },
  {
    id: 'deliver',
    label: 'Prospect delivery',
    alt: `Prospect ${PROSPECT_ID}, ${NORA.name} of ${NORA.company}, out of an envelope with the company’s details: ${COMPANY.website}, ${COMPANY.industry}, ${COMPANY.email} at ${COMPANY.emailScore}% confidence and ${COMPANY.phone}, with cards for awards, phone and email behind.`,
  },
];

/** How long the cycle stays on a visual, in ms. */
const SLOT = 4600;

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

/** A name, and a role at a company, the company in colour. */
function Who({ contact, className }: { contact: Contact; className?: string }) {
  return (
    <p className={`${styles.who} ${className ?? ''}`} data-part="who">
      <strong>{contact.name}</strong>
      <span>
        {contact.role} at <b>{contact.company}</b>
      </span>
    </p>
  );
}

/** 1. A contact's email is found; the phone number is still on its way. */
function Enrich() {
  return (
    <div className={styles.enrich} data-part="enrich">
      <div className={styles.person} data-part="person">
        <Avatar person={NORA.person} className={styles.avatar} />
        <Who contact={NORA} />
      </div>
      <div className={styles.fields} data-part="fields">
        <div className={styles.field}>
          <span className={styles.fieldLabel}>Phone number</span>
          <ChevronIcon className={styles.chevron} />
        </div>
        <div className={styles.field}>
          <span className={styles.fieldLabel}>Email</span>
          <ChevronIcon className={styles.chevron} />
        </div>
        <div className={styles.found} data-part="found">
          <div className={styles.foundRow}>
            <span className={styles.email}>{FOUND_EMAIL.address}</span>
            <span className={styles.score} data-part="score">
              {FOUND_EMAIL.score}%
            </span>
            <CopyIcon className={styles.copy} />
          </div>
        </div>
        <div className={styles.searching} data-part="searching">
          <span className={styles.track}>
            <span className={styles.sweep} />
          </span>
          <svg className={styles.spinner} viewBox="0 0 24 24" aria-hidden="true">
            <circle cx="12" cy="12" r="9.5" />
          </svg>
        </div>
      </div>
    </div>
  );
}

/** 2. A job alert pops over a profile. */
function Alert() {
  return (
    <div className={styles.alert}>
      <div className={styles.profile} data-part="profile">
        <Avatar person={NORA.person} className={styles.avatarSmall} />
        <Who contact={NORA} className={styles.whoStacked} />
        <span className={styles.tag} data-part="tag">
          Promoted last month
        </span>
      </div>
      <p className={styles.bubble} data-part="bubble">
        <SparkleIcon className={styles.sparkle} />
        New job alert
      </p>
    </div>
  );
}

/** One contact in the stack: a star, the face with the company's mark, the name and role. */
function ContactCard({ contact, mark }: { contact: Contact; mark: 'mossbyte' | 'loafly' }) {
  const Mark = mark === 'mossbyte' ? MossbyteMark : LoaflyMark;
  return (
    <>
      <StarIcon className={styles.star} />
      <span className={styles.face}>
        <Avatar person={contact.person} className={styles.avatar} />
        <span className={styles.badge}>
          <Mark />
        </span>
      </span>
      <Who contact={contact} />
    </>
  );
}

/** 3. Contacts slide into a stack, on their way in from an address book. */
function Import() {
  return (
    <div className={styles.stack}>
      <div className={styles.contact} data-card="under" />
      <div className={styles.contact} data-card="back">
        <ContactCard contact={HELEN} mark="loafly" />
      </div>
      <div className={styles.contact} data-card="front" data-part="contact">
        <ContactCard contact={TESSA} mark="mossbyte" />
      </div>
      <p className={styles.importPill} data-part="import-pill">
        <SourceMark className={styles.sourceMark} />
        Import data
      </p>
    </div>
  );
}

/** 4. A prospect comes out of an envelope, its company's details fanning out around it. */
function Deliver() {
  const flapId = useId();
  return (
    <div className={styles.deliver}>
      <div className={styles.mini} data-card="email">
        <Avatar person={NORA.person} className={styles.miniAvatar} />
        <span className={styles.label}>Email</span>
      </div>
      <div className={styles.mini} data-card="awards" data-part="mini">
        <span className={styles.label}>
          Awards <AwardIcon className={styles.labelIcon} />
        </span>
        <span className={styles.miniValue}>{COMPANY.award}</span>
      </div>
      <div className={styles.mini} data-card="phone">
        <span className={styles.label}>Phone</span>
        <span className={styles.miniValue}>{COMPANY.phone}</span>
      </div>

      <div className={styles.envelope} data-part="envelope">
        <div className={styles.back} />
        <div className={styles.slot}>
          <div className={styles.prospect} data-part="prospect">
            <span className={styles.label}>Prospect {PROSPECT_ID}</span>
            <Avatar person={NORA.person} className={styles.prospectAvatar} />
            <Who contact={NORA} className={styles.whoSmall} />
          </div>
        </div>
        <span className={styles.glow} data-side="left" />
        <span className={styles.glow} data-side="right" />
        <svg className={styles.pocket} viewBox="0 0 342 154" aria-hidden="true">
          <path
            d="M0 8 83 54 167 14 258 52 342 0V136a18 18 0 0 1-18 18H18A18 18 0 0 1 0 136z"
            fill="var(--surface-2)"
          />
          <g fill="none" stroke="var(--pocket-line)" strokeWidth="10" strokeLinejoin="round">
            <path d="M0 46 83 92 167 52 258 90 342 38" />
            <path d="M0 84 83 130 167 90 258 128 342 76" />
          </g>
        </svg>
        <p className={styles.watermark}>
          <KiteMark className={styles.kite} />
          Leadkite
        </p>
        <svg className={styles.flap} viewBox="0 0 342 92" aria-hidden="true">
          <defs>
            <linearGradient id={`${flapId}-fill`} x1="0" y1="0" x2="0" y2="1">
              <stop offset="0" stopColor="var(--flap)" />
              <stop offset="1" stopColor="var(--flap-low)" />
            </linearGradient>
          </defs>
          <path
            d="M1 1h340L178 86a12 12 0 0 1-14 0z"
            fill={`url(#${flapId}-fill)`}
            stroke="var(--flap-line)"
            strokeWidth="1.5"
            strokeLinejoin="round"
          />
        </svg>
      </div>

      <div className={styles.details} data-part="details">
        <span className={styles.label}>Company details</span>
        <div className={styles.company}>
          <span className={styles.logo}>
            <VeltoMark />
          </span>
          <span className={styles.companyText}>
            <strong>{COMPANY.name}</strong>
            <span>{COMPANY.website}</span>
          </span>
          <span className={styles.industry}>{COMPANY.industry}</span>
        </div>
        <p className={styles.line}>
          <AtIcon className={styles.lineIcon} />
          {COMPANY.email}
          <span className={styles.score}>{COMPANY.emailScore}%</span>
        </p>
        <p className={styles.line}>
          <PhoneIcon className={styles.lineIcon} />
          <span className={styles.phone}>{COMPANY.phone}</span>
          <HandIcon className={styles.hand} />
        </p>
      </div>
    </div>
  );
}

const ART: Record<VisualId, ComponentType> = {
  enrich: Enrich,
  alert: Alert,
  import: Import,
  deliver: Deliver,
};

export function ProspectCards({
  defaultVisual = 'enrich',
  autoplay = true,
  colorScheme,
  className = '',
  onVisualChange,
}: {
  /** The visual shown first. */
  defaultVisual?: VisualId;
  /** While on screen and untouched, the visuals take turns, each playing its moment. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
  /** A new visual came on stage: picked by the reader, or by the cycle. */
  onVisualChange?: (visual: VisualId) => void;
}) {
  const { ref: root, playing } = useAutoplay<HTMLDivElement>();
  const first = Math.max(
    0,
    VISUALS.findIndex((visual) => visual.id === defaultVisual),
  );
  /** The visual on stage; `play` when it came in with its moment; `round` counts the changes. */
  const [shown, setShown] = useState({ index: first, play: false, round: 0 });
  /** The reader has touched the stage: the cycle stops for good. */
  const [held, setHeld] = useState(false);
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const baseId = useId();

  /** Brings in visual `index`, which plays its moment (again, if it is on stage already). */
  const show = (index: number) => {
    setShown({ index, play: !reducedMotion(), round: shown.round + 1 });
    onVisualChange?.(VISUALS[index].id);
  };

  // The cycle: on to the next visual every few seconds, while on screen and untouched. The timer
  // is set again after each change, so the `show` it calls is always the latest.
  const cycling = autoplay && playing && !held;
  useEffect(() => {
    if (!cycling) return;
    const timer = window.setTimeout(() => show((shown.index + 1) % VISUALS.length), SLOT);
    return () => window.clearTimeout(timer);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [cycling, shown.index, shown.round]);

  const onTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
    const last = VISUALS.length - 1;
    const next = {
      ArrowRight: index === last ? 0 : index + 1,
      ArrowLeft: index === 0 ? last : index - 1,
      Home: 0,
      End: last,
    }[event.key];
    if (next === undefined) return;
    event.preventDefault();
    tabs.current[next]?.focus();
    show(next);
  };

  const Current = ART[VISUALS[shown.index].id];

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      data-cycling={cycling || undefined}
      onPointerDownCapture={() => setHeld(true)}
      onKeyDownCapture={() => setHeld(true)}
      onFocusCapture={() => setHeld(true)}
    >
      <div className={styles.scene} data-part="scene">
        <div
          key={shown.round}
          id={`${baseId}-panel`}
          className={styles.visual}
          role="tabpanel"
          aria-labelledby={`${baseId}-tab-${shown.index}`}
          data-play={shown.play || undefined}
        >
          <figure className={styles.figure}>
            <div aria-hidden="true">
              <Current />
            </div>
            <figcaption className={styles.hidden}>{VISUALS[shown.index].alt}</figcaption>
          </figure>
        </div>
      </div>

      <div className={styles.dots} data-part="dots" role="tablist" aria-label="Prospecting visuals">
        {VISUALS.map((visual, i) => {
          const selected = i === shown.index;
          return (
            <button
              key={visual.id}
              ref={(element) => {
                tabs.current[i] = element;
              }}
              id={`${baseId}-tab-${i}`}
              type="button"
              role="tab"
              className={styles.dot}
              data-part="dot"
              aria-label={visual.label}
              aria-selected={selected}
              aria-controls={selected ? `${baseId}-panel` : undefined}
              tabIndex={selected ? 0 : -1}
              onClick={() => show(i)}
              onKeyDown={(event) => onTabKeyDown(event, i)}
            >
              <span className={styles.pip} style={{ '--slot': `${SLOT}ms` } as CSSProperties}>
                <span key={selected ? shown.round : 'rest'} className={styles.dotFill} />
              </span>
            </button>
          );
        })}
      </div>
    </div>
  );
}
```

### prospect-cards/prospect-cards.module.css

```css
/* Prospect cards (prospect-cards.tsx): a pale lavender stage with one visual at a time and the
   dots that pick it, or at night an ink-blue one with dark cards. Every size in the visuals is in
   em, of a unit the scene works out from the stage's width; a visual with data-play runs its
   moment, one without it shows it at rest. */

.root {
  /* Its colours, light and dark: each light-dark() pair resolves by the color-scheme the root
     inherits from the page, or the one the colorScheme prop sets (below). The faces, the
     companies' marks on their white plates, the envelope's glow and the pointing hand keep their
     colours. */
  /* The stage, then the cards (and the envelope) a step up. */
  --surface: #f3f3f8;
  --surface-2: #fff;
  /* Names; roles; labels; a company's name in a role. */
  --ink: #111322;
  --ink-muted: #6a6a74;
  --ink-faint: #8d8e98;
  --blue: #4d5ad6;
  /* The rules between fields, the stage's edge. */
  --line: #f0f0f4;
  --edge: rgb(0 0 0 / 0.06);
  /* The job alert, and the white on it; the dots' focus ring. */
  --accent: #5109f2;
  --accent-ink: #fff;
  --focus: #5109f2;
  /* The cards' edge, the shade right under them and their soft drop. */
  --card-line: rgb(30 30 80 / 0.025);
  --shadow-near: rgb(26 26 66 / 0.04);
  --shadow: rgb(36 36 92 / 0.14);
  /* The shades under a portrait, a mark's plate, the envelope and the prospect's card. */
  --avatar-shadow: rgb(60 40 120 / 0.14);
  --plate-shadow: rgb(20 20 50 / 0.14);
  --logo-shadow: rgb(20 20 50 / 0.16);
  --envelope-shadow: rgb(36 36 92 / 0.18);
  --prospect-shadow: rgb(26 26 66 / 0.08);
  /* A confidence score, on its tint. */
  --score: #e3f5e1;
  --score-ink: #3e9a4c;
  /* 1. A field's label, its chevron, the email found, its copy icon; the search bar, light to
     faint, the sweep over it, and the spinner. */
  --field-label: #84858e;
  --chevron: #a2a3ad;
  --email-ink: #1d1e2c;
  --copy: #a0a1ab;
  --track: #8e92ee;
  --track-mid: #b4b8f2;
  --track-low: #dcdeef;
  --track-end: rgb(232 233 242 / 0);
  --sweep: rgb(255 255 255 / 0.75);
  --spinner: #6d71ea;
  /* 2. The alert's tag, on its tint. */
  --tag: #efeafd;
  --tag-ink: #6a4ddb;
  /* 3. The star, the address book's mark. */
  --star: #cbc9d9;
  --source: #111;
  /* 4. The envelope's lines, its flap (top to bottom, and edge), the tool's name on it; the
     industry's chip and the details' icons. */
  --pocket-line: #f6f6f9;
  --flap: #ffffff;
  --flap-low: #f1f1f7;
  --flap-line: #e4e4ee;
  --watermark: #dcdde5;
  --chip: #ececf1;
  --chip-ink: #575864;
  --line-icon: #4d6ad8;
  /* The dots: at rest, under the pointer, and the current one's fill. */
  --pip: #d2d2df;
  --pip-hover: #b9b9cc;
  --pip-fill: #2a2840;
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
  --pop: cubic-bezier(0.34, 1.5, 0.64, 1);
  --card-shadow:
    0 0 0 1px var(--card-line), 0 0.1em 0.25em var(--shadow-near),
    0 0.8em 2.2em -0.4em var(--shadow);
  display: grid;
  container-type: inline-size;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--edge);
  color: var(--ink);
  font:
    400 16px/1.25 -apple-system,
    BlinkMacSystemFont,
    'SF Pro Text',
    Inter,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#f3f3f8, #111117);
    --surface-2: light-dark(#fff, #1c1c25);
    --ink: light-dark(#111322, #f2f2f8);
    --ink-muted: light-dark(#6a6a74, #a3a3ae);
    --ink-faint: light-dark(#8d8e98, #8a8b97);
    --blue: light-dark(#4d5ad6, #8b95ff);
    --line: light-dark(#f0f0f4, #2a2a36);
    --edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
    --focus: light-dark(#5109f2, #9a6bff);
    --card-line: light-dark(rgb(30 30 80 / 0.025), rgb(255 255 255 / 0.07));
    --shadow-near: light-dark(rgb(26 26 66 / 0.04), rgb(0 0 0 / 0.3));
    --shadow: light-dark(rgb(36 36 92 / 0.14), rgb(0 0 0 / 0.55));
    --avatar-shadow: light-dark(rgb(60 40 120 / 0.14), rgb(0 0 0 / 0.4));
    --plate-shadow: light-dark(rgb(20 20 50 / 0.14), rgb(0 0 0 / 0.4));
    --logo-shadow: light-dark(rgb(20 20 50 / 0.16), rgb(0 0 0 / 0.4));
    --envelope-shadow: light-dark(rgb(36 36 92 / 0.18), rgb(0 0 0 / 0.5));
    --prospect-shadow: light-dark(rgb(26 26 66 / 0.08), rgb(0 0 0 / 0.4));
    --score: light-dark(#e3f5e1, #1d3322);
    --score-ink: light-dark(#3e9a4c, #5fd47a);
    --field-label: light-dark(#84858e, #8f909c);
    --chevron: light-dark(#a2a3ad, #70717d);
    --email-ink: light-dark(#1d1e2c, #e8e8f0);
    --copy: light-dark(#a0a1ab, #72737f);
    --track: light-dark(#8e92ee, #7d82f0);
    --track-mid: light-dark(#b4b8f2, #5559b0);
    --track-low: light-dark(#dcdeef, #2f3157);
    --track-end: light-dark(rgb(232 233 242 / 0), rgb(47 49 87 / 0));
    --sweep: light-dark(rgb(255 255 255 / 0.75), rgb(255 255 255 / 0.3));
    --spinner: light-dark(#6d71ea, #8d91f5);
    --tag: light-dark(#efeafd, #2a2240);
    --tag-ink: light-dark(#6a4ddb, #b9a6ff);
    --star: light-dark(#cbc9d9, #4a4958);
    --source: light-dark(#111, #f2f2f8);
    --pocket-line: light-dark(#f6f6f9, #23232e);
    --flap: light-dark(#ffffff, #282833);
    --flap-low: light-dark(#f1f1f7, #1f1f29);
    --flap-line: light-dark(#e4e4ee, #363642);
    --watermark: light-dark(#dcdde5, #34343f);
    --chip: light-dark(#ececf1, #2a2a36);
    --chip-ink: light-dark(#575864, #b0b1bd);
    --line-icon: light-dark(#4d6ad8, #8b9cff);
    --pip: light-dark(#d2d2df, #40404d);
    --pip-hover: light-dark(#b9b9cc, #55556a);
    --pip-fill: light-dark(#2a2840, #e6e5f2);
  }
}

/* The colorScheme prop. Without it the root sets nothing and inherits the page's color-scheme. */
.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 scene: one unit (1em) for every visual, up to 16px, smaller on a narrow stage. The visual on
   stage is replaced at each turn and builds itself in. */
.scene {
  display: grid;
  place-items: center;
  height: 28em;
  font-size: clamp(8px, calc((100cqw - 32px) / 26), 16px);
}
.visual {
  display: grid;
  place-items: center;
}
.figure {
  margin: 0;
}

/* Shared pieces: a round portrait, and a name over a role. */
.avatar,
.avatarSmall,
.prospectAvatar,
.miniAvatar {
  display: block;
  flex: none;
  overflow: hidden;
  border-radius: 50%;
}
.avatar svg,
.avatarSmall svg,
.prospectAvatar svg,
.miniAvatar svg {
  display: block;
  width: 100%;
  height: 100%;
}
.avatar {
  width: 3em;
  height: 3em;
  box-shadow:
    0 0 0 0.14em var(--surface-2),
    0 0.2em 0.6em var(--avatar-shadow);
}
.who {
  display: grid;
  gap: 0.15em;
  margin: 0;
  white-space: nowrap;
}
.who strong {
  font-size: 0.94em;
  font-weight: 600;
  line-height: 1.3;
  letter-spacing: -0.006em;
}
.who span {
  font-size: 0.81em;
  line-height: 1.35;
  color: var(--ink-muted);
}
.who b {
  font-weight: 400;
  color: var(--blue);
}
.score {
  flex: none;
  padding: 0.28em 0.55em;
  border-radius: 0.42em;
  background: var(--score);
  font-size: 0.69em;
  font-weight: 600;
  line-height: 1.2;
  color: var(--score-ink);
}
.label {
  display: flex;
  align-items: center;
  gap: 0.3em;
  font-size: 0.45em;
  font-weight: 600;
  letter-spacing: 0.06em;
  color: var(--ink-faint);
  text-transform: uppercase;
  white-space: nowrap;
}

/* 1. Enrichment: the person, then the fields, the email opening as it is found. */
.enrich {
  width: 22.5em;
  height: 13.85em;
}
.person {
  display: flex;
  align-items: center;
  gap: 0.78em;
  padding-left: 1em;
}
.fields {
  margin-top: 1.25em;
  border-radius: 0.65em;
  background: var(--surface-2);
  box-shadow: var(--card-shadow);
}
.field,
.foundRow,
.searching {
  display: flex;
  align-items: center;
  box-sizing: border-box;
  padding: 0 1.05em 0 1.17em;
  border-bottom: 1px solid var(--line);
}
.field {
  justify-content: space-between;
  height: 2.08em;
}
.fieldLabel {
  font-size: 0.69em;
  font-weight: 600;
  letter-spacing: 0.035em;
  color: var(--field-label);
  text-transform: uppercase;
}
.chevron {
  width: 1em;
  height: 1em;
  color: var(--chevron);
}
.found {
  height: 2.84em;
  overflow: hidden;
}
.foundRow {
  height: 100%;
  gap: 0.6em;
}
.email {
  margin-right: auto;
  font-size: 0.81em;
  font-weight: 500;
  color: var(--email-ink);
}
.copy {
  width: 1.08em;
  height: 1.08em;
  color: var(--copy);
}
.searching {
  justify-content: space-between;
  height: 2.67em;
  padding-right: 0.8em;
  border-bottom: 0;
}
.track {
  position: relative;
  width: 8em;
  height: 0.36em;
  overflow: hidden;
  border-radius: 1em;
  background: linear-gradient(
    90deg,
    var(--track),
    var(--track-mid) 32%,
    var(--track-low) 62%,
    var(--track-end) 100%
  );
}
.sweep {
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, rgb(255 255 255 / 0), var(--sweep), rgb(255 255 255 / 0));
  opacity: 0;
}
.spinner {
  width: 1.45em;
  height: 1.45em;
  fill: none;
  stroke: var(--spinner);
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-dasharray: 44 60;
  transform: rotate(-120deg);
}

.visual[data-play] .found {
  animation: open 0.55s var(--ease) 1.5s backwards;
}
.visual[data-play] .foundRow > * {
  animation: rise 0.45s var(--ease) backwards;
}
.visual[data-play] .email {
  animation-delay: 1.75s;
}
.visual[data-play] .foundRow .score {
  animation: pop 0.55s var(--pop) 1.95s backwards;
}
.visual[data-play] .copy {
  animation-delay: 2.1s;
}
.visual[data-play] .sweep {
  animation: sweep 1.1s ease-in-out 0.3s 4;
}
.visual[data-play] .spinner {
  animation: spin 0.9s linear 5;
}
@keyframes open {
  from {
    height: 0;
  }
}
@keyframes sweep {
  from {
    opacity: 1;
    transform: translateX(-100%);
  }
  to {
    opacity: 1;
    transform: translateX(100%);
  }
}
@keyframes spin {
  from {
    transform: rotate(-120deg);
  }
  to {
    transform: rotate(240deg);
  }
}

/* 2. Job alert: a profile, and the alert popping over it from its tail. */
.alert {
  position: relative;
  width: 13em;
  padding-top: 2.67em;
}
.profile {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  box-sizing: border-box;
  height: 11.1em;
  padding: 1.04em 1em 1em;
  border-radius: 0.8em;
  background: var(--surface-2);
  box-shadow: var(--card-shadow);
}
.avatarSmall {
  width: 2.65em;
  height: 2.65em;
}
.whoStacked {
  gap: 0.4em;
  margin-top: 1.35em;
}
.whoStacked strong {
  font-size: 0.875em;
}
.tag {
  margin-top: auto;
  padding: 0.12em 0.42em;
  border-radius: 0.32em;
  background: var(--tag);
  font-size: 0.875em;
  font-weight: 500;
  line-height: 1.3;
  color: var(--tag-ink);
  white-space: nowrap;
}
.bubble {
  position: absolute;
  top: 0;
  left: 0.6em;
  display: flex;
  align-items: center;
  gap: 0.42em;
  height: 2.25em;
  margin: 0;
  padding: 0 0.95em 0 0.8em;
  border-radius: 1.2em;
  background: var(--accent);
  box-shadow: 0 0.4em 1em -0.3em rgb(81 9 242 / 0.5);
  font-weight: 500;
  color: var(--accent-ink);
  white-space: nowrap;
  transform-origin: 1.95em 115%;
}
.bubble::after {
  position: absolute;
  bottom: -0.3em;
  left: 1.6em;
  width: 0.7em;
  height: 0.7em;
  border-radius: 0.14em;
  background: inherit;
  transform: rotate(45deg);
  content: '';
}
.sparkle {
  width: 0.8em;
  height: 0.8em;
}
.visual[data-play] .bubble {
  animation: bubble 0.7s var(--pop) 0.55s backwards;
}
.visual[data-play] .sparkle {
  animation: twirl 0.9s var(--ease) 0.75s backwards;
}
.visual[data-play] .tag {
  animation: rise 0.5s var(--ease) 1.25s backwards;
}
@keyframes bubble {
  from {
    opacity: 0;
    transform: translateY(0.5em) scale(0.3);
  }
  30% {
    opacity: 1;
  }
}
@keyframes twirl {
  from {
    transform: rotate(-120deg) scale(0.4);
  }
}

/* 3. Import: cards each turned a little, sliding into a stack, and where they come from. Flat
   turns, not 3D: a card turned in 3D keeps a layer of its own for as long as it is shown. */
.stack {
  position: relative;
  width: 22.4em;
  height: 11.2em;
  margin: 0.8em 0 0 0.9em;
}
.contact {
  position: absolute;
  display: flex;
  align-items: center;
  box-sizing: border-box;
  width: 20.4em;
  height: 5em;
  padding: 0 1em 0 1.2em;
  border-radius: 0.75em;
  background: var(--surface-2);
  box-shadow: var(--card-shadow);
}
.contact[data-card='under'] {
  top: 2.5em;
  left: -0.3em;
  transform: rotate(-0.8deg);
}
.contact[data-card='back'] {
  top: 4.45em;
  left: 0.95em;
  width: 21.2em;
  transform: rotate(4.3deg);
}
.contact[data-card='front'] {
  top: 0;
  left: 0;
  transform: rotate(1.9deg);
}
.star {
  flex: none;
  width: 1em;
  height: 1em;
  color: var(--star);
}
.face {
  position: relative;
  flex: none;
  margin-left: 1.15em;
}
.face .avatar {
  width: 2.75em;
  height: 2.75em;
}
.badge {
  position: absolute;
  right: -0.35em;
  bottom: -0.15em;
  display: grid;
  place-items: center;
  width: 1.3em;
  height: 1.3em;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 0.08em 0.3em var(--plate-shadow);
}
.badge svg {
  width: 0.85em;
  height: 0.85em;
}
.contact .who {
  margin-left: 1.1em;
}
.importPill {
  position: absolute;
  top: 9.55em;
  left: 7.8em;
  display: flex;
  align-items: center;
  gap: 0.35em;
  height: 1.62em;
  margin: 0;
  padding: 0 0.7em 0 0.5em;
  border-radius: 0.5em;
  background: var(--surface-2);
  box-shadow: var(--card-shadow);
  font-size: 0.94em;
  font-weight: 500;
  white-space: nowrap;
}
.sourceMark {
  width: 1.05em;
  height: 1.05em;
  color: var(--source);
}
.visual[data-play] .contact[data-card='under'] {
  animation: slide-under 0.7s var(--ease) 0.1s backwards;
}
.visual[data-play] .contact[data-card='back'] {
  animation: slide-back 0.8s var(--ease) 0.2s backwards;
}
.visual[data-play] .contact[data-card='front'] {
  animation: slide-front 0.85s var(--pop) 0.6s backwards;
}
.visual[data-play] .importPill {
  animation: pop 0.6s var(--pop) 1.35s backwards;
}
@keyframes slide-under {
  from {
    opacity: 0;
    transform: translateY(2.4em) rotate(-4deg);
  }
}
@keyframes slide-back {
  from {
    opacity: 0;
    transform: translate(1.6em, 3em) rotate(9deg);
  }
}
@keyframes slide-front {
  from {
    opacity: 0;
    transform: translate(-1.2em, -2.6em) rotate(-3deg);
  }
}

/* 4. Delivery: an envelope opens, the prospect rises out of it, the company's cards fan out. */
.deliver {
  position: relative;
  width: 22.4em;
  height: 16.1em;
  margin: 0 0 1.2em 2.9em;
}
.envelope {
  position: absolute;
  top: 8.34em;
  left: 0;
  width: 17.1em;
  height: 7.7em;
  border-radius: 0 0 0.9em 0.9em;
  box-shadow: 0 0.9em 2em -0.8em var(--envelope-shadow);
}
.back {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--surface-2);
}
/* The prospect's card rises from inside: a frame that hides what is below the envelope's
   opening. */
.slot {
  position: absolute;
  top: -9.9em;
  left: -0.25em;
  width: 10.6em;
  height: 12.2em;
  overflow: hidden;
  overflow: clip;
}
.prospect {
  position: absolute;
  top: 2.1em;
  left: 1em;
  box-sizing: border-box;
  width: 9.2em;
  height: 10.1em;
  padding: 1.15em 1.08em;
  border-radius: 0.9em;
  background: var(--surface-2);
  box-shadow: 0 0.06em 0.3em var(--prospect-shadow);
}
.prospectAvatar {
  width: 2.42em;
  height: 2.42em;
  margin-top: 0.75em;
}
.whoSmall {
  gap: 0.35em;
  margin-top: 0.75em;
}
.whoSmall strong {
  font-size: 0.6em;
}
.whoSmall span {
  font-size: 0.55em;
}
.glow {
  position: absolute;
  top: 0;
  height: 2.5em;
}
.glow[data-side='left'] {
  left: 0;
  width: 8.35em;
  background: radial-gradient(
    ellipse 55% 125% at 47% 100%,
    rgb(176 112 246 / 0.95),
    rgb(204 162 250 / 0.6) 50%,
    rgb(232 214 252 / 0) 100%
  );
}
.glow[data-side='right'] {
  left: 8.35em;
  width: 8.75em;
  background: radial-gradient(
    ellipse 55% 125% at 53% 100%,
    rgb(128 182 252 / 0.9),
    rgb(174 208 252 / 0.55) 50%,
    rgb(214 230 252 / 0) 100%
  );
}
.pocket {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
.watermark {
  position: absolute;
  right: 1.5em;
  bottom: 0.85em;
  display: flex;
  align-items: center;
  gap: 0.3em;
  margin: 0;
  font-size: 0.78em;
  font-weight: 600;
  color: var(--watermark);
}
.kite {
  width: 1.15em;
  height: 1.15em;
}
/* The flap, closed at first; it turns open and is gone. */
.flap {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 4.6em;
  transform-origin: 50% 0;
  visibility: hidden;
}

.mini,
.details {
  position: absolute;
  box-sizing: border-box;
  border-radius: 0.55em;
  background: var(--surface-2);
  box-shadow: var(--card-shadow);
}
.mini {
  display: flex;
  flex-direction: column;
  gap: 0.6em;
  padding: 0.65em 0.6em;
}
.mini[data-card='email'] {
  top: 0;
  left: 12.85em;
  flex-direction: row;
  align-items: center;
  width: 6.3em;
  height: 2.9em;
  padding-top: 0.2em;
  transform: rotate(3deg);
}
.mini[data-card='awards'] {
  top: 1.44em;
  left: 9.9em;
  width: 5.2em;
  height: 2.75em;
  transform: rotate(2deg);
}
.mini[data-card='phone'] {
  top: 4.04em;
  left: 9em;
  width: 4.4em;
  height: 3.05em;
  transform: rotate(-1deg);
}
.miniAvatar {
  width: 1.45em;
  height: 1.45em;
  margin-right: 0.45em;
}
.miniValue {
  font-size: 0.52em;
  font-weight: 500;
  white-space: nowrap;
}
.labelIcon {
  width: 1.3em;
  height: 1.3em;
}

.details {
  top: 2.79em;
  left: 11.05em;
  display: flex;
  flex-direction: column;
  width: 11.25em;
  height: 6.6em;
  padding: 0.45em 0.62em 0;
  transform: rotate(-3deg);
}
.company {
  display: flex;
  align-items: center;
  gap: 0.5em;
  margin-top: 0.5em;
}
.logo {
  display: grid;
  flex: none;
  place-items: center;
  width: 1.3em;
  height: 1.3em;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 0.06em 0.3em var(--logo-shadow);
}
.logo svg {
  width: 0.62em;
  height: 0.62em;
}
.companyText {
  display: grid;
  gap: 0.15em;
}
.companyText strong {
  font-size: 0.52em;
  font-weight: 600;
}
.companyText span {
  font-size: 0.5em;
  color: var(--ink-faint);
}
.industry {
  margin-left: auto;
  align-self: flex-start;
  padding: 0.25em 0.5em;
  border-radius: 0.35em;
  background: var(--chip);
  font-size: 0.45em;
  font-weight: 500;
  color: var(--chip-ink);
  white-space: nowrap;
}
.line {
  display: flex;
  align-items: center;
  gap: 0.45em;
  margin: 0.9em 0 0;
  font-size: 0.52em;
  font-weight: 500;
  white-space: nowrap;
}
.line + .line {
  margin-top: 0.75em;
}
.lineIcon {
  width: 1.45em;
  height: 1.45em;
  color: var(--line-icon);
}
.line .score {
  margin-left: 0.6em;
  font-size: 0.9em;
}
.phone {
  position: relative;
}
.hand {
  width: 1.35em;
  height: 1.35em;
  margin: 0.6em 0 0 -0.3em;
}

/* The envelope is there at once, closed: its flap hides the glow until it turns open. */
.visual[data-play] .flap {
  animation: flap 0.7s ease-in 0.35s backwards;
}
.visual[data-play] .prospect {
  animation: emerge 0.75s var(--ease) 0.75s backwards;
}
.visual[data-play] .mini,
.visual[data-play] .details {
  animation: fan 0.7s var(--pop) backwards;
}
.visual[data-play] .mini[data-card='email'] {
  --from: translate(-4em, 6em) rotate(-8deg) scale(0.6);
  animation-delay: 1.1s;
}
.visual[data-play] .mini[data-card='awards'] {
  --from: translate(-2em, 5.5em) rotate(-6deg) scale(0.6);
  animation-delay: 1.2s;
}
.visual[data-play] .mini[data-card='phone'] {
  --from: translate(-1.2em, 3.5em) rotate(-5deg) scale(0.6);
  animation-delay: 1.3s;
}
.visual[data-play] .details {
  --from: translate(-3em, 4.6em) rotate(-10deg) scale(0.6);
  animation-delay: 1.45s;
}
.visual[data-play] .hand {
  animation: point 0.7s var(--ease) 2.35s backwards;
}
@keyframes flap {
  from {
    visibility: visible;
    transform: perspective(30em) rotateX(0deg);
  }
  60% {
    visibility: visible;
    opacity: 1;
  }
  to {
    visibility: visible;
    opacity: 0;
    transform: perspective(30em) rotateX(-160deg);
  }
}
@keyframes emerge {
  from {
    transform: translateY(9.9em);
  }
}
@keyframes fan {
  from {
    opacity: 0;
    transform: var(--from);
  }
}
@keyframes point {
  from {
    opacity: 0;
    transform: translate(1.4em, 1.2em);
  }
}

/* The moments' small motions. */
@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(0.5em);
  }
}
@keyframes pop {
  from {
    opacity: 0;
    transform: scale(0.6);
  }
}

/* The dots: tabs, with room around each for a finger. The current one is a pill that fills while
   the cycle waits on it. */
.dots {
  display: flex;
  justify-content: center;
  gap: 2px;
  padding: 0 0 14px;
}
.dot {
  display: grid;
  place-items: center;
  height: 28px;
  margin: 0;
  padding: 0 5px;
  border: 0;
  background: none;
  cursor: pointer;
}
.pip {
  position: relative;
  width: 6px;
  height: 6px;
  overflow: hidden;
  border-radius: 3px;
  background: var(--pip);
  transition:
    width 0.35s var(--ease),
    background-color 0.3s ease;
}
.dot[aria-selected='true'] .pip {
  width: 22px;
}
.dotFill {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--pip-fill);
  opacity: 0;
  transform-origin: 0 50%;
}
.dot[aria-selected='true'] .dotFill {
  opacity: 1;
}
.root[data-cycling] .dot[aria-selected='true'] .dotFill {
  animation: fill var(--slot) linear backwards;
}
@keyframes fill {
  from {
    transform: scaleX(0);
  }
}
.dot:focus-visible {
  outline: none;
}
.dot:focus-visible .pip {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
}
@media (hover: hover) {
  .dot:hover .pip {
    background: var(--pip-hover);
  }
}

/* For screen readers only. */
.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) {
  .visual,
  .visual * {
    animation: none !important;
  }
  .pip {
    transition: none;
  }
}
```

### prospect-cards/avatars.tsx

```tsx
/*
 * The people, drawn: flat portraits on a pastel disc, from the shoulders up. Each is an SVG on a
 * 48 × 48 grid; the round frame and its ring come from the CSS, which clips the shoulders.
 */

export type PersonId = 'nora' | 'tessa' | 'helen';

type Look = {
  /** The disc behind the portrait. */
  backdrop: string;
  skin: string;
  /** The shade under the chin and on the neck. */
  shade: string;
  hair: string;
  /** The lighter strands on top. */
  shine: string;
  top: string;
};

const LOOKS: Record<PersonId, Look> = {
  nora: {
    backdrop: '#e9defc',
    skin: '#f5cfae',
    shade: '#e2ad8a',
    hair: '#c08a4a',
    shine: '#dcab63',
    top: '#f3c44a',
  },
  tessa: {
    backdrop: '#f6eee6',
    skin: '#f7d6bd',
    shade: '#e8b896',
    hair: '#e0bd76',
    shine: '#f0d498',
    top: '#fbf8f2',
  },
  helen: {
    backdrop: '#fbf1d9',
    skin: '#f0c9a8',
    shade: '#dba886',
    hair: '#cfcac3',
    shine: '#e9e5df',
    top: '#e6d9c6',
  },
};

/** The face: eyes, a smile and warm cheeks, the same for everyone. */
function Face() {
  return (
    <>
      <circle cx="20.6" cy="22.4" r="1" fill="#3a2a22" />
      <circle cx="27.4" cy="22.4" r="1" fill="#3a2a22" />
      <circle cx="18.9" cy="25.6" r="1.7" fill="#f08f86" opacity="0.32" />
      <circle cx="29.1" cy="25.6" r="1.7" fill="#f08f86" opacity="0.32" />
      <path
        d="M21.4 26.4c1.5 1.4 3.7 1.4 5.2 0"
        fill="none"
        stroke="#b4604c"
        strokeWidth="1.1"
        strokeLinecap="round"
      />
    </>
  );
}

/** Shoulders, neck and head, shared by every portrait under its own hair. */
function Body({ look }: { look: Look }) {
  return (
    <>
      <path d="M6 49c.9-8.4 7.4-12.6 18-12.6S41.1 40.6 42 49z" fill={look.top} />
      <path d="M20.3 29.5h7.4v6.6c0 2.2-7.4 2.2-7.4 0z" fill={look.shade} />
      <ellipse cx="24" cy="22.2" rx="7.7" ry="9" fill={look.skin} />
    </>
  );
}

export function Avatar({ person, className }: { person: PersonId; className?: string }) {
  const look = LOOKS[person];
  return (
    <span className={className} aria-hidden="true">
      <svg viewBox="0 0 48 48">
        <rect width="48" height="48" fill={look.backdrop} />
        {person === 'nora' && (
          <>
            {/* Long waves falling past the shoulders. */}
            <path
              d="M24 6.2c-8.6 0-13.3 6.3-13.3 14.6 0 6.2 1.2 10.6-1.8 15.6 3.2 2.3 7.6 2.8 11.2 1.2L24 31l3.9 6.6c3.6 1.6 8 1.1 11.2-1.2-3-5-1.8-9.4-1.8-15.6C37.3 12.5 32.6 6.2 24 6.2z"
              fill={look.hair}
            />
            <Body look={look} />
            <path
              d="M16.1 21.6c-.1-7.4 3.6-11.4 8.6-11.4 4.6 0 7.6 3.4 8.1 8.8-4.4-.5-8.2-2.4-10.6-5.5-1.1 3.8-3 6.4-6.1 8.1z"
              fill={look.shine}
            />
            <path
              d="M15.7 22.4c.5 5.2-.3 9.1-2.2 12.4M32.4 22.4c-.5 5.2.3 9.1 2.2 12.4"
              fill="none"
              stroke={look.shine}
              strokeWidth="1.6"
              strokeLinecap="round"
            />
          </>
        )}
        {person === 'tessa' && (
          <>
            {/* A blunt bob to the jaw. */}
            <path
              d="M24 7.2c-8.2 0-12.7 6-12.7 13.6 0 4.9.5 8.4-.9 11.4 2.9 1.7 6.2 1.9 8.9 1.1h9.4c2.7.8 6 .6 8.9-1.1-1.4-3-.9-6.5-.9-11.4C36.7 13.2 32.2 7.2 24 7.2z"
              fill={look.hair}
            />
            <Body look={look} />
            <path
              d="M16.2 20.8c.6-6.4 3.9-9.8 8.7-9.8 4.4 0 7.4 2.9 8 7.6-3.6.2-8.6-1.2-11.4-4-1 2.8-2.8 4.8-5.3 6.2z"
              fill={look.shine}
            />
            <path d="M20.3 36.6 24 40l3.7-3.4" fill="none" stroke="#eadfcf" strokeWidth="1.2" />
          </>
        )}
        {person === 'helen' && (
          <>
            <Body look={look} />
            {/* A short crop, and round glasses. */}
            <path
              d="M15.9 21.4c-1.1-8.1 3.3-13.6 8.6-13.6 5.9 0 9.7 4.8 8.2 13.2-1.1-3.6-3.2-5.9-5.9-7.1-2.6 2-7 3.4-10.9 7.5z"
              fill={look.hair}
            />
            <path
              d="M18.6 13.4c2.6-2.3 6.6-3 9.9-1.6"
              fill="none"
              stroke={look.shine}
              strokeWidth="1.5"
              strokeLinecap="round"
            />
            <g fill="none" stroke="#33323a" strokeWidth="1">
              <circle cx="20.6" cy="22.4" r="2.9" />
              <circle cx="27.4" cy="22.4" r="2.9" />
              <path d="M23.5 22.2h1" />
            </g>
          </>
        )}
        <Face />
      </svg>
    </span>
  );
}
```

### prospect-cards/marks.tsx

```tsx
/*
 * The small pieces of the visuals, on a 16 × 16 grid: the companies' marks, the tool's own mark,
 * and the interface icons. All are decorative; each visual has a text alternative.
 */

type IconProps = { className?: string };

const line = {
  viewBox: '0 0 16 16',
  fill: 'none',
  stroke: 'currentColor',
  strokeLinecap: 'round',
  strokeLinejoin: 'round',
  'aria-hidden': true,
} as const;

const solid = { viewBox: '0 0 16 16', fill: 'currentColor', 'aria-hidden': true } as const;

/** Velto: a cut gem. */
export function VeltoMark({ className }: IconProps) {
  return (
    <svg {...solid} className={className}>
      <path d="M8 1.6 14.4 8 8 14.4 1.6 8z" fill="#1d1b2c" />
      <path d="M8 5.2 10.8 8 8 10.8 5.2 8z" fill="#fff" />
    </svg>
  );
}

/** Mossbyte: a sun. */
export function MossbyteMark({ className }: IconProps) {
  return (
    <svg {...line} className={className} stroke="#f2663a" strokeWidth={1.5}>
      <circle cx="8" cy="8" r="2.4" fill="#f2663a" stroke="none" />
      <path d="M8 1.8v1.6M8 12.6v1.6M1.8 8h1.6M12.6 8h1.6M3.6 3.6l1.1 1.1M11.3 11.3l1.1 1.1M3.6 12.4l1.1-1.1M11.3 4.7l1.1-1.1" />
    </svg>
  );
}

/** Loafly: a sprout. */
export function LoaflyMark({ className }: IconProps) {
  return (
    <svg {...solid} className={className}>
      <path d="M7.6 14.2V8.4" stroke="#2f8a57" strokeWidth="1.5" strokeLinecap="round" />
      <path d="M7.6 8.6C7.4 5 5 3 1.8 3.2 1.8 6.7 4 8.8 7.6 8.6z" fill="#2f8a57" />
      <path d="M8.2 9.6c.1-3.3 2.4-5.3 6-5.1-.1 3.4-2.4 5.3-6 5.1z" fill="#5cb37f" />
    </svg>
  );
}

/** The address book the contacts come from: a bold square with a quarter cut out. */
export function SourceMark({ className }: IconProps) {
  return (
    <svg {...solid} className={className}>
      <path d="M4.6 2h6.8A2.6 2.6 0 0 1 14 4.6v2.2A7.2 7.2 0 0 0 6.8 14H4.6A2.6 2.6 0 0 1 2 11.4V4.6A2.6 2.6 0 0 1 4.6 2z" />
      <path d="M14 10.4V11.4a2.6 2.6 0 0 1-2.6 2.6h-1A3.6 3.6 0 0 1 14 10.4z" />
    </svg>
  );
}

/** The tool's own mark: a kite on a string. */
export function KiteMark({ className }: IconProps) {
  return (
    <svg {...line} className={className} strokeWidth={1.4}>
      <path d="M8 1.8 12 6.4 8 11.2 4 6.4z" />
      <path d="M4 6.4h8M8 1.8v9.4M8 11.2c-.4 1.6.6 2.1 1.6 2.6" />
    </svg>
  );
}

export function ChevronIcon({ className }: IconProps) {
  return (
    <svg {...line} className={className} strokeWidth={1.5}>
      <path d="m4.6 6.4 3.4 3.4 3.4-3.4" />
    </svg>
  );
}

export function CopyIcon({ className }: IconProps) {
  return (
    <svg {...line} className={className} strokeWidth={1.3}>
      <rect x="5.4" y="5.4" width="8" height="8" rx="2" />
      <path d="M10.6 5.2V4.4a1.8 1.8 0 0 0-1.8-1.8H4.4a1.8 1.8 0 0 0-1.8 1.8v4.4a1.8 1.8 0 0 0 1.8 1.8h.8" />
    </svg>
  );
}

export function StarIcon({ className }: IconProps) {
  return (
    <svg {...solid} className={className}>
      <path d="m8 1.8 1.9 3.9 4.3.6-3.1 3 .7 4.3L8 11.6l-3.8 2 .7-4.3-3.1-3 4.3-.6z" />
    </svg>
  );
}

/** The four-pointed sparkle of the job alert. */
export function SparkleIcon({ className }: IconProps) {
  return (
    <svg {...solid} className={className}>
      <path d="M8 1.2c.5 3.6 2.6 5.8 6.8 6.8-4.2 1-6.3 3.2-6.8 6.8-.5-3.6-2.6-5.8-6.8-6.8 4.2-1 6.3-3.2 6.8-6.8z" />
    </svg>
  );
}

export function AtIcon({ className }: IconProps) {
  return (
    <svg {...line} className={className} strokeWidth={1.4}>
      <circle cx="8" cy="8" r="2.6" />
      <path d="M10.6 8v1a2 2 0 0 0 4 0V8a6.6 6.6 0 1 0-2.6 5.3" />
    </svg>
  );
}

export function PhoneIcon({ className }: IconProps) {
  return (
    <svg {...line} className={className} strokeWidth={1.4}>
      <path d="M5.6 2.4 3.8 2.6c-.8.1-1.4.8-1.3 1.6.6 5.1 4.6 9.1 9.7 9.7.8.1 1.5-.5 1.6-1.3l.2-1.8a1 1 0 0 0-.7-1.1l-2-.6a1 1 0 0 0-1 .3l-.8.9a8 8 0 0 1-3.6-3.6l.9-.8a1 1 0 0 0 .3-1l-.6-2a1 1 0 0 0-1-.7z" />
    </svg>
  );
}

/** A small rosette, beside the awards label. */
export function AwardIcon({ className }: IconProps) {
  return (
    <svg {...line} className={className} strokeWidth={1.3}>
      <circle cx="8" cy="6.4" r="3.6" />
      <path d="m6 9.6-1.2 4.6L8 12.6l3.2 1.6L10 9.6" />
    </svg>
  );
}

/** The pointing hand over the phone number. */
export function HandIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 16 16" className={className} aria-hidden="true">
      <path
        d="M6.2 1.6c-.7 0-1.2.5-1.2 1.2v6.1l-.9-.9c-.5-.5-1.3-.5-1.8 0s-.5 1.2 0 1.7l3 3.4c.6.7 1.5 1.1 2.4 1.1h2.1c1.8 0 3.2-1.4 3.2-3.2V7.6c0-.6-.5-1.1-1.1-1.1s-1.1.5-1.1 1.1v-.5c0-.6-.5-1.1-1.1-1.1s-1.1.5-1.1 1.1v-.4c0-.6-.5-1.1-1.1-1.1-.6 0-1.1.5-1.1 1.1V2.8c0-.7-.5-1.2-1.2-1.2z"
        fill="#fff"
        stroke="#1d1b2c"
        strokeWidth="1"
        strokeLinejoin="round"
      />
      <path
        d="M8.4 9.4v2.2M10.4 9.4v2.2"
        stroke="#1d1b2c"
        strokeWidth="0.8"
        strokeLinecap="round"
      />
    </svg>
  );
}
```

### prospect-cards/prospects.ts

```tsx
/*
 * What the visuals show: made-up people at made-up companies, with made-up addresses. The phone
 * numbers are in the 555-01xx range, which is kept for fiction.
 */

import type { PersonId } from './avatars';

export type Contact = {
  person: PersonId;
  name: string;
  role: string;
  company: string;
};

export const NORA: Contact = {
  person: 'nora',
  name: 'Nora Whitfield',
  role: 'IT Operations',
  company: 'Velto',
};

export const TESSA: Contact = {
  person: 'tessa',
  name: 'Tessa Morrow',
  role: 'Sr. Director',
  company: 'Mossbyte',
};

export const HELEN: Contact = {
  person: 'helen',
  name: 'Helen Ostrowski',
  role: 'Merchandise Director',
  company: 'Loafly',
};

/** What the tool finds about Velto, Nora's company. */
export const COMPANY = {
  name: 'Velto',
  website: 'www.velto.co',
  industry: 'Computer Software',
  email: 'contact@velto.co',
  emailScore: 93,
  phone: '(425) 555-0148',
  award: 'Fast 50, 2025',
};

/** Nora's own address, and how sure the tool is of it. */
export const FOUND_EMAIL = { address: 'nora.w@velto.co', score: 98 };

/** The prospect's number in the tool. */
export const PROSPECT_ID = '#0048';
```

### prospect-cards/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 };
}
```