Parley
Contact section that routes before it asks: topic chips (Sales / Support / Press / Other) reshape the form with layout animation — fields glide in and out, the submit label and expected-reply line change. Blur validation with a shake, async pending → drawn-check success or an inline error that keeps every value, plus channel cards with copy-to-clipboard and an office card with an abstract SVG street plate and pulsing pin.
"use client";
import { AnimatePresence, motion, useAnimate, type Transition } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
/* ---------------------------------------------------------------- types */
/** Input kind of a form field. */
export type ParleyFieldType = "text" | "email" | "tel" | "number" | "textarea" | "select" | "segmented";
export interface ParleyOption {
/** Submitted value. */
value: string;
/** Visible label. */
label: string;
}
export interface ParleyField {
/**
* Key in the submitted values. Fields that share a `name` across topics
* keep their value and glide to their new position when the topic changes.
*/
name: string;
/** Visible label. */
label: string;
/** Input kind. @default "text" */
type?: ParleyFieldType;
/** Placeholder text (text-like inputs and textarea). */
placeholder?: string;
/** Choices for `select` and `segmented` fields (strings use the same value and label). */
options?: (ParleyOption | string)[];
/** Initial value (e.g. the preselected segmented choice). */
defaultValue?: string;
/** Must be filled before submit. @default false */
required?: boolean;
/** Message shown when a required field is empty. @default "{label} is required." */
requiredMessage?: string;
/** Format check applied to non-empty values. */
pattern?: RegExp;
/** Message shown when `pattern` fails. @default "Check the format of this field." */
patternMessage?: string;
/** Custom validator; return a message to flag the field. Runs after the built-in checks. */
validate?: (value: string, values: Record<string, string>) => string | undefined | null;
/** Half width beside its neighbour when the form is wide enough. @default false */
half?: boolean;
/** Small helper line under the input. */
hint?: string;
/** `autocomplete` attribute for text-like inputs. */
autoComplete?: string;
/** Visible rows for a textarea. @default 4 */
rows?: number;
}
/** Built-in topic glyphs. Pass a node for your own icon. */
export type ParleyTopicGlyph = "sales" | "support" | "press" | "other";
export interface ParleyTopic {
/** Stable id, passed back to `onSubmit`. */
id: string;
/** Chip label. */
label: string;
/** Built-in glyph name or your own icon node. @default "other" */
icon?: ParleyTopicGlyph | React.ReactNode;
/** Fields shown, in order, while this topic is selected. */
fields: ParleyField[];
/** Submit button label. @default "Send message" */
submitLabel?: string;
/** Expected-reply line under the form (e.g. "Replies within 2 hours"). */
replyLine?: string;
/** Body of the success card. @default "Your message is in. {replyLine}." */
successMessage?: string;
}
/** Built-in channel glyphs. */
export type ParleyChannelKind = "email" | "chat" | "phone" | "other";
export interface ParleyChannel {
/** Glyph and default link scheme. @default "other" */
kind?: ParleyChannelKind;
/** Card heading (e.g. "Email us"). */
label: string;
/** The visible contact value (address, handle, number). */
value: string;
/** Muted supporting line (hours, response time). */
detail?: string;
/** Link for the value. Email/phone default to `mailto:` / `tel:`. */
href?: string;
/** Text written to the clipboard. @default value */
copyValue?: string;
}
export interface ParleyOffice {
/** Card heading. @default "Visit the studio" */
label?: string;
/** Address lines. */
lines: string[];
/** Muted supporting line (opening hours). */
detail?: string;
/** Directions link. */
href?: string;
/** Text written to the clipboard. @default lines joined with ", " */
copyValue?: string;
}
/** Imperative verbs exposed through the `ref` prop. */
export interface ParleyHandle {
/** Clear every field, drop errors and the success card, and return to the default topic. */
reset: () => void;
/** Re-run the designed intro. */
replay: () => void;
/** Switch topic programmatically. */
setTopic: (id: string) => void;
/** Focus the first field of the form. */
focus: () => void;
}
export interface ParleyProps extends Omit<React.ComponentPropsWithoutRef<"section">, "children" | "title" | "onSubmit"> {
/** Small label above the title. @default "Contact" */
eyebrow?: React.ReactNode;
/** Section heading. @default "Talk to the right person" */
title?: React.ReactNode;
/** Line under the heading. */
subtitle?: React.ReactNode;
/** Topic chips and the form each one routes to. Omit for Sales / Support / Press / Other. */
topics?: ParleyTopic[];
/** Uncontrolled initial topic id. @default first topic */
defaultTopic?: string;
/** Controlled topic id. */
topic?: string;
/** Called when the visitor picks a topic. */
onTopicChange?: (id: string) => void;
/**
* Called with the current topic's values. Return a Promise to hold the
* button in its pending state — resolve shows the success card, reject
* keeps every value and shows the rejection's message inline.
*/
onSubmit?: (values: Record<string, string>, topic: string) => void | Promise<unknown>;
/** Side-column channel cards with copy-to-clipboard. Omit for a sample set; `[]` hides them. */
channels?: ParleyChannel[];
/** Office card with an abstract location plate, or `false` to hide it. */
office?: ParleyOffice | false;
/** Accent hex for the active chip, submit button, focus rings and pin. @default "#60a5fa" */
accentColor?: string;
/** Imperative handle (`reset()`, `replay()`, `setTopic()`, `focus()`). React 19 ref-as-prop. */
ref?: React.Ref<ParleyHandle>;
/** Extra classes for the `<section>` root. */
className?: string;
}
/* ---------------------------------------------------------------- defaults */
const DEFAULT_ACCENT = "#60a5fa";
const EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];
const SPRING: Transition = { type: "spring", duration: 0.45, bounce: 0 };
const INSTANT: Transition = { duration: 0 };
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const NAME: ParleyField = {
name: "name",
label: "Full name",
placeholder: "Ada Moreno",
required: true,
requiredMessage: "Tell us who we're talking to.",
autoComplete: "name",
half: true,
};
const EMAIL: ParleyField = {
name: "email",
label: "Work email",
type: "email",
placeholder: "ada@company.com",
required: true,
requiredMessage: "We need an email to reply to.",
autoComplete: "email",
half: true,
};
const MESSAGE: ParleyField = {
name: "message",
label: "How can we help?",
type: "textarea",
placeholder: "A few lines of context goes a long way.",
required: true,
requiredMessage: "Add a short message.",
validate: (v) => (v.trim().length < 12 ? "Add a little more detail (12+ characters)." : undefined),
rows: 4,
};
const DEFAULT_TOPICS: ParleyTopic[] = [
{
id: "sales",
label: "Sales",
icon: "sales",
submitLabel: "Talk to sales",
replyLine: "Replies within 4 hours",
fields: [
NAME,
EMAIL,
{
name: "companySize",
label: "Company size",
type: "select",
required: true,
requiredMessage: "Pick a company size.",
options: [
{ value: "", label: "Select a range" },
{ value: "1-10", label: "1–10 people" },
{ value: "11-50", label: "11–50 people" },
{ value: "51-200", label: "51–200 people" },
{ value: "201-1000", label: "201–1,000 people" },
{ value: "1000+", label: "1,000+ people" },
],
half: true,
},
{
name: "teamSize",
label: "Seats needed",
type: "number",
placeholder: "12",
required: true,
requiredMessage: "Roughly how many seats?",
validate: (v) => (!/^\d+$/.test(v.trim()) || Number(v) < 1 ? "Use a whole number, 1 or more." : undefined),
half: true,
},
{ ...MESSAGE, label: "What are you hoping to solve?", placeholder: "Current setup, timeline, must-haves…" },
],
},
{
id: "support",
label: "Support",
icon: "support",
submitLabel: "Open a ticket",
replyLine: "Replies within 2 hours",
fields: [
NAME,
EMAIL,
{
name: "accountId",
label: "Account ID",
placeholder: "AC-104238",
pattern: /^[A-Za-z]{2}-\d{4,8}$/,
patternMessage: "Account IDs look like AC-104238.",
hint: "Settings → Billing. Optional, but it speeds things up.",
},
{
name: "severity",
label: "Severity",
type: "segmented",
options: [
{ value: "low", label: "Low" },
{ value: "normal", label: "Normal" },
{ value: "high", label: "High" },
{ value: "urgent", label: "Urgent" },
],
defaultValue: "normal",
},
{ ...MESSAGE, label: "What's going on?", placeholder: "What you expected, what happened, and when it started." },
],
},
{
id: "press",
label: "Press",
icon: "press",
submitLabel: "Send press inquiry",
replyLine: "Replies within 1 business day",
fields: [
NAME,
EMAIL,
{ name: "outlet", label: "Publication", placeholder: "The Weekly Ledger", required: true, requiredMessage: "Which outlet are you writing for?", half: true },
{
name: "deadline",
label: "Deadline",
type: "select",
options: ["No fixed deadline", "Within 48 hours", "This week", "This month"],
defaultValue: "No fixed deadline",
half: true,
},
{ ...MESSAGE, label: "Your questions", placeholder: "Angle, questions, and who you'd like to speak with." },
],
},
{
id: "other",
label: "Other",
icon: "other",
submitLabel: "Send message",
replyLine: "Replies within 2 business days",
fields: [NAME, EMAIL, { name: "subject", label: "Subject", placeholder: "Partnership, feedback, hello…" }, MESSAGE],
},
];
const DEFAULT_CHANNELS: ParleyChannel[] = [
{ kind: "email", label: "Email", value: "hi@halden.example", detail: "Answered by a person, never a bot" },
{ kind: "chat", label: "Live chat", value: "halden.example/chat", href: "#", detail: "Weekdays 8:00–18:00 PT" },
{ kind: "phone", label: "Phone", value: "+1 (415) 555-0142", detail: "Priority line for Business plans" },
];
const DEFAULT_OFFICE: ParleyOffice = {
label: "Visit the studio",
lines: ["418 Alder Street, Floor 3", "Portland, OR 97204"],
detail: "Tue–Thu, 10:00–17:00 · by appointment",
href: "#",
};
/* ---------------------------------------------------------------- helpers */
/** Black or white text, whichever reads against the accent fill. */
function readableOn(hex: string): string {
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return "#0a0a0a";
let s = m[1];
if (s.length === 3) s = s.replace(/./g, (c) => c + c);
const n = parseInt(s, 16);
const lum = 0.2126 * ((n >> 16) & 255) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255);
return lum > 140 ? "#0a0a0a" : "#ffffff";
}
function normOptions(options: ParleyField["options"]): ParleyOption[] {
return (options ?? []).map((o) => (typeof o === "string" ? { value: o, label: o } : o));
}
function initialValue(f: ParleyField): string {
if (f.defaultValue != null) return f.defaultValue;
if (f.type === "segmented") return normOptions(f.options)[0]?.value ?? "";
return "";
}
function validateField(f: ParleyField, raw: string | undefined, values: Record<string, string>): string | undefined {
const value = (raw ?? "").trim();
if (!value) return f.required ? f.requiredMessage ?? `${f.label} is required.` : undefined;
if (f.type === "email" && !EMAIL_RE.test(value)) return "That doesn't look like an email address.";
if (f.pattern && !f.pattern.test(value)) return f.patternMessage ?? "Check the format of this field.";
return f.validate?.(value, values) ?? undefined;
}
async function writeClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
/* fall through to the legacy path */
}
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand("copy");
ta.remove();
return ok;
} catch {
return false;
}
}
/* ---------------------------------------------------------------- glyphs */
const stroke = {
fill: "none",
stroke: "currentColor",
strokeWidth: 1.7,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
const TOPIC_GLYPHS: Record<ParleyTopicGlyph, React.ReactNode> = {
sales: <path d="M4 17.5 9.5 12l3.5 3.5L20 8.5M15 8.5h5v5" />,
support: (
<>
<circle cx="12" cy="12" r="8" />
<circle cx="12" cy="12" r="3.4" />
<path d="m6.4 6.4 3.2 3.2M14.4 14.4l3.2 3.2M17.6 6.4l-3.2 3.2M9.6 14.4l-3.2 3.2" />
</>
),
press: (
<>
<path d="M5.5 5h10A1.5 1.5 0 0 1 17 6.5V18a1.5 1.5 0 0 0 1.5 1.5H6A2.5 2.5 0 0 1 3.5 17V7A2 2 0 0 1 5.5 5Z" />
<path d="M17 9.5h2A1.5 1.5 0 0 1 20.5 11v7a1.5 1.5 0 0 1-3 0M7 9h6M7 12.5h6M7 16h3.5" />
</>
),
other: (
<path d="M5 5.5h14a1.5 1.5 0 0 1 1.5 1.5v8.5A1.5 1.5 0 0 1 19 17h-8l-4.5 3.5V17H5a1.5 1.5 0 0 1-1.5-1.5V7A1.5 1.5 0 0 1 5 5.5Z" />
),
};
const CHANNEL_GLYPHS: Record<ParleyChannelKind, React.ReactNode> = {
email: (
<>
<rect x="3.5" y="5.5" width="17" height="13" rx="2" />
<path d="m4 7 8 6 8-6" />
</>
),
chat: (
<>
<path d="M4.5 6.5A2 2 0 0 1 6.5 4.5h11a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H11l-4 3.5v-3.5h-.5a2 2 0 0 1-2-2Z" />
<path d="M8.5 10h.01M12 10h.01M15.5 10h.01" strokeWidth="2.4" />
</>
),
phone: (
<path d="M6.5 3.8h2.6l1.4 4-2 1.3a10.5 10.5 0 0 0 6.4 6.4l1.3-2 4 1.4v2.6a2 2 0 0 1-2.2 2A16 16 0 0 1 4.5 6a2 2 0 0 1 2-2.2Z" />
),
other: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M3.5 12h17M12 3.5c2.4 2.4 3.5 5.2 3.5 8.5s-1.1 6.1-3.5 8.5c-2.4-2.4-3.5-5.2-3.5-8.5S9.6 5.9 12 3.5Z" />
</>
),
};
function Glyph({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<svg aria-hidden viewBox="0 0 24 24" className={cn("size-4 shrink-0", className)} {...stroke}>
{children}
</svg>
);
}
function Spinner() {
return (
<svg aria-hidden viewBox="0 0 24 24" className="size-4 motion-safe:animate-spin" fill="none">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
);
}
/* ---------------------------------------------------------------- auto height */
/** Animates its own height to match its content (field enter/leave, form ↔ success). */
function AutoHeight({ children, instant }: { children: React.ReactNode; instant: boolean }) {
const innerRef = React.useRef<HTMLDivElement>(null);
const [height, setHeight] = React.useState<number | "auto">("auto");
React.useEffect(() => {
const el = innerRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => setHeight(el.offsetHeight));
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<motion.div
initial={false}
animate={{ height }}
transition={instant ? INSTANT : SPRING}
className="overflow-hidden"
>
<div ref={innerRef} className="flow-root">
{children}
</div>
</motion.div>
);
}
/* ---------------------------------------------------------------- copy button */
function CopyButton({
text,
label,
reducedMotion,
onCopied,
}: {
text: string;
label: string;
reducedMotion: boolean;
onCopied: (label: string, ok: boolean) => void;
}) {
const [copied, setCopied] = React.useState(false);
const timer = React.useRef<number | undefined>(undefined);
React.useEffect(() => () => window.clearTimeout(timer.current), []);
const swap: Transition = reducedMotion ? { duration: 0.12 } : { duration: 0.22, ease: EASE };
return (
<button
type="button"
aria-label={copied ? `${label} copied` : `Copy ${label}`}
title={copied ? "Copied" : "Copy"}
onClick={async () => {
const ok = await writeClipboard(text);
onCopied(label, ok);
if (!ok) return;
setCopied(true);
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), 1800);
}}
className={cn(
"relative grid size-8 shrink-0 place-items-center rounded-lg border transition-colors duration-200",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--parley-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950",
copied
? "border-[color-mix(in_srgb,var(--parley-accent)_45%,transparent)] bg-[color-mix(in_srgb,var(--parley-accent)_14%,transparent)] text-[var(--parley-accent)]"
: "border-white/[0.08] bg-white/[0.03] text-white/55 hover:border-white/15 hover:text-white"
)}
>
<AnimatePresence initial={false} mode="popLayout">
{copied ? (
<motion.svg
key="check"
aria-hidden
viewBox="0 0 24 24"
className="size-4"
{...stroke}
strokeWidth={2.1}
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6, rotate: -20 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
transition={swap}
>
<motion.path
d="M5 12.5l4.5 4.5L19 7.5"
initial={reducedMotion ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={reducedMotion ? INSTANT : { duration: 0.32, ease: EASE, delay: 0.05 }}
/>
</motion.svg>
) : (
<motion.svg
key="copy"
aria-hidden
viewBox="0 0 24 24"
className="size-4"
{...stroke}
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6, rotate: 20 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
transition={swap}
>
<rect x="8.5" y="8.5" width="11" height="11" rx="2.2" />
<path d="M15.5 8.5V6.2a1.7 1.7 0 0 0-1.7-1.7H6.2a1.7 1.7 0 0 0-1.7 1.7v7.6a1.7 1.7 0 0 0 1.7 1.7h2.3" />
</motion.svg>
)}
</AnimatePresence>
</button>
);
}
/* ---------------------------------------------------------------- location plate */
/** Abstract street grid + pulsing pin. Pure SVG, no map library. */
function LocationPlate({ accent, reducedMotion, shown }: { accent: string; reducedMotion: boolean; shown: boolean }) {
const streets = "rgba(255,255,255,0.075)";
const major = "rgba(255,255,255,0.13)";
return (
<svg aria-hidden viewBox="0 0 320 132" preserveAspectRatio="xMidYMid slice" className="block h-full w-full">
<defs>
<radialGradient id="parley-plate-glow" cx="58%" cy="52%" r="50%">
<stop offset="0%" stopColor={accent} stopOpacity="0.16" />
<stop offset="100%" stopColor={accent} stopOpacity="0" />
</radialGradient>
<linearGradient id="parley-plate-fade" x1="0" x2="0" y1="0" y2="1">
<stop offset="0.55" stopColor="#0a0a0a" stopOpacity="0" />
<stop offset="1" stopColor="#0a0a0a" stopOpacity="0.75" />
</linearGradient>
</defs>
<rect width="320" height="132" fill="#0d0d0f" />
<rect width="320" height="132" fill="url(#parley-plate-glow)" />
{/* River */}
<path d="M-10 112 C 60 96, 92 128, 160 116 S 270 92, 330 104" fill="none" stroke={accent} strokeOpacity="0.13" strokeWidth="11" />
{/* Park block */}
<rect x="104" y="30" width="44" height="30" rx="3" fill={accent} fillOpacity="0.07" />
{/* Minor grid */}
<g stroke={streets} strokeWidth="1.2">
{[16, 44, 70, 98].map((y) => (
<line key={`h${y}`} x1="0" x2="320" y1={y} y2={y} />
))}
{[28, 64, 100, 152, 196, 236, 280].map((x) => (
<line key={`v${x}`} x1={x} x2={x} y1="0" y2="132" />
))}
</g>
{/* Arterials */}
<g stroke={major} strokeLinecap="round">
<line x1="0" x2="320" y1="70" y2="70" strokeWidth="2.6" />
<line x1="152" x2="152" y1="0" y2="132" strokeWidth="2.6" />
<line x1="-6" x2="326" y1="126" y2="-6" strokeWidth="4" />
</g>
<rect width="320" height="132" fill="url(#parley-plate-fade)" />
{/* Pin */}
<g transform="translate(188 64)">
{!reducedMotion ? (
<>
<circle r="5" fill={accent} className="parley-ping" />
<circle r="5" fill={accent} className="parley-ping [animation-delay:1.1s]" />
</>
) : null}
<ellipse cx="0" cy="1" rx="5" ry="2" fill="#000" opacity="0.5" />
<motion.g
initial={false}
animate={shown ? { y: 0, opacity: 1 } : { y: -14, opacity: 0 }}
transition={reducedMotion ? INSTANT : { type: "spring", duration: 0.6, bounce: 0, delay: 0.55 }}
>
<path
d="M0 0 C -1.2 -4, -8 -8.5, -8 -15 a 8 8 0 0 1 16 0 C 8 -8.5, 1.2 -4, 0 0 Z"
fill={accent}
stroke="rgba(0,0,0,0.35)"
strokeWidth="0.8"
/>
<circle cy="-15" r="3" fill={readableOn(accent)} fillOpacity="0.85" />
</motion.g>
</g>
</svg>
);
}
/* ---------------------------------------------------------------- field */
interface FieldProps {
field: ParleyField;
id: string;
value: string;
error?: string;
shake: number;
reducedMotion: boolean;
registerFocus: (name: string, el: HTMLElement | null) => void;
onChange: (name: string, value: string) => void;
onBlur: (name: string) => void;
}
const inputBase =
"w-full rounded-xl border bg-white/[0.03] px-3.5 text-sm text-white placeholder:text-white/30 transition-[border-color,background-color,box-shadow] duration-200 focus:outline-none focus:bg-white/[0.05] focus:border-[var(--parley-accent)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--parley-accent)_18%,transparent)]";
function Field({ field, id, value, error, shake, reducedMotion, registerFocus, onChange, onBlur }: FieldProps) {
const [scope, animate] = useAnimate<HTMLDivElement>();
const errorId = `${id}-error`;
const hintId = `${id}-hint`;
const type = field.type ?? "text";
const describedBy = [error ? errorId : null, field.hint ? hintId : null].filter(Boolean).join(" ") || undefined;
const borderTone = error ? "border-red-400/55" : "border-white/[0.09] hover:border-white/[0.16]";
React.useEffect(() => {
if (!shake || reducedMotion || !scope.current) return;
animate(scope.current, { x: [0, -7, 6, -4, 3, -1, 0] }, { duration: 0.42, ease: "easeOut" });
}, [shake, reducedMotion, animate, scope]);
const options = normOptions(field.options);
const segRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const selectedIndex = Math.max(
0,
options.findIndex((o) => o.value === value)
);
let control: React.ReactNode;
if (type === "textarea") {
control = (
<textarea
ref={(el) => registerFocus(field.name, el)}
id={id}
name={field.name}
rows={field.rows ?? 4}
placeholder={field.placeholder}
value={value}
onChange={(e) => onChange(field.name, e.target.value)}
onBlur={() => onBlur(field.name)}
aria-invalid={error ? true : undefined}
aria-required={field.required || undefined}
aria-describedby={describedBy}
className={cn(inputBase, borderTone, "block min-h-24 resize-y py-2.5 leading-relaxed")}
/>
);
} else if (type === "select") {
control = (
<div className="relative">
<select
ref={(el) => registerFocus(field.name, el)}
id={id}
name={field.name}
value={value}
onChange={(e) => onChange(field.name, e.target.value)}
onBlur={() => onBlur(field.name)}
aria-invalid={error ? true : undefined}
aria-required={field.required || undefined}
aria-describedby={describedBy}
className={cn(inputBase, borderTone, "h-11 cursor-pointer appearance-none pr-9", !value && "text-white/40")}
>
{options.map((o) => (
<option key={o.value} value={o.value} disabled={o.value === "" && field.required} className="bg-neutral-900 text-white">
{o.label}
</option>
))}
</select>
<svg
aria-hidden
viewBox="0 0 24 24"
className="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-white/40"
{...stroke}
>
<path d="m7 10 5 5 5-5" />
</svg>
</div>
);
} else if (type === "segmented") {
const move = (to: number) => {
const i = (to + options.length) % options.length;
onChange(field.name, options[i].value);
segRefs.current[i]?.focus();
};
control = (
<div
role="radiogroup"
aria-labelledby={`${id}-label`}
aria-describedby={describedBy}
className="grid gap-1 rounded-xl border border-white/[0.09] bg-white/[0.03] p-1"
style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
>
{options.map((o, i) => {
const checked = i === selectedIndex;
return (
<button
key={o.value}
ref={(el) => {
segRefs.current[i] = el;
if (checked) registerFocus(field.name, el);
}}
type="button"
role="radio"
aria-checked={checked}
tabIndex={checked ? 0 : -1}
onClick={() => onChange(field.name, o.value)}
onBlur={() => onBlur(field.name)}
onKeyDown={(e) => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
move(i + 1);
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
move(i - 1);
} else if (e.key === "Home") {
e.preventDefault();
move(0);
} else if (e.key === "End") {
e.preventDefault();
move(options.length - 1);
}
}}
className={cn(
"relative isolate flex h-9 min-w-0 items-center justify-center gap-1.5 rounded-lg px-1 text-[13px] font-medium transition-colors duration-200",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--parley-accent)]",
checked ? "text-white" : "text-white/50 hover:text-white/80"
)}
>
{checked ? (
<motion.span
layoutId={`${id}-seg`}
transition={reducedMotion ? INSTANT : SPRING}
className="absolute inset-0 -z-10 rounded-lg border border-white/[0.1] bg-white/[0.08]"
/>
) : null}
{o.value === "urgent" || o.value === "high" ? (
<span
aria-hidden
className={cn("size-1.5 shrink-0 rounded-full", o.value === "urgent" ? "bg-rose-400" : "bg-amber-300")}
/>
) : null}
<span className="truncate">{o.label}</span>
</button>
);
})}
</div>
);
} else {
control = (
<input
ref={(el) => registerFocus(field.name, el)}
id={id}
name={field.name}
type={type === "number" ? "text" : type}
inputMode={type === "number" ? "numeric" : type === "email" ? "email" : type === "tel" ? "tel" : undefined}
autoComplete={field.autoComplete ?? "off"}
placeholder={field.placeholder}
value={value}
onChange={(e) => onChange(field.name, e.target.value)}
onBlur={() => onBlur(field.name)}
aria-invalid={error ? true : undefined}
aria-required={field.required || undefined}
aria-describedby={describedBy}
className={cn(inputBase, borderTone, "h-11", type === "number" && "tabular-nums")}
/>
);
}
return (
<div ref={scope} className="flex flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-3">
{type === "segmented" ? (
<span id={`${id}-label`} className="text-[13px] font-medium text-white/75">
{field.label}
</span>
) : (
<label id={`${id}-label`} htmlFor={id} className="text-[13px] font-medium text-white/75">
{field.label}
</label>
)}
{!field.required && type !== "segmented" && type !== "select" ? (
<span className="text-[11px] text-white/30">Optional</span>
) : null}
</div>
{control}
<AnimatePresence initial={false}>
{error ? (
<motion.p
key="err"
id={errorId}
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
className="flex items-center gap-1.5 text-xs text-red-300"
>
<svg aria-hidden viewBox="0 0 24 24" className="size-3.5 shrink-0" {...stroke} strokeWidth={2}>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.8v5M12 16.2h.01" />
</svg>
{error}
</motion.p>
) : field.hint ? (
<p id={hintId} className="text-xs text-white/35">
{field.hint}
</p>
) : null}
</AnimatePresence>
</div>
);
}
/* ---------------------------------------------------------------- main */
type Phase = "idle" | "pending" | "error" | "success";
/**
* Parley — a contact section that routes before it asks. A radiogroup of
* topic chips sits atop the form; picking one reshapes it — fields glide in
* and out with layout animation, values carry across topics by field name,
* and the submit label and expected-reply line change with the route.
* Validation runs on blur with a short shake and inline messages; an async
* `onSubmit` drives pending → success (a drawn check with "Send another") or
* an inline error that keeps every value. A side column holds channel cards
* with copy-to-clipboard (the icon morphs to a check) and an office card with
* an abstract SVG street plate and pulsing pin. Reduced motion: crossfades
* only, no shake, no pulse.
*/
export function Parley({
eyebrow = "Contact",
title = "Talk to the right person",
subtitle = "Pick a topic and we'll route you straight to the team that can help — no ticket mazes, no bots.",
topics = DEFAULT_TOPICS,
defaultTopic,
topic: topicProp,
onTopicChange,
onSubmit,
channels = DEFAULT_CHANNELS,
office = DEFAULT_OFFICE,
accentColor = DEFAULT_ACCENT,
ref,
className,
style,
...props
}: ParleyProps) {
const reducedMotion = useReducedMotion();
const uid = React.useId().replace(/[:]/g, "");
const rootRef = React.useRef<HTMLElement>(null);
const successRef = React.useRef<HTMLHeadingElement>(null);
const focusMap = React.useRef(new Map<string, HTMLElement>());
const chipRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const fallbackTopic = defaultTopic ?? topics[0]?.id ?? "";
const [innerTopic, setInnerTopic] = React.useState(fallbackTopic);
const topicId = topicProp ?? innerTopic;
const current = topics.find((t) => t.id === topicId) ?? topics[0];
const fields = React.useMemo(() => current?.fields ?? [], [current]);
const seedValues = React.useCallback(() => {
const v: Record<string, string> = {};
for (const t of topics) for (const f of t.fields) if (!(f.name in v)) v[f.name] = initialValue(f);
return v;
}, [topics]);
const [values, setValues] = React.useState<Record<string, string>>(seedValues);
const [touched, setTouched] = React.useState<Record<string, boolean>>({});
const [shakes, setShakes] = React.useState<Record<string, number>>({});
const [phase, setPhase] = React.useState<Phase>("idle");
const [submitError, setSubmitError] = React.useState<string | null>(null);
const [announce, setAnnounce] = React.useState("");
const [sentTopic, setSentTopic] = React.useState<ParleyTopic | null>(null);
const pendingRef = React.useRef(false);
const focusAfter = React.useRef<"success" | "first" | null>(null);
// Designed intro: once, the first time the section is in view.
const [seen, setSeen] = React.useState(false);
const [run, setRun] = React.useState(0);
React.useEffect(() => {
const el = rootRef.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
setSeen(true);
io.disconnect();
}
},
{ threshold: 0.15 }
);
io.observe(el);
return () => io.disconnect();
}, []);
const shown = reducedMotion || seen;
const accentInk = readableOn(accentColor);
const pending = phase === "pending";
const errors = React.useMemo(() => {
const out: Record<string, string | undefined> = {};
for (const f of fields) if (touched[f.name]) out[f.name] = validateField(f, values[f.name], values);
return out;
}, [fields, touched, values]);
const selectTopic = React.useCallback(
(id: string) => {
if (id === topicId) return;
const next = topics.find((t) => t.id === id);
if (!next) return;
if (topicProp == null) setInnerTopic(id);
onTopicChange?.(id);
setSubmitError(null);
if (phase === "error") setPhase("idle");
const added = next.fields.filter((f) => !fields.some((g) => g.name === f.name)).length;
setAnnounce(`${next.label} form${added ? `, ${added} new field${added === 1 ? "" : "s"}` : ""}. ${next.replyLine ?? ""}`);
},
[topicId, topics, topicProp, onTopicChange, phase, fields]
);
const registerFocus = React.useCallback((name: string, el: HTMLElement | null) => {
if (el) focusMap.current.set(name, el);
}, []);
const focusFirst = React.useCallback(() => {
const first = fields[0];
if (first) focusMap.current.get(first.name)?.focus({ preventScroll: false });
}, [fields]);
const handleChange = React.useCallback((name: string, value: string) => {
setValues((v) => ({ ...v, [name]: value }));
}, []);
const handleBlur = React.useCallback(
(name: string) => {
const f = fields.find((x) => x.name === name);
if (!f) return;
// Don't flag untouched empty fields just for tabbing through — submit catches those.
const value = values[name] ?? "";
if (!touched[name] && !value.trim()) return;
setTouched((t) => ({ ...t, [name]: true }));
const err = validateField(f, value, values);
// Shake when a new problem appears, not on every blur of an already-flagged field.
if (err && err !== errors[name]) setShakes((s) => ({ ...s, [name]: (s[name] ?? 0) + 1 }));
},
[fields, values, touched, errors]
);
const reset = React.useCallback(() => {
pendingRef.current = false;
setValues(seedValues());
setTouched({});
setShakes({});
setSubmitError(null);
setPhase("idle");
setSentTopic(null);
if (topicProp == null) setInnerTopic(fallbackTopic);
else if (topicProp !== fallbackTopic) onTopicChange?.(fallbackTopic);
}, [seedValues, topicProp, fallbackTopic, onTopicChange]);
const sendAnother = () => {
// Keep who they are; clear what they said.
const seeded = seedValues();
setValues((v) => ({ ...seeded, name: v.name ?? "", email: v.email ?? "" }));
setTouched({});
setSubmitError(null);
setPhase("idle");
focusAfter.current = "first";
};
const replay = React.useCallback(() => {
setSeen(true);
setRun((r) => r + 1);
}, []);
React.useImperativeHandle(ref, () => ({ reset, replay, setTopic: selectTopic, focus: focusFirst }), [
reset,
replay,
selectTopic,
focusFirst,
]);
React.useEffect(() => {
if (focusAfter.current === "success" && phase === "success") {
successRef.current?.focus({ preventScroll: true });
focusAfter.current = null;
}
}, [phase]);
// "Send another": the form mounts only after the success card exits, so focus from its ref.
const formRef = React.useCallback(
(el: HTMLFormElement | null) => {
if (!el || focusAfter.current !== "first") return;
focusAfter.current = null;
requestAnimationFrame(() => focusFirst());
},
[focusFirst]
);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (pendingRef.current || !current) return;
const invalid = fields.filter((f) => validateField(f, values[f.name], values));
if (invalid.length) {
setTouched((t) => {
const next = { ...t };
for (const f of fields) next[f.name] = true;
return next;
});
setShakes((s) => {
const next = { ...s };
for (const f of invalid) next[f.name] = (next[f.name] ?? 0) + 1;
return next;
});
setAnnounce(`${invalid.length} field${invalid.length === 1 ? " needs" : "s need"} attention.`);
focusMap.current.get(invalid[0].name)?.focus();
return;
}
const payload: Record<string, string> = {};
for (const f of fields) payload[f.name] = (values[f.name] ?? "").trim();
pendingRef.current = true;
setSubmitError(null);
setPhase("pending");
setAnnounce("Sending…");
try {
await onSubmit?.(payload, current.id);
pendingRef.current = false;
setSentTopic(current);
focusAfter.current = "success";
setPhase("success");
setAnnounce(`Message sent. ${current.replyLine ?? ""}`);
} catch (err) {
pendingRef.current = false;
const msg = err instanceof Error && err.message ? err.message : "Something went wrong. Your message is still here — try again.";
setSubmitError(msg);
setPhase("error");
setAnnounce("");
}
};
const onCopied = React.useCallback((label: string, ok: boolean) => {
setAnnounce(ok ? `${label} copied to clipboard.` : `Couldn't copy ${label}.`);
}, []);
/* ---- chip radiogroup keyboard ---- */
const topicIndex = Math.max(0, topics.findIndex((t) => t.id === current?.id));
const moveChip = (to: number) => {
const i = (to + topics.length) % topics.length;
selectTopic(topics[i].id);
chipRefs.current[i]?.focus();
};
/* ---- intro choreography ---- */
const enter = (delay: number) =>
reducedMotion
? { initial: false as const, animate: { opacity: 1, y: 0 } }
: {
initial: { opacity: 0, y: 14 },
animate: shown ? { opacity: 1, y: 0 } : { opacity: 0, y: 14 },
transition: { duration: 0.55, ease: EASE, delay },
};
const fieldT: Transition = reducedMotion ? { duration: 0.15 } : SPRING;
const focusRing =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--parley-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950";
const hasSide = channels.length > 0 || office !== false;
const officeCopy = office ? office.copyValue ?? office.lines.join(", ") : "";
const firstName = (values.name ?? "").trim().split(/\s+/)[0];
return (
<section
ref={rootRef}
data-crucible="parley"
aria-labelledby={`${uid}-title`}
className={cn("@container relative isolate w-full text-white", className)}
style={{ ["--parley-accent" as string]: accentColor, ...style }}
{...props}
>
<style>{`@keyframes parley-ping{0%{transform:scale(1);opacity:.55}100%{transform:scale(4.2);opacity:0}}.parley-ping{transform-box:fill-box;transform-origin:center;animation:parley-ping 2.2s cubic-bezier(0,0,.2,1) infinite}`}</style>
<div key={run} className="mx-auto w-full max-w-6xl px-4 py-10 @xl:px-8 @xl:py-14">
{/* Header */}
<motion.header {...enter(0)} className="max-w-xl">
<p className="inline-flex items-center gap-2 text-xs font-medium tracking-[0.16em] text-[var(--parley-accent)] uppercase">
<span aria-hidden className="h-px w-5 bg-[var(--parley-accent)] opacity-70" />
{eyebrow}
</p>
<h2 id={`${uid}-title`} className="mt-3 text-3xl font-semibold tracking-tight text-balance text-white @xl:text-4xl">
{title}
</h2>
{subtitle ? <p className="mt-3 max-w-lg text-[15px] leading-relaxed text-pretty text-white/50">{subtitle}</p> : null}
</motion.header>
<div className={cn("mt-10 grid gap-5", hasSide && "@3xl:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)] @3xl:gap-6")}>
{/* Form card */}
<motion.div
{...enter(0.08)}
className="@container/form relative rounded-2xl border border-white/[0.08] bg-white/[0.025] shadow-[0_1px_0_0_rgba(255,255,255,0.04)_inset,0_24px_60px_-30px_rgba(0,0,0,0.8)]"
>
{/* top hairline glow */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-8 top-0 h-px"
style={{ background: `linear-gradient(90deg, transparent, color-mix(in srgb, ${accentColor} 55%, transparent), transparent)` }}
/>
<AutoHeight instant={reducedMotion}>
<AnimatePresence initial={false} mode="wait">
{phase === "success" ? (
<motion.div
key="success"
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
transition={reducedMotion ? { duration: 0.15 } : { duration: 0.35, ease: EASE }}
className="flex min-h-[420px] flex-col items-center justify-center px-6 py-14 text-center"
>
<svg aria-hidden viewBox="0 0 64 64" className="size-16" fill="none">
<circle cx="32" cy="32" r="30" fill={accentColor} fillOpacity="0.08" />
<motion.circle
cx="32"
cy="32"
r="27"
stroke={accentColor}
strokeWidth="2"
strokeLinecap="round"
transform="rotate(-90 32 32)"
initial={reducedMotion ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={reducedMotion ? INSTANT : { duration: 0.55, ease: EASE }}
/>
<motion.path
d="M21 33l7.5 7.5L44 25"
stroke={accentColor}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
initial={reducedMotion ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={reducedMotion ? INSTANT : { duration: 0.35, ease: EASE, delay: 0.38 }}
/>
</svg>
<h3
ref={successRef}
tabIndex={-1}
className="mt-6 text-xl font-semibold tracking-tight text-white focus:outline-none"
>
{firstName ? `Thanks, ${firstName} — message sent` : "Message sent"}
</h3>
<p className="mt-2 max-w-xs text-sm leading-relaxed text-white/50">
{sentTopic?.successMessage ??
`It's with our ${sentTopic?.label.toLowerCase() ?? ""} team. ${sentTopic?.replyLine ? `${sentTopic.replyLine}.` : ""}`}
</p>
{values.email ? (
<p className="mt-4 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1 text-xs text-white/55">
Reply goes to <span className="text-white/80">{values.email}</span>
</p>
) : null}
<button
type="button"
onClick={sendAnother}
className={cn(
"mt-7 inline-flex items-center gap-1.5 rounded-md text-sm font-medium text-[var(--parley-accent)] underline-offset-4 hover:underline",
focusRing
)}
>
Send another
<Glyph className="size-3.5">
<path d="M5 12h14m-5-5 5 5-5 5" />
</Glyph>
</button>
</motion.div>
) : (
<motion.form
key="form"
ref={formRef}
noValidate
onSubmit={handleSubmit}
aria-labelledby={`${uid}-title`}
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={reducedMotion ? { duration: 0.15 } : { duration: 0.3, ease: EASE }}
className="p-4 @md:p-6"
>
{/* Topic chips */}
<div id={`${uid}-topic-label`} className="mb-2.5 text-[13px] font-medium text-white/75">
What's this about?
</div>
<div
role="radiogroup"
aria-labelledby={`${uid}-topic-label`}
className="grid grid-cols-2 gap-1 rounded-xl border border-white/[0.08] bg-black/30 p-1 @sm/form:grid-cols-4"
style={topics.length !== 4 ? { gridTemplateColumns: `repeat(${Math.min(topics.length, 4)}, minmax(0, 1fr))` } : undefined}
>
{topics.map((t, i) => {
const checked = i === topicIndex;
const icon =
t.icon != null && typeof t.icon !== "string" ? (
t.icon
) : (
<Glyph>{TOPIC_GLYPHS[(t.icon as ParleyTopicGlyph) ?? "other"] ?? TOPIC_GLYPHS.other}</Glyph>
);
return (
<button
key={t.id}
ref={(el) => {
chipRefs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={checked}
tabIndex={checked ? 0 : -1}
disabled={pending}
onClick={() => selectTopic(t.id)}
onKeyDown={(e) => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
moveChip(i + 1);
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
moveChip(i - 1);
} else if (e.key === "Home") {
e.preventDefault();
moveChip(0);
} else if (e.key === "End") {
e.preventDefault();
moveChip(topics.length - 1);
}
}}
className={cn(
"relative isolate flex h-10 min-w-0 items-center justify-center gap-2 rounded-lg px-2 text-sm font-medium transition-colors duration-200 disabled:cursor-not-allowed",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--parley-accent)]",
checked ? "" : "text-white/50 hover:bg-white/[0.04] hover:text-white/85"
)}
style={checked ? { color: accentInk } : undefined}
>
{checked ? (
<motion.span
layoutId={`${uid}-chip`}
transition={reducedMotion ? INSTANT : SPRING}
className="absolute inset-0 -z-10 rounded-lg"
style={{
backgroundColor: accentColor,
boxShadow: `0 6px 20px -8px ${accentColor}, inset 0 1px 0 rgba(255,255,255,0.25)`,
}}
/>
) : null}
{icon}
<span className="truncate">{t.label}</span>
</button>
);
})}
</div>
{/* Fields */}
<fieldset disabled={pending} className="relative mt-6 grid min-w-0 grid-cols-1 gap-x-4 gap-y-5 @sm/form:grid-cols-2">
<legend className="sr-only">{current?.label} details</legend>
<AnimatePresence initial={false} mode="popLayout">
{fields.map((f) => (
<motion.div
key={f.name}
layout={reducedMotion ? false : "position"}
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 10, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.97, filter: "blur(4px)" }}
transition={fieldT}
className={cn("min-w-0", f.half ? "@sm/form:col-span-1" : "@sm/form:col-span-2")}
>
<Field
field={f}
id={`${uid}-${f.name}`}
value={values[f.name] ?? ""}
error={errors[f.name]}
shake={shakes[f.name] ?? 0}
reducedMotion={reducedMotion}
registerFocus={registerFocus}
onChange={handleChange}
onBlur={handleBlur}
/>
</motion.div>
))}
</AnimatePresence>
</fieldset>
{/* Submit error */}
<AnimatePresence initial={false}>
{phase === "error" && submitError ? (
<motion.div
key="submit-error"
role="alert"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="mt-5 flex items-start gap-2.5 rounded-xl border border-red-400/25 bg-red-500/[0.07] px-3.5 py-3 text-sm text-red-200"
>
<Glyph className="mt-0.5 text-red-300">
<path d="M12 4 21 19.5H3Z" />
<path d="M12 10v4M12 17h.01" />
</Glyph>
<span>{submitError}</span>
</motion.div>
) : null}
</AnimatePresence>
{/* Footer: reply line + submit */}
<div className="mt-6 flex flex-col-reverse gap-4 border-t border-white/[0.07] pt-5 @sm/form:flex-row @sm/form:items-center @sm/form:justify-between">
<p className="flex items-center gap-2 text-xs text-white/45">
<span aria-hidden className="relative flex size-2">
{!reducedMotion ? (
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50 [animation-duration:2.4s]" />
) : null}
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
</span>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={current?.replyLine ?? "none"}
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -6 }}
transition={{ duration: 0.25, ease: EASE }}
className="tabular-nums"
>
{current?.replyLine ?? "We read every message"}
</motion.span>
</AnimatePresence>
</p>
<motion.button
type="submit"
layout={reducedMotion ? false : true}
transition={reducedMotion ? INSTANT : SPRING}
aria-disabled={pending || undefined}
aria-busy={pending || undefined}
className={cn(
"inline-flex h-11 shrink-0 items-center justify-center gap-2 overflow-hidden rounded-xl px-5 text-sm font-semibold whitespace-nowrap transition-[filter] hover:brightness-110 active:brightness-95",
focusRing
)}
style={{
backgroundColor: accentColor,
color: accentInk,
boxShadow: `0 10px 30px -12px ${accentColor}, inset 0 1px 0 rgba(255,255,255,0.3)`,
}}
>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={pending ? "pending" : current?.submitLabel ?? "Send message"}
layout={reducedMotion ? false : "position"}
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -10 }}
transition={{ duration: 0.22, ease: EASE }}
className="inline-flex items-center gap-2"
>
{pending ? <Spinner /> : null}
{pending ? "Sending…" : current?.submitLabel ?? "Send message"}
{!pending ? (
<Glyph className="size-3.5">
<path d="M5 12h14m-5-5 5 5-5 5" />
</Glyph>
) : null}
</motion.span>
</AnimatePresence>
</motion.button>
</div>
</motion.form>
)}
</AnimatePresence>
</AutoHeight>
</motion.div>
{/* Side column */}
{hasSide ? (
<div className="grid content-start gap-3 @xl:grid-cols-2 @3xl:grid-cols-1">
{channels.map((c, i) => {
const kind = c.kind ?? "other";
const href = c.href ?? (kind === "email" ? `mailto:${c.value}` : kind === "phone" ? `tel:${c.value.replace(/[^\d+]/g, "")}` : undefined);
return (
<motion.div
key={`${c.label}-${c.value}`}
{...enter(0.16 + i * 0.06)}
className="group flex items-center gap-3.5 rounded-2xl @xl:col-start-1 @3xl:col-start-auto border border-white/[0.07] bg-white/[0.02] p-3.5 transition-colors duration-200 hover:border-white/[0.12] hover:bg-white/[0.035]"
>
<span
aria-hidden
className="grid size-10 shrink-0 place-items-center rounded-xl border border-white/[0.08] bg-white/[0.03] text-white/70 transition-colors duration-200 group-hover:text-[var(--parley-accent)]"
>
<Glyph className="size-[18px]">{CHANNEL_GLYPHS[kind]}</Glyph>
</span>
<div className="min-w-0 flex-1">
<p className="text-[13px] font-medium text-white/85">{c.label}</p>
{href ? (
<a
href={href}
className={cn(
"block truncate rounded-sm text-sm text-white/55 tabular-nums transition-colors hover:text-[var(--parley-accent)]",
focusRing
)}
>
{c.value}
</a>
) : (
<p className="truncate text-sm text-white/55 tabular-nums">{c.value}</p>
)}
{c.detail ? <p className="mt-0.5 text-xs leading-snug text-pretty text-white/30">{c.detail}</p> : null}
</div>
<CopyButton
text={c.copyValue ?? c.value}
label={c.label}
reducedMotion={reducedMotion}
onCopied={onCopied}
/>
</motion.div>
);
})}
{office ? (
<motion.div
{...enter(0.16 + channels.length * 0.06)}
className="flex flex-col overflow-hidden rounded-2xl border border-white/[0.07] bg-white/[0.02] @xl:col-start-2 @xl:row-start-1 @xl:row-span-3 @3xl:col-start-auto @3xl:row-start-auto @3xl:row-span-1"
>
<div className="relative h-28 flex-1 border-b border-white/[0.06] @3xl:flex-none">
<LocationPlate accent={accentColor} reducedMotion={reducedMotion} shown={shown} />
</div>
<div className="flex items-start gap-3.5 p-3.5">
<div className="min-w-0 flex-1">
<p className="text-[13px] font-medium text-white/85">{office.label ?? "Visit the studio"}</p>
<address className="mt-0.5 text-sm leading-snug text-white/55 not-italic">
{office.lines.map((l) => (
<span key={l} className="block">
{l}
</span>
))}
</address>
{office.detail ? <p className="mt-1 text-xs text-white/30">{office.detail}</p> : null}
{office.href ? (
<a
href={office.href}
className={cn(
"mt-2 inline-flex items-center gap-1 rounded-sm text-xs font-medium text-[var(--parley-accent)] underline-offset-4 hover:underline",
focusRing
)}
>
Get directions
<Glyph className="size-3">
<path d="M7 17 17 7M9 7h8v8" />
</Glyph>
</a>
) : null}
</div>
<CopyButton
text={officeCopy}
label={office.label ? `${office.label} address` : "Address"}
reducedMotion={reducedMotion}
onCopied={onCopied}
/>
</div>
</motion.div>
) : null}
</div>
) : null}
</div>
</div>
<p role="status" aria-live="polite" className="sr-only">
{announce}
</p>
</section>
);
}
Installation
CLI
npx shadcn@latest add @crucible/parleyManual — install dependencies, then copy the source
npm install motionProps
| Prop | Type | Default | Description |
|---|---|---|---|
| eyebrow | React.ReactNode | "Contact" | Small label above the title. |
| title | React.ReactNode | "Talk to the right person" | Section heading. |
| subtitle | React.ReactNode | Line under the heading. | |
| topics | ParleyTopic[] | Topic chips and the form each one routes to. Omit for Sales / Support / Press / Other. | |
| defaultTopic | string | first topic | Uncontrolled initial topic id. |
| topic | string | Controlled topic id. | |
| onTopicChange | (id: string) => void | Called when the visitor picks a topic. | |
| onSubmit | (values: Record<string, string>, topic: string) => void | Promise<unknown> | Called with the current topic's values. Return a Promise to hold the button in its pending state — resolve shows the success card, reject keeps every value and shows the rejection's message inline. | |
| channels | ParleyChannel[] | Side-column channel cards with copy-to-clipboard. Omit for a sample set; [] hides them. | |
| office | ParleyOffice | false | Office card with an abstract location plate, or false to hide it. | |
| accentColor | string | "#60a5fa" | Accent hex for the active chip, submit button, focus rings and pin. |
| ref | React.Ref<ParleyHandle> | Imperative handle (reset(), replay(), setTopic(), focus()). React 19 ref-as-prop. | |
| className | string | Extra classes for the <section> root. | |
Also accepts all props of Omit<React.ComponentPropsWithoutRef<"section">, "children" | "title" | "onSubmit"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.