Hot Fire Request Form
A booking form in hi-vis: underlined fields that light orange, chip choices that fill solid, inline errors that name the fix, and a sent state that replays the request.
DOM + CSS0 deps1 file · 12 KB TSXreduced-motion aware
Props
"use client";
import { useId, useState } from "react";
import type { CSSProperties, FormEvent } from "react";
/** Safety orange as a field colour. Black on it reads at 6.3:1. */
const HV_ORANGE = "#FF5B14";
/** The hot highlight, for orange marks on black. */
const HV_HOT = "#FF7A1A";
const HV_BLACK = "#0B0A09";
const HV_BONE = "#EFEBE4";
/**
* The display face: Archivo's width axis does the shouting (wdth 62..125).
* Load it as a variable font with the width axis and expose it as --font-hv,
* e.g. in Next: Archivo({ subsets: ["latin"], axes: ["wdth"], variable: "--font-hv" }).
*/
const HV_DISPLAY = 'var(--font-hv, "Archivo"), "Archivo", "Arial Narrow", ui-sans-serif, system-ui, sans-serif';
/** Labels and readouts: Geist Mono as --font-hv-mono (any monospace works). */
const HV_MONO = 'var(--font-hv-mono, "Geist Mono"), "Geist Mono", ui-monospace, "SFMono-Regular", Menlo, monospace';
/**
* The kit's scoped CSS: one focus ring (2px, offset 3px, in the ink of the
* ground it sits on), tabular figures, the press, and transitions off for
* anything a frame loop writes.
*/
const HV_CSS =
".hv-scope{--hv-ring:" + HV_HOT + "}" +
".hv-scope[data-hv-ground=orange],.hv-scope [data-hv-ground=orange]{--hv-ring:" + HV_BLACK + "}" +
".hv-scope[data-hv-ground=bone],.hv-scope [data-hv-ground=bone]{--hv-ring:" + HV_BLACK + "}" +
".hv-scope :focus-visible{outline:2px solid var(--hv-ring)!important;outline-offset:3px!important}" +
".hv-scope .hv-num{font-variant-numeric:tabular-nums}" +
".hv-scope [data-hv-live]{transition:none!important}" +
".hv-scope .hv-press{transition:transform 160ms cubic-bezier(.2,.8,.2,1),background-color 160ms,color 160ms}" +
".hv-scope .hv-press:active{transform:scale(.97)}" +
".hv-scope .hv-arr{transition:transform 220ms cubic-bezier(.2,.8,.2,1)}.hv-scope a:hover .hv-arr,.hv-scope button:hover .hv-arr{transform:translateX(3px)}" +
"@media (prefers-reduced-motion:reduce){.hv-scope .hv-press,.hv-scope .hv-arr{transition:none}}";
/** The kit's arrow: slides on hover. */
function HvArrow({ size = 14 }: { size?: number }) {
return (
<svg className="hv-arr" width={size} height={size} viewBox="0 0 14 14" aria-hidden>
<path d="M2 7h9M7.5 3.5L11 7l-3.5 3.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="square" />
</svg>
);
}
/** Ardent's mark: a shock diamond, the bright knot in a rocket plume. Takes the current colour. */
function HvMark({ size = 22 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden>
<path d="M12 1.5 L19 12 L12 22.5 L5 12 Z" fill="currentColor" />
<path d="M12 7.2 L15.2 12 L12 16.8 L8.8 12 Z" fill="var(--hv-mark-cut, #0B0A09)" />
</svg>
);
}
export interface HotFireRequest {
company: string;
email: string;
thrust: string;
burn: string;
notes: string;
}
const RF_CSS =
".hv-rf-field{position:relative;display:block}" +
".hv-rf-field input,.hv-rf-field textarea{width:100%;border:0;border-radius:0;background:transparent;color:" + HV_BONE + ";font:inherit;font-size:20px;font-weight:600;padding:26px 0 10px;outline:none;box-shadow:inset 0 -2px 0 rgba(239,235,228,.35);transition:box-shadow 200ms}" +
".hv-rf-field textarea{resize:vertical;min-height:96px;font-size:17px;font-weight:500;line-height:1.45}" +
".hv-rf-field input:focus,.hv-rf-field textarea:focus{box-shadow:inset 0 -3px 0 " + HV_ORANGE + "}" +
".hv-rf-field[data-bad] input{box-shadow:inset 0 -3px 0 " + HV_HOT + "}" +
".hv-rf-field>span{position:absolute;left:0;top:0;font-size:11.5px;text-transform:uppercase;letter-spacing:.14em;opacity:.72}" +
".hv-rf-scope .hv-rf-field input:focus-visible,.hv-rf-scope .hv-rf-field textarea:focus-visible{outline:none!important}" +
".hv-rf-chip{position:relative;display:inline-flex;align-items:center;height:40px;padding:0 14px;cursor:pointer;font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;box-shadow:inset 0 0 0 1.5px rgba(239,235,228,.5);transition:background-color 160ms,color 160ms,box-shadow 160ms}" +
".hv-rf-chip:hover{box-shadow:inset 0 0 0 1.5px " + HV_BONE + "}" +
".hv-rf-chip:has(input:checked){background:" + HV_ORANGE + ";color:" + HV_BLACK + ";box-shadow:none}" +
".hv-rf-chip:has(input:focus-visible){outline:2px solid " + HV_HOT + ";outline-offset:3px}" +
".hv-rf-chip input{position:absolute;opacity:0;width:1px;height:1px}" +
"@media (prefers-reduced-motion:reduce){.hv-rf-field input,.hv-rf-field textarea,.hv-rf-chip{transition:none}}";
/** A plain, linear-time email check (one @, a dot in the domain, no spaces). */
const RF_EMAIL = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/;
export interface HotFireRequestFormProps {
eyebrow?: string;
headline?: string;
sub?: string;
thrustClasses?: string[];
burns?: string[];
/** Where the request goes. Resolve when it has been received; throw (or reject) to show the error line. */
onSubmit: (request: HotFireRequest) => Promise<void> | void;
/** What the form says once onSubmit has resolved. */
sentText?: string;
className?: string;
style?: CSSProperties;
}
/**
* HotFireRequestForm — book time on the test stand.
*/
export function HotFireRequestForm({
eyebrow = "Test your own stage",
headline = "Book a hot fire.",
sub = "Bring an upper stage or a single engine. We fire it on stand B, horizontal, and send you the data the same day.",
thrustClasses = ["Under 100 kN", "100–500 kN", "500 kN +"],
burns = ["30 s", "120 s", "240 s", "Full duration"],
onSubmit,
sentText = "Request received. We reply within two working days with a stand date.",
className = "",
style,
}: HotFireRequestFormProps) {
const [form, setForm] = useState<HotFireRequest>({ company: "", email: "", thrust: thrustClasses[1] ?? "", burn: burns[1] ?? "", notes: "" });
const [touched, setTouched] = useState(false);
const [state, setState] = useState<"idle" | "sending" | "sent" | "error">("idle");
const id = useId();
const badCompany = form.company.trim().length < 2;
const badEmail = !RF_EMAIL.test(form.email.trim());
const set = (k: keyof HotFireRequest) => (e: { target: { value: string } }) => setForm((f) => ({ ...f, [k]: e.target.value }));
const submit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setTouched(true);
if (badCompany || badEmail || state === "sending") {
const first = e.currentTarget.querySelector<HTMLInputElement>(badCompany ? "#" + CSS.escape(id + "c") : "#" + CSS.escape(id + "e"));
first?.focus();
return;
}
setState("sending");
try {
await onSubmit({ ...form, company: form.company.trim(), email: form.email.trim(), notes: form.notes.trim() });
setState("sent");
} catch {
setState("error");
}
};
const chips = (name: keyof HotFireRequest, label: string, options: string[]) => (
<fieldset className="m-0 min-w-0 border-0 p-0">
<legend className="mb-3 p-0 text-[11.5px] uppercase tracking-[0.14em]" style={{ fontFamily: HV_MONO, opacity: 0.72 }}>
{label}
</legend>
<div className="flex flex-wrap gap-2">
{options.map((o) => (
<label key={o} className="hv-rf-chip">
<input type="radio" name={id + name} value={o} checked={form[name] === o} onChange={set(name)} />
{o}
</label>
))}
</div>
</fieldset>
);
return (
<section data-hv-ground="black" className={"hv-scope hv-rf-scope @container relative w-full " + className} style={{ background: HV_BLACK, color: HV_BONE, fontFamily: HV_DISPLAY, ...style }}>
<style>{HV_CSS + RF_CSS}</style>
<div className="mx-auto grid w-full max-w-[1320px] grid-cols-1 gap-12 px-5 py-20 @3xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] @3xl:gap-[6cqw] @3xl:px-[4cqw] @3xl:py-28">
<div className="flex flex-col gap-4">
<p className="m-0 flex items-center gap-2.5 text-[11.5px] font-medium uppercase tracking-[0.14em] @3xl:text-[12.5px]" style={{ fontFamily: HV_MONO, opacity: 0.85 }}>
<span aria-hidden className="inline-block h-2 w-2" style={{ background: HV_HOT }} />
{eyebrow}
</p>
<h2 className="m-0 text-[clamp(44px,12cqw,64px)] uppercase leading-[0.86] @3xl:text-[clamp(56px,6.4cqw,104px)]" style={{ fontWeight: 900, fontVariationSettings: '"wdth" 70' }}>
{headline}
</h2>
<p className="m-0 max-w-[40ch] text-[16px] leading-[1.5]" style={{ fontWeight: 450, opacity: 0.8 }}>
{sub}
</p>
</div>
{state === "sent" ? (
<div role="status" className="flex flex-col items-start justify-center gap-5 p-8" style={{ background: HV_ORANGE, color: HV_BLACK }}>
<HvMark size={28} />
<p className="m-0 text-[clamp(26px,3cqw,36px)] uppercase leading-[0.95]" style={{ fontWeight: 900, fontVariationSettings: '"wdth" 76' }}>
{sentText}
</p>
<p className="m-0 text-[12px] uppercase tracking-[0.12em]" style={{ fontFamily: HV_MONO }}>
{form.company.trim()} · {form.thrust} · {form.burn}
</p>
</div>
) : (
<form noValidate onSubmit={submit} className="flex flex-col gap-8" aria-busy={state === "sending"}>
<div className="grid grid-cols-1 gap-8 @3xl:grid-cols-2">
<label className="hv-rf-field" data-bad={touched && badCompany ? "" : undefined}>
<span style={{ fontFamily: HV_MONO }}>Company</span>
<input id={id + "c"} name="company" autoComplete="organization" required value={form.company} onChange={set("company")} aria-invalid={touched && badCompany} aria-describedby={touched && badCompany ? id + "ce" : undefined} />
{touched && badCompany ? (
<em id={id + "ce"} className="mt-2 block text-[12.5px] not-italic" style={{ color: HV_HOT }}>
Tell us who is firing.
</em>
) : null}
</label>
<label className="hv-rf-field" data-bad={touched && badEmail ? "" : undefined}>
<span style={{ fontFamily: HV_MONO }}>Work email</span>
<input id={id + "e"} name="email" type="email" autoComplete="email" inputMode="email" required value={form.email} onChange={set("email")} aria-invalid={touched && badEmail} aria-describedby={touched && badEmail ? id + "ee" : undefined} />
{touched && badEmail ? (
<em id={id + "ee"} className="mt-2 block text-[12.5px] not-italic" style={{ color: HV_HOT }}>
We need an address that takes mail.
</em>
) : null}
</label>
</div>
{chips("thrust", "Stage thrust", thrustClasses)}
{chips("burn", "Burn length", burns)}
<label className="hv-rf-field">
<span style={{ fontFamily: HV_MONO }}>Anything we should know (optional)</span>
<textarea name="notes" rows={3} value={form.notes} onChange={set("notes")} />
</label>
<div className="flex flex-wrap items-center gap-5">
<button type="submit" disabled={state === "sending"} className="hv-press inline-flex h-14 items-center gap-2.5 border-0 px-7 text-[15px] font-bold uppercase tracking-[0.06em]" style={{ background: HV_ORANGE, color: HV_BLACK, cursor: state === "sending" ? "progress" : "pointer", opacity: state === "sending" ? 0.7 : 1 }}>
{state === "sending" ? "Sending" : "Send request"}
<HvArrow />
</button>
<p aria-live="polite" className="m-0 text-[13px]" style={{ color: state === "error" ? HV_HOT : "inherit", opacity: state === "error" ? 1 : 0.7 }}>
{state === "error" ? "That did not go through. Try again in a moment." : "No account needed. We only use this to reply."}
</p>
</div>
</form>
)}
</div>
</section>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this section
The kit's form, for the one thing a visitor might send: a request for time on the test stand. Two underlined fields (company and work email) whose rule turns safety orange on focus, two rows of chips (stage thrust and burn length) that fill solid orange when picked, and an optional note. Submitting checks the fields first: a bad one turns its rule hot orange, says what to fix in plain words and takes focus. While your handler runs, the button says it is sending; when it resolves, the form is replaced by an orange panel that confirms and replays what was asked for. If the handler fails, the form stays filled and says so.
Curator’s note
Free. onSubmit is required: the form only claims a request was received after your handler resolves, and shows its error line if it throws. Radio chips are real radios inside labels (styled with :has), validation is a linear-time email check, and the first bad field takes focus on submit. Every Hi-Vis entry is cut byte for byte from one site (Ardent, a made-up engine maker) and shares its fixed palette: safety orange #FF5B14 as a field, warm black, bone, Archivo's width axis for the shouting.
