Prospect cards
Four moments of a prospecting tool, live: an email found, a job alert, contacts coming in, a prospect delivered.
prospect-cards/prospect-cards.tsxThe 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
- 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.
You can add this component with the shadcn CLI or by hand:
Into components/prospect-cards of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/prospect-cards.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
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} />;}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.
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.
- 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.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultVisual | 'enrich' | 'alert' | 'import' | 'deliver' | '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 | 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. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// 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,/* 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;/* * 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',/* * 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" />/* * 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','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;- 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.