Booking Form
Original · freebooking form with date and slots
byWeb InnoventixReact + Tailwind
formbookingforms
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/form-booking.jsonform-booking.tsx
"use client";
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type FormEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/* ----------------------------- data + helpers ----------------------------- */
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
const MONTHS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
] as const;
const SLOT_TIMES = ["09:00", "10:30", "12:00", "13:30", "15:00", "16:30"] as const;
const SESSIONS = [
{
id: "discovery",
label: "Discovery call",
duration: "30 min",
price: "Free",
desc: "A quick fit check — goals, timeline, and whether we're right for the build.",
},
{
id: "strategy",
label: "Strategy session",
duration: "45 min",
price: "$120",
desc: "We map scope, information architecture, and a phased roadmap you keep.",
},
{
id: "technical",
label: "Technical review",
duration: "60 min",
price: "$180",
desc: "Audit of an existing site: performance, SEO, accessibility, and stack risks.",
},
] as const;
type SessionId = (typeof SESSIONS)[number]["id"];
type Slot = { time: string; available: boolean };
type FormErrors = {
name?: string;
email?: string;
date?: string;
slot?: string;
};
const startOfDay = (d: Date): Date =>
new Date(d.getFullYear(), d.getMonth(), d.getDate());
const startOfMonth = (d: Date): Date => new Date(d.getFullYear(), d.getMonth(), 1);
const addDays = (d: Date, n: number): Date => {
const next = new Date(d);
next.setDate(next.getDate() + n);
return next;
};
const addMonths = (d: Date, n: number): Date =>
new Date(d.getFullYear(), d.getMonth() + n, 1);
const isSameDay = (a: Date, b: Date): boolean =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
const isSameMonth = (a: Date, b: Date): boolean =>
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
const toKey = (d: Date): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
d.getDate(),
).padStart(2, "0")}`;
const to12h = (t: string): string => {
const [h, m] = t.split(":").map(Number);
const period = h >= 12 ? "PM" : "AM";
const hr = h % 12 === 0 ? 12 : h % 12;
return `${hr}:${String(m).padStart(2, "0")} ${period}`;
};
const formatLong = (d: Date): string =>
`${WEEKDAYS[d.getDay()]}, ${MONTHS[d.getMonth()]} ${d.getDate()}`;
const hash = (s: string): number => {
let h = 0;
for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return h;
};
// Deterministic availability so slots feel real and differ per day.
const slotsFor = (d: Date): Slot[] => {
const day = d.getDay();
if (day === 0 || day === 6) return SLOT_TIMES.map((t) => ({ time: t, available: false }));
const key = toKey(d);
return SLOT_TIMES.map((t) => ({
time: t,
available: hash(`${key}#${t}`) % 5 !== 0,
}));
};
const countAvailable = (d: Date): number =>
slotsFor(d).reduce((n, s) => (s.available ? n + 1 : n), 0);
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/* -------------------------------- component ------------------------------- */
export default function FormBooking() {
const reduce = useReducedMotion();
const uid = useId();
const today = useMemo(() => startOfDay(new Date()), []);
const minDate = today;
const maxDate = useMemo(() => addDays(today, 59), [today]);
const [viewMonth, setViewMonth] = useState<Date>(() => startOfMonth(today));
const [focusedDate, setFocusedDate] = useState<Date>(today);
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
const [session, setSession] = useState<SessionId>("discovery");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [notes, setNotes] = useState("");
const [errors, setErrors] = useState<FormErrors>({});
const [submitted, setSubmitted] = useState(false);
const gridRef = useRef<HTMLDivElement | null>(null);
const moveFocus = useRef(false);
const cells = useMemo(() => {
const first = startOfMonth(viewMonth);
const gridStart = addDays(first, -first.getDay());
return Array.from({ length: 42 }, (_, i) => addDays(gridStart, i));
}, [viewMonth]);
const slots = useMemo(
() => (selectedDate ? slotsFor(selectedDate) : []),
[selectedDate],
);
// Roving focus: move keyboard focus to the active day after arrow navigation.
useEffect(() => {
if (!moveFocus.current) return;
moveFocus.current = false;
const el = gridRef.current?.querySelector<HTMLButtonElement>(
`[data-date="${toKey(focusedDate)}"]`,
);
el?.focus();
}, [focusedDate, viewMonth]);
const outOfRange = (d: Date): boolean =>
startOfDay(d).getTime() < minDate.getTime() ||
startOfDay(d).getTime() > maxDate.getTime();
const isDisabledDay = (d: Date): boolean =>
outOfRange(d) || countAvailable(d) === 0;
const clamp = (d: Date): Date => {
const t = startOfDay(d).getTime();
if (t < minDate.getTime()) return minDate;
if (t > maxDate.getTime()) return maxDate;
return startOfDay(d);
};
const chooseDate = (d: Date) => {
if (isDisabledDay(d)) return;
const day = startOfDay(d);
setSelectedDate(day);
setFocusedDate(day);
setSelectedSlot(null);
setErrors((e) => ({ ...e, date: undefined, slot: undefined }));
if (!isSameMonth(day, viewMonth)) setViewMonth(startOfMonth(day));
};
const shiftFocus = (next: Date) => {
const target = clamp(next);
moveFocus.current = true;
setFocusedDate(target);
if (!isSameMonth(target, viewMonth)) setViewMonth(startOfMonth(target));
};
const onGridKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case "ArrowLeft":
e.preventDefault();
shiftFocus(addDays(focusedDate, -1));
break;
case "ArrowRight":
e.preventDefault();
shiftFocus(addDays(focusedDate, 1));
break;
case "ArrowUp":
e.preventDefault();
shiftFocus(addDays(focusedDate, -7));
break;
case "ArrowDown":
e.preventDefault();
shiftFocus(addDays(focusedDate, 7));
break;
case "Home":
e.preventDefault();
shiftFocus(addDays(focusedDate, -focusedDate.getDay()));
break;
case "End":
e.preventDefault();
shiftFocus(addDays(focusedDate, 6 - focusedDate.getDay()));
break;
case "PageUp":
e.preventDefault();
shiftFocus(addMonths(focusedDate, -1));
break;
case "PageDown":
e.preventDefault();
shiftFocus(addMonths(focusedDate, 1));
break;
case "Enter":
case " ":
e.preventDefault();
chooseDate(focusedDate);
break;
default:
break;
}
};
const canGoPrev = startOfMonth(viewMonth).getTime() > startOfMonth(minDate).getTime();
const canGoNext = startOfMonth(viewMonth).getTime() < startOfMonth(maxDate).getTime();
const activeSession = SESSIONS.find((s) => s.id === session)!;
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const next: FormErrors = {};
if (!name.trim()) next.name = "Tell us who we're meeting.";
if (!EMAIL_RE.test(email)) next.email = "Enter a valid email for the invite.";
if (!selectedDate) next.date = "Pick a day from the calendar.";
if (!selectedSlot) next.slot = "Choose an available time.";
setErrors(next);
if (Object.keys(next).length === 0) setSubmitted(true);
};
const reset = () => {
setSubmitted(false);
setSelectedDate(null);
setSelectedSlot(null);
setSession("discovery");
setName("");
setEmail("");
setNotes("");
setErrors({});
setFocusedDate(today);
setViewMonth(startOfMonth(today));
};
const ease = reduce ? undefined : ([0.22, 1, 0.36, 1] as const);
const field =
"w-full rounded-xl border border-zinc-300 bg-white/70 px-3.5 py-2.5 text-sm text-zinc-900 placeholder:text-zinc-400 shadow-sm outline-none transition focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-500/50 dark:border-zinc-700 dark:bg-zinc-900/60 dark:text-zinc-100 dark:placeholder:text-zinc-500";
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-100 px-4 py-16 text-zinc-900 sm:px-6 md:py-24 dark:from-zinc-950 dark:via-zinc-950 dark:to-black dark:text-zinc-100">
<style>{`
@keyframes fbkFadeUp {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fbkPop {
0% { transform: scale(0.6); opacity: 0; }
60% { transform: scale(1.08); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes fbkDraw {
to { stroke-dashoffset: 0; }
}
@keyframes fbkGlow {
0%, 100% { opacity: 0.35; }
50% { opacity: 0.6; }
}
.fbk-fade-up { animation: fbkFadeUp 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; }
.fbk-glow { animation: fbkGlow 7s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.fbk-fade-up, .fbk-glow { animation: none !important; }
}
`}</style>
{/* atmosphere */}
<div
aria-hidden="true"
className="fbk-glow pointer-events-none absolute -top-24 left-1/2 h-72 w-[36rem] -translate-x-1/2 rounded-full bg-indigo-400/25 blur-3xl dark:bg-indigo-600/20"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-[0.04] dark:opacity-[0.07]"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)",
backgroundSize: "22px 22px",
}}
/>
<div className="fbk-fade-up relative mx-auto max-w-5xl">
<header className="mb-8 text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium tracking-wide text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
</span>
Booking open · usually replies within a day
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
Book a call with our team
</h2>
<p className="mx-auto mt-3 max-w-xl text-sm text-zinc-600 sm:text-base dark:text-zinc-400">
Pick a day, grab a time that works, and tell us a little about the project.
You'll get a calendar invite the moment you confirm.
</p>
</header>
<div className="rounded-3xl border border-zinc-200 bg-white/80 p-1.5 shadow-xl shadow-zinc-900/5 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70 dark:shadow-black/40">
<AnimatePresence mode="wait" initial={false}>
{submitted ? (
<motion.div
key="confirmed"
initial={{ opacity: 0, y: reduce ? 0 : 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduce ? 0 : -12 }}
transition={{ duration: reduce ? 0 : 0.4, ease }}
className="rounded-[1.35rem] px-6 py-14 text-center sm:px-10"
role="status"
aria-live="polite"
>
<div
className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-500/15"
style={reduce ? undefined : { animation: "fbkPop 0.5s ease-out both" }}
>
<svg viewBox="0 0 24 24" className="h-8 w-8" aria-hidden="true">
<path
d="M5 13l4 4L19 7"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
className="text-emerald-600 dark:text-emerald-400"
style={
reduce
? undefined
: {
strokeDasharray: 30,
strokeDashoffset: 30,
animation: "fbkDraw 0.5s ease-out 0.25s forwards",
}
}
/>
</svg>
</div>
<h3 className="mt-6 text-2xl font-semibold tracking-tight">
You're booked in
</h3>
<p className="mx-auto mt-2 max-w-md text-sm text-zinc-600 dark:text-zinc-400">
We've sent a confirmation and calendar invite to{" "}
<span className="font-medium text-zinc-900 dark:text-zinc-100">
{email}
</span>
. Here's what you locked in:
</p>
<dl className="mx-auto mt-7 grid max-w-md gap-px overflow-hidden rounded-2xl border border-zinc-200 bg-zinc-200 text-left text-sm dark:border-zinc-800 dark:bg-zinc-800">
{[
{ k: "Session", v: `${activeSession.label} · ${activeSession.duration}` },
{ k: "Date", v: selectedDate ? formatLong(selectedDate) : "—" },
{ k: "Time", v: selectedSlot ? to12h(selectedSlot) : "—" },
{ k: "Attendee", v: name },
].map((row) => (
<div
key={row.k}
className="flex items-center justify-between gap-4 bg-white px-4 py-3 dark:bg-zinc-900"
>
<dt className="text-zinc-500 dark:text-zinc-400">{row.k}</dt>
<dd className="text-right font-medium text-zinc-900 dark:text-zinc-100">
{row.v}
</dd>
</div>
))}
</dl>
<button
type="button"
onClick={reset}
className="mt-8 inline-flex items-center justify-center rounded-xl border border-zinc-300 bg-white px-5 py-2.5 text-sm font-medium text-zinc-800 shadow-sm outline-none transition hover:bg-zinc-50 focus-visible:ring-2 focus-visible:ring-indigo-500/60 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
>
Book another call
</button>
</motion.div>
) : (
<motion.form
key="form"
onSubmit={handleSubmit}
noValidate
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.3 }}
className="grid gap-1.5 md:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)]"
>
{/* ---------------- left: calendar + slots ---------------- */}
<div className="rounded-[1.35rem] bg-zinc-50/70 p-5 sm:p-6 dark:bg-zinc-950/40">
<div className="mb-4 flex items-center justify-between">
<div
id={`${uid}-month`}
aria-live="polite"
className="text-sm font-semibold"
>
{MONTHS[viewMonth.getMonth()]} {viewMonth.getFullYear()}
</div>
<div className="flex gap-1">
<button
type="button"
onClick={() => canGoPrev && setViewMonth(addMonths(viewMonth, -1))}
disabled={!canGoPrev}
aria-label="Previous month"
className="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-200 bg-white text-zinc-600 outline-none transition hover:bg-zinc-100 focus-visible:ring-2 focus-visible:ring-indigo-500/60 disabled:cursor-not-allowed disabled:opacity-40 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden="true">
<path
d="M15 18l-6-6 6-6"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<button
type="button"
onClick={() => canGoNext && setViewMonth(addMonths(viewMonth, 1))}
disabled={!canGoNext}
aria-label="Next month"
className="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-200 bg-white text-zinc-600 outline-none transition hover:bg-zinc-100 focus-visible:ring-2 focus-visible:ring-indigo-500/60 disabled:cursor-not-allowed disabled:opacity-40 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden="true">
<path
d="M9 18l6-6-6-6"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
</div>
<div
role="grid"
aria-labelledby={`${uid}-month`}
ref={gridRef}
onKeyDown={onGridKeyDown}
>
<div role="row" className="mb-1 grid grid-cols-7">
{WEEKDAYS.map((w) => (
<div
key={w}
role="columnheader"
aria-label={w}
className="py-1.5 text-center text-[0.7rem] font-medium uppercase tracking-wider text-zinc-400 dark:text-zinc-500"
>
{w.slice(0, 2)}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{cells.map((day) => {
const inMonth = isSameMonth(day, viewMonth);
const disabled = isDisabledDay(day);
const selected = selectedDate && isSameDay(day, selectedDate);
const isToday = isSameDay(day, today);
const tabbable = isSameDay(day, focusedDate);
const free = !disabled && inMonth ? countAvailable(day) : 0;
return (
<div role="gridcell" key={toKey(day)} className="relative">
<button
type="button"
data-date={toKey(day)}
tabIndex={tabbable ? 0 : -1}
disabled={disabled}
aria-pressed={!!selected}
aria-current={isToday ? "date" : undefined}
aria-label={`${formatLong(day)}${
disabled
? ", unavailable"
: `, ${free} slot${free === 1 ? "" : "s"} open`
}`}
onClick={() => chooseDate(day)}
className={[
"relative flex aspect-square w-full items-center justify-center rounded-lg text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-indigo-500/70",
!inMonth ? "text-zinc-300 dark:text-zinc-700" : "",
disabled
? "cursor-not-allowed text-zinc-300 line-through decoration-1 dark:text-zinc-700"
: "cursor-pointer",
selected
? "bg-indigo-600 font-semibold text-white shadow-md shadow-indigo-600/30 dark:bg-indigo-500"
: !disabled && inMonth
? "text-zinc-700 hover:bg-indigo-50 dark:text-zinc-200 dark:hover:bg-indigo-500/15"
: "",
!selected && isToday
? "ring-1 ring-inset ring-indigo-400 dark:ring-indigo-500"
: "",
].join(" ")}
>
{day.getDate()}
{!disabled && inMonth && !selected ? (
<span
aria-hidden="true"
className={[
"absolute bottom-1 h-1 w-1 rounded-full",
free <= 2
? "bg-amber-400 dark:bg-amber-500"
: "bg-emerald-400 dark:bg-emerald-500",
].join(" ")}
/>
) : null}
</button>
</div>
);
})}
</div>
</div>
{/* legend */}
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[0.72rem] text-zinc-500 dark:text-zinc-400">
<span className="inline-flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400 dark:bg-emerald-500" />
Open
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-amber-400 dark:bg-amber-500" />
Filling up
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-3 border-t border-zinc-400" />
Full / weekend
</span>
</div>
{/* time slots */}
<div className="mt-5">
<div className="mb-2.5 flex items-baseline justify-between">
<h3
id={`${uid}-slots`}
className="text-sm font-semibold text-zinc-800 dark:text-zinc-200"
>
Available times
</h3>
<span className="text-xs text-zinc-500 dark:text-zinc-400">
{selectedDate ? formatLong(selectedDate) : "Select a day first"}
</span>
</div>
{selectedDate ? (
<div
role="group"
aria-labelledby={`${uid}-slots`}
className="grid grid-cols-3 gap-2"
>
{slots.map((s) => {
const active = selectedSlot === s.time;
return (
<button
key={s.time}
type="button"
disabled={!s.available}
aria-pressed={active}
onClick={() => {
setSelectedSlot(s.time);
setErrors((e) => ({ ...e, slot: undefined }));
}}
className={[
"rounded-xl border px-2 py-2 text-sm font-medium tabular-nums outline-none transition focus-visible:ring-2 focus-visible:ring-indigo-500/60",
s.available
? active
? "border-indigo-600 bg-indigo-600 text-white shadow-sm shadow-indigo-600/30 dark:border-indigo-500 dark:bg-indigo-500"
: "border-zinc-200 bg-white text-zinc-700 hover:border-indigo-400 hover:text-indigo-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:border-indigo-500 dark:hover:text-indigo-300"
: "cursor-not-allowed border-dashed border-zinc-200 bg-transparent text-zinc-300 line-through dark:border-zinc-800 dark:text-zinc-600",
].join(" ")}
>
{to12h(s.time)}
</button>
);
})}
</div>
) : (
<p className="rounded-xl border border-dashed border-zinc-300 px-4 py-6 text-center text-sm text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
Choose a highlighted day to see open times.
</p>
)}
{errors.date ? (
<p className="mt-2 text-xs font-medium text-rose-600 dark:text-rose-400">
{errors.date}
</p>
) : null}
{errors.slot && !errors.date ? (
<p className="mt-2 text-xs font-medium text-rose-600 dark:text-rose-400">
{errors.slot}
</p>
) : null}
</div>
</div>
{/* ---------------- right: details ---------------- */}
<div className="flex flex-col gap-5 p-5 sm:p-6">
<fieldset>
<legend className="mb-2.5 text-sm font-semibold text-zinc-800 dark:text-zinc-200">
What should we cover?
</legend>
<div className="grid gap-2">
{SESSIONS.map((s) => {
const checked = session === s.id;
return (
<label
key={s.id}
className={[
"group flex cursor-pointer items-start gap-3 rounded-xl border p-3 transition",
checked
? "border-indigo-500 bg-indigo-50/70 dark:border-indigo-500/70 dark:bg-indigo-500/10"
: "border-zinc-200 bg-white hover:border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:border-zinc-600",
].join(" ")}
>
<input
type="radio"
name="session"
value={s.id}
checked={checked}
onChange={() => setSession(s.id)}
className="peer sr-only"
/>
<span
aria-hidden="true"
className={[
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 transition peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500/60 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:peer-focus-visible:ring-offset-zinc-900",
checked
? "border-indigo-600 dark:border-indigo-400"
: "border-zinc-300 dark:border-zinc-600",
].join(" ")}
>
<span
className={[
"h-2 w-2 rounded-full bg-indigo-600 transition dark:bg-indigo-400",
checked ? "scale-100" : "scale-0",
].join(" ")}
/>
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{s.label}
</span>
<span className="shrink-0 text-xs font-semibold text-indigo-600 dark:text-indigo-300">
{s.price} · {s.duration}
</span>
</span>
<span className="mt-0.5 block text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
{s.desc}
</span>
</span>
</label>
);
})}
</div>
</fieldset>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label
htmlFor={`${uid}-name`}
className="mb-1.5 block text-sm font-medium text-zinc-800 dark:text-zinc-200"
>
Full name
</label>
<input
id={`${uid}-name`}
type="text"
value={name}
autoComplete="name"
placeholder="Jordan Ellis"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? `${uid}-name-err` : undefined}
onChange={(e) => {
setName(e.target.value);
if (errors.name) setErrors((p) => ({ ...p, name: undefined }));
}}
className={`${field} ${
errors.name
? "border-rose-400 focus-visible:border-rose-500 focus-visible:ring-rose-500/40 dark:border-rose-500/60"
: ""
}`}
/>
{errors.name ? (
<p
id={`${uid}-name-err`}
className="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
>
{errors.name}
</p>
) : null}
</div>
<div>
<label
htmlFor={`${uid}-email`}
className="mb-1.5 block text-sm font-medium text-zinc-800 dark:text-zinc-200"
>
Work email
</label>
<input
id={`${uid}-email`}
type="email"
value={email}
autoComplete="email"
placeholder="jordan@company.com"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? `${uid}-email-err` : undefined}
onChange={(e) => {
setEmail(e.target.value);
if (errors.email) setErrors((p) => ({ ...p, email: undefined }));
}}
className={`${field} ${
errors.email
? "border-rose-400 focus-visible:border-rose-500 focus-visible:ring-rose-500/40 dark:border-rose-500/60"
: ""
}`}
/>
{errors.email ? (
<p
id={`${uid}-email-err`}
className="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
>
{errors.email}
</p>
) : null}
</div>
</div>
<div>
<label
htmlFor={`${uid}-notes`}
className="mb-1.5 block text-sm font-medium text-zinc-800 dark:text-zinc-200"
>
Anything to prep?{" "}
<span className="font-normal text-zinc-400">(optional)</span>
</label>
<textarea
id={`${uid}-notes`}
value={notes}
rows={3}
placeholder="A link to your current site, the outcome you're after, or a rough budget range."
onChange={(e) => setNotes(e.target.value)}
className={`${field} resize-none`}
/>
</div>
{/* summary + submit */}
<div className="mt-auto rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 dark:border-zinc-800 dark:bg-zinc-950/40">
<div
className="flex items-center gap-2 text-sm"
aria-live="polite"
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4 shrink-0 text-indigo-500"
aria-hidden="true"
>
<rect
x="3"
y="4"
width="18"
height="17"
rx="2"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
/>
<path
d="M3 9h18M8 2v4M16 2v4"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
/>
</svg>
{selectedDate && selectedSlot ? (
<span className="text-zinc-700 dark:text-zinc-300">
<span className="font-semibold text-zinc-900 dark:text-zinc-100">
{formatLong(selectedDate)}
</span>{" "}
at{" "}
<span className="font-semibold text-zinc-900 dark:text-zinc-100">
{to12h(selectedSlot)}
</span>{" "}
· {activeSession.duration}
</span>
) : (
<span className="text-zinc-500 dark:text-zinc-400">
No time selected yet
</span>
)}
</div>
</div>
<button
type="submit"
className="group inline-flex w-full items-center justify-center gap-2 rounded-xl bg-indigo-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-indigo-600/25 outline-none transition hover:bg-indigo-700 focus-visible:ring-2 focus-visible:ring-indigo-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white active:scale-[0.99] dark:focus-visible:ring-offset-zinc-900"
>
Confirm booking
<svg
viewBox="0 0 24 24"
className="h-4 w-4 transition-transform group-hover:translate-x-0.5"
aria-hidden="true"
>
<path
d="M5 12h14M13 6l6 6-6 6"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<p className="text-center text-xs text-zinc-400 dark:text-zinc-500">
No charge to book. Reschedule anytime from your invite.
</p>
</div>
</motion.form>
)}
</AnimatePresence>
</div>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Login Form
Originalclean login form with social buttons

Signup Form
Originalsignup form with password strength

Contact Form
Originalcontact form with fields and message

Multistep Form
Originalmulti-step wizard form with progress

Payment Form
Originalcard payment form with formatting

Newsletter Form
Originalinline newsletter subscribe form

Search Filters Form
Originalfaceted search filter panel

Profile Form
Originalprofile settings form

OTP Verify Form
OriginalOTP verification form

Upload Dropzone Form
Originaldrag-and-drop file upload with preview

Feedback Form
Originalfeedback form with emoji rating

Survey Form
Originalshort survey with radios and scale

