Callback request
A number and a moment, and the business calls. Days and time windows with booked ones paused, a live status with the wait and a call right now, one row for a hero, or the topic that decides who calls.
One of 4 components in Request forms
Variants
Details
What Callback request brings along, read from the registry and the source.
- Category
- Request forms
- Area
- Content
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/callback-request.tsx
- npm packages
- None
- Languages
- English and German, 37 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CallbackRequestProps interface from components/ui/callback-request.tsx, with the comments from the code.
variantoptionalCallbackRequestVarianttime-slotsnow-or-laterinline-bartopic-agent
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodedaysoptionalCallbackDay[]availableoptionalbooleannow-or-later: is someone free right now, and the wait in minutes
waitoptionalnumberhoursoptionalstringnow-or-later and inline-bar: the opening hours line
agentsoptionalCallbackAgent[]topic-agent
dialCodeoptionalstringCountry code shown before the number, e.g. +49
askNameoptionalbooleanAsk for a name too
promiseoptionalstringinline-bar: quiet promise next to the field
submitLabeloptionalstringdoneoptional{ title: string; text?: string }onSubmitoptional(data: Record<string, unknown>) => void
Built-in texts
Callback request writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 37 texts
| Key | English | German |
|---|---|---|
| day | English Day | German Tag |
| window | English Time window | German Zeitfenster |
| windowNeeded | English Please choose a time window, so we call when it suits you. | German Bitte wähle ein Zeitfenster, damit wir anrufen, wenn es dir passt. |
| full | English Booked | German Belegt |
| free | English {n} free | German {n} frei |
| pickWindow | English Choose a time window that suits you | German Wähle ein Zeitfenster, das dir passt |
| pickTopic | English Choose a topic and see who calls you. | German Wähle ein Thema und sieh, wer dich anruft. |
| phone | English Your number | German Deine Nummer |
| phoneNeeded | English This number looks too short. Please enter it with the area code, so we reach you. | German Diese Nummer wirkt zu kurz. Bitte gib sie mit Vorwahl ein, damit wir dich erreichen. |
| name | English Name | German Name |
| topic | English What is it about? | German Worum geht es? |
| topicNeeded | English Please choose a topic, it decides who calls you. | German Bitte wähle ein Thema, danach richtet sich, wer dich anruft. |
| note | English Anything we should know before the call? | German Sollen wir vor dem Anruf etwas wissen? |
| consent | English I agree to be called at this number about my request. | German Ich bin einverstanden, unter dieser Nummer zu meiner Anfrage angerufen zu werden. |
| consentNeeded | English Please agree, so we are allowed to call you. | German Bitte stimme zu, damit wir dich anrufen dürfen. |
| call | English Call me back | German Rückruf anfordern |
| callNow | English Call me now | German Jetzt anrufen lassen |
| later | English Pick a time | German Zeit wählen |
| when | English When should we call? | German Wann sollen wir anrufen? |
| now | English Now | German Sofort |
| availableNow | English Our team is free now | German Unser Team ist gerade frei |
| waitAbout | English about {n} minutes wait | German etwa {n} Minuten Wartezeit |
| busy | English All lines are busy right now | German Gerade sind alle Leitungen belegt |
| busyText | English Pick a time and we call you then. | German Wähle eine Zeit, dann rufen wir dich an. |
| youGetCall | English We call you {when} | German Wir rufen dich {when} an |
| between | English between {w} | German zwischen {w} |
| doneNow | English We call you in about {n} minutes | German Wir rufen dich in etwa {n} Minuten an |
| doneNowText | English Keep your phone close. The call comes from a number starting with {d}. | German Halte dein Telefon bereit. Der Anruf kommt von einer Nummer, die mit {d} beginnt. |
| doneLater | English Your call is planned | German Dein Anruf ist geplant |
| doneLaterText | English We call you {d} {w}. You get a reminder by text one hour before. | German Wir rufen dich {d} {w} an. Eine Stunde vorher bekommst du eine Erinnerung per SMS. |
| doneInline | English Thank you, we call you back | German Danke, wir rufen zurück |
| doneInlineText | English Usually within 15 minutes during opening hours. | German Meist innerhalb von 15 Minuten während der Öffnungszeiten. |
| change | English Change | German Ändern |
| calls | English Who calls you | German Wer dich anruft |
| consentShort | English Agreement to the call | German Einverständnis zum Anruf |
| callsYou | English {n} calls you | German {n} ruft dich an |
| speaks | English Speaks {l} | German Spricht {l} |
Accessibility
Read from the source of the component.
- Sets aria-label and aria-live, so screen readers announce purpose and state.
- Uses the roles radiogroup and status.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 563 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { Stagger } from '@/components/ui/motion';
import { ChipGroup, DoneMark, ErrorSummary, FlowHead, isPhone, Unfold, useFocusFirstError, summaryOf, useFormState } from '@/components/ui/form-flow';
/*
* Asking for a call back. The visitor leaves a number and a moment, the
* business calls. Every variant says plainly when the call comes.
*
* time-slots days as tabs, time windows as chips, full ones paused
* now-or-later a live status with the wait, call now or pick a time
* inline-bar one row for a hero or a footer, number and button
* topic-agent the topic decides who calls, that person shows up with name and languages
*
* Sends { phone, name, day, window, topic, now } to `onSubmit`.
*/
export type CallbackRequestVariant = 'time-slots' | 'now-or-later' | 'inline-bar' | 'topic-agent';
export interface CallbackDay {
value: string;
/** e.g. Today, Tomorrow, Thu 23 */
label: string;
/** Time windows with their state */
windows: { value: string; label: string; full?: boolean }[];
}
export interface CallbackAgent {
topic: string;
/** Topic label as shown */
label: string;
icon?: IconName;
name: string;
role?: string;
initials?: string;
languages?: string;
}
export interface CallbackRequestProps {
variant?: CallbackRequestVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
days?: CallbackDay[];
/** now-or-later: is someone free right now, and the wait in minutes */
available?: boolean;
wait?: number;
/** now-or-later and inline-bar: the opening hours line */
hours?: string;
/** topic-agent */
agents?: CallbackAgent[];
/** Country code shown before the number, e.g. +49 */
dialCode?: string;
/** Ask for a name too */
askName?: boolean;
/** inline-bar: quiet promise next to the field */
promise?: string;
submitLabel?: string;
done?: { title: string; text?: string };
onSubmit?: (data: Record<string, unknown>) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
day: 'Day',
window: 'Time window',
windowNeeded: 'Please choose a time window, so we call when it suits you.',
full: 'Booked',
free: '{n} free',
pickWindow: 'Choose a time window that suits you',
pickTopic: 'Choose a topic and see who calls you.',
phone: 'Your number',
phoneNeeded: 'This number looks too short. Please enter it with the area code, so we reach you.',
name: 'Name',
topic: 'What is it about?',
topicNeeded: 'Please choose a topic, it decides who calls you.',
note: 'Anything we should know before the call?',
consent: 'I agree to be called at this number about my request.',
consentNeeded: 'Please agree, so we are allowed to call you.',
call: 'Call me back',
callNow: 'Call me now',
later: 'Pick a time',
when: 'When should we call?',
now: 'Now',
availableNow: 'Our team is free now',
waitAbout: 'about {n} minutes wait',
busy: 'All lines are busy right now',
busyText: 'Pick a time and we call you then.',
youGetCall: 'We call you {when}',
between: 'between {w}',
doneNow: 'We call you in about {n} minutes',
doneNowText: 'Keep your phone close. The call comes from a number starting with {d}.',
doneLater: 'Your call is planned',
doneLaterText: 'We call you {d} {w}. You get a reminder by text one hour before.',
doneInline: 'Thank you, we call you back',
doneInlineText: 'Usually within 15 minutes during opening hours.',
change: 'Change',
calls: 'Who calls you',
consentShort: 'Agreement to the call',
callsYou: '{n} calls you',
speaks: 'Speaks {l}',
},
de: {
day: 'Tag',
window: 'Zeitfenster',
windowNeeded: 'Bitte wähle ein Zeitfenster, damit wir anrufen, wenn es dir passt.',
full: 'Belegt',
free: '{n} frei',
pickWindow: 'Wähle ein Zeitfenster, das dir passt',
pickTopic: 'Wähle ein Thema und sieh, wer dich anruft.',
phone: 'Deine Nummer',
phoneNeeded: 'Diese Nummer wirkt zu kurz. Bitte gib sie mit Vorwahl ein, damit wir dich erreichen.',
name: 'Name',
topic: 'Worum geht es?',
topicNeeded: 'Bitte wähle ein Thema, danach richtet sich, wer dich anruft.',
note: 'Sollen wir vor dem Anruf etwas wissen?',
consent: 'Ich bin einverstanden, unter dieser Nummer zu meiner Anfrage angerufen zu werden.',
consentNeeded: 'Bitte stimme zu, damit wir dich anrufen dürfen.',
call: 'Rückruf anfordern',
callNow: 'Jetzt anrufen lassen',
later: 'Zeit wählen',
when: 'Wann sollen wir anrufen?',
now: 'Sofort',
availableNow: 'Unser Team ist gerade frei',
waitAbout: 'etwa {n} Minuten Wartezeit',
busy: 'Gerade sind alle Leitungen belegt',
busyText: 'Wähle eine Zeit, dann rufen wir dich an.',
youGetCall: 'Wir rufen dich {when} an',
between: 'zwischen {w}',
doneNow: 'Wir rufen dich in etwa {n} Minuten an',
doneNowText: 'Halte dein Telefon bereit. Der Anruf kommt von einer Nummer, die mit {d} beginnt.',
doneLater: 'Dein Anruf ist geplant',
doneLaterText: 'Wir rufen dich {d} {w} an. Eine Stunde vorher bekommst du eine Erinnerung per SMS.',
doneInline: 'Danke, wir rufen zurück',
doneInlineText: 'Meist innerhalb von 15 Minuten während der Öffnungszeiten.',
change: 'Ändern',
calls: 'Wer dich anruft',
consentShort: 'Einverständnis zum Anruf',
callsYou: '{n} ruft dich an',
speaks: 'Spricht {l}',
},
};
type Texts = (typeof TEXTS)['en'];
/** A short confirmation with the moment of the call, and a way to change it */
function Planned({ title, text, onChange, t, className }: { title: string; text: string; onChange: () => void; t: Texts; className?: string }) {
const ref = useRef<HTMLParagraphElement>(null);
useEffect(() => ref.current?.focus({ preventScroll: true }), []);
return (
<div role="status" className={cn('kit-rise flex flex-col items-center py-4 text-center', className)}>
<DoneMark size={56} />
<p ref={ref} tabIndex={-1} className="mt-5 text-balance text-[20px] font-semibold tracking-[-0.02em] text-foreground outline-none">
{title}
</p>
<p className="mt-2 max-w-[28rem] text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{text}</p>
<Button variant="ghost" size="sm" iconLeft="edit" onClick={onChange} className="mt-4">
{t.change}
</Button>
</div>
);
}
function PhoneField({ id, t, value, set, error, dialCode, size = 'md', label = true }: { id: string; t: Texts; value: string; set: (v: string) => void; error?: string; dialCode?: string; size?: 'md' | 'lg'; label?: boolean }) {
return (
<Input
id={id}
type="tel"
inputMode="tel"
autoComplete="tel-national"
label={label ? t.phone : undefined}
aria-label={label ? undefined : t.phone}
required
size={size}
startAddon={dialCode}
icon={dialCode ? undefined : 'phone'}
value={value}
onChange={(e) => set(e.target.value)}
error={error}
/>
);
}
/* ------------------------------------------------------------------ */
/* time-slots */
/* ------------------------------------------------------------------ */
function TimeSlots({ p, t }: { p: CallbackRequestProps; t: Texts }) {
const id = useId();
const f = useFormState();
const days = p.days ?? [];
const [day, setDay] = useState(days[0]?.value ?? '');
const [win, setWin] = useState<string[]>([]);
const [phone, setPhone] = useState('');
const [name, setName] = useState('');
const [consent, setConsent] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const summaryRef = useRef<HTMLDivElement>(null);
useFocusFirstError(formRef, f.attempt, summaryRef);
const current = days.find((d) => d.value === day) ?? days[0];
const index = Math.max(0, days.findIndex((d) => d.value === day));
const chosenWindow = current?.windows.find((w) => w.value === win[0]);
const summary = summaryOf(f.errors, [
['window', `${id}-w`, t.window],
['phone', `${id}-phone`, t.phone],
['consent', `${id}-consent`, t.consentShort],
]);
if (f.done)
return (
<div className="kit-card mx-auto max-w-[36rem] p-6 sm:p-10">
<Planned title={t.doneLater} text={t.doneLaterText.replace('{d}', current?.label ?? '').replace('{w}', t.between.replace('{w}', chosenWindow?.label ?? ''))} onChange={() => f.setDone(false)} t={t} />
</div>
);
return (
<Reveal effect="rise" className="mx-auto max-w-[40rem]">
<form
ref={formRef}
noValidate
className="kit-card overflow-hidden"
onSubmit={(e) => {
e.preventDefault();
const found: Record<string, string> = {};
if (!win.length) found.window = t.windowNeeded;
if (!isPhone(phone)) found.phone = t.phoneNeeded;
if (!consent) found.consent = t.consentNeeded;
if (f.fail(found)) return;
p.onSubmit?.({ day, window: win[0], phone, name });
f.setDone(true);
}}
>
{/* Days as tabs with a sliding marker, arrow keys move between them */}
<div role="radiogroup" aria-label={t.day} className="relative grid border-b border-border bg-surface" style={{ gridTemplateColumns: `repeat(${days.length}, minmax(0, 1fr))` }}>
<span aria-hidden className="absolute bottom-0 start-0 h-0.5 bg-primary transition-[translate] duration-500 ease-[cubic-bezier(0.3,1.2,0.5,1)] motion-reduce:transition-none" style={{ width: `${Math.round((100 / Math.max(1, days.length)) * 1000) / 1000}%`, translate: `${index * 100}% 0` }} />
{days.map((d, i) => {
const free = d.windows.filter((w) => !w.full).length;
return (
<label key={d.value} htmlFor={`${id}-d${i}`} className="flex min-h-16 cursor-pointer flex-col items-center justify-center gap-0.5 px-1 py-3 text-center has-[:focus-visible]:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)]">
<input id={`${id}-d${i}`} type="radio" name={`${id}-day`} checked={day === d.value} onChange={() => (setDay(d.value), setWin([]))} className="sr-only" />
<span className={cn('text-[14px] font-medium transition-colors', day === d.value ? 'text-foreground' : 'text-muted-foreground')}>{d.label}</span>
<span className="text-[11.5px] tabular-nums text-muted-foreground">{t.free.replace('{n}', String(free))}</span>
</label>
);
})}
</div>
<div className="grid gap-6 p-6 sm:p-8">
<ErrorSummary ref={summaryRef} errors={summary} />
<div key={day} className="kit-rise">
<ChipGroup id={`${id}-w`} legend={t.window} required layout={3} options={(current?.windows ?? []).map((w) => ({ value: w.value, label: w.label, meta: w.full ? t.full : undefined, disabled: w.full }))} value={win} onChange={(v) => (setWin(v), f.clear('window'))} error={f.errors.window} />
</div>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<PhoneField id={`${id}-phone`} t={t} value={phone} set={(v) => (setPhone(v), f.clear('phone'))} error={f.errors.phone} dialCode={p.dialCode} />
{p.askName !== false && <Input label={t.name} optional autoComplete="name" value={name} onChange={(e) => setName(e.target.value)} />}
</div>
<Checkbox id={`${id}-consent`} label={t.consent} checked={consent} onChange={(e) => (setConsent(e.target.checked), f.clear('consent'))} error={f.errors.consent} />
</div>
{/* The promise in words, it updates with the choice */}
<div className="flex flex-col gap-4 border-t border-border bg-surface px-6 py-5 sm:flex-row sm:items-center sm:justify-between sm:px-8">
<p aria-live="polite" className="flex items-center gap-2.5 text-[14px] text-muted-foreground">
<Icon name="phone-call" size={17} className="shrink-0 text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]" />
{chosenWindow ? <span key={chosenWindow.value} className="kit-rise font-medium text-foreground">{t.youGetCall.replace('{when}', `${current?.label ?? ''} ${t.between.replace('{w}', chosenWindow.label)}`)}</span> : t.pickWindow}
</p>
<Button type="submit" icon="phone-call" className="max-sm:w-full">
{p.submitLabel ?? t.call}
</Button>
</div>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* now-or-later */
/* ------------------------------------------------------------------ */
function NowOrLater({ p, t }: { p: CallbackRequestProps; t: Texts }) {
const id = useId();
const f = useFormState();
const available = p.available !== false;
const [mode, setMode] = useState<'now' | 'later'>(available ? 'now' : 'later');
const days = p.days ?? [];
const [day, setDay] = useState<string[]>(days[0] ? [days[0].value] : []);
const [win, setWin] = useState<string[]>([]);
const [phone, setPhone] = useState('');
const formRef = useRef<HTMLFormElement>(null);
useFocusFirstError(formRef, f.attempt);
const current = days.find((d) => d.value === day[0]);
const wait = p.wait ?? 3;
if (f.done)
return (
<div className="kit-card mx-auto max-w-[36rem] p-6 sm:p-10">
{mode === 'now' ? (
<Planned title={t.doneNow.replace('{n}', String(wait))} text={t.doneNowText.replace('{d}', p.dialCode ?? '')} onChange={() => f.setDone(false)} t={t} />
) : (
<Planned title={t.doneLater} text={t.doneLaterText.replace('{d}', current?.label ?? '').replace('{w}', t.between.replace('{w}', current?.windows.find((w) => w.value === win[0])?.label ?? ''))} onChange={() => f.setDone(false)} t={t} />
)}
</div>
);
return (
<Reveal effect="scale" className="mx-auto max-w-[38rem]">
<form
ref={formRef}
noValidate
className="kit-card overflow-hidden"
onSubmit={(e) => {
e.preventDefault();
const found: Record<string, string> = {};
if (!isPhone(phone)) found.phone = t.phoneNeeded;
if (mode === 'later' && !win.length) found.window = t.windowNeeded;
if (f.fail(found)) return;
p.onSubmit?.({ now: mode === 'now', phone, day: day[0], window: win[0] });
f.setDone(true);
}}
>
{/* Live status: a dot that breathes while someone is free */}
<div className="flex items-center gap-3.5 border-b border-border px-6 py-5 sm:px-8">
<span className="relative grid size-3 place-items-center">
{available && <span className="absolute inset-0 rounded-full bg-success opacity-60 motion-safe:animate-ping" />}
<span className={cn('relative size-3 rounded-full', available ? 'bg-success' : 'bg-warning')} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[15px] font-semibold text-foreground">{available ? t.availableNow : t.busy}</span>
<span className="block text-[13px] text-muted-foreground">{available ? t.waitAbout.replace('{n}', String(wait)) : t.busyText}</span>
</span>
{p.hours && <span className="hidden shrink-0 text-[12.5px] text-muted-foreground sm:block">{p.hours}</span>}
</div>
<div className="grid gap-6 p-6 sm:p-8">
{/* The switch and the days it opens are one block, so the closed days cost no gap */}
<div>
{available && (
<div role="radiogroup" aria-label={t.when} className="relative grid grid-cols-2 rounded-xl bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] p-1">
<span aria-hidden className={cn('absolute inset-y-1 start-1 w-[calc(50%-0.25rem)] rounded-lg bg-card shadow-[var(--shadow-sm)] transition-[translate] duration-[420ms] ease-[cubic-bezier(0.3,1.25,0.5,1)] motion-reduce:transition-none', mode === 'later' && 'translate-x-full rtl:-translate-x-full')} />
{(['now', 'later'] as const).map((m) => (
<label key={m} className="relative z-10 flex min-h-11 cursor-pointer items-center justify-center gap-2 rounded-lg text-[14px] font-medium text-muted-foreground transition-colors has-[:checked]:text-foreground has-[:focus-visible]:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)]">
<input type="radio" name={`${id}-mode`} checked={mode === m} onChange={() => (setMode(m), f.clear('window'))} className="sr-only" />
<Icon name={m === 'now' ? 'bolt' : 'calendar'} size={16} />
{m === 'now' ? t.now : t.later}
</label>
))}
</div>
)}
<Unfold open={mode === 'later'}>
<div className={cn('grid gap-5 pb-1', available && 'pt-6')}>
<ChipGroup legend={t.day} options={days.map((d) => ({ value: d.value, label: d.label }))} value={day} onChange={(v) => (setDay(v), setWin([]))} />
<ChipGroup id={`${id}-w`} legend={t.window} required layout={3} options={(current?.windows ?? []).map((w) => ({ value: w.value, label: w.label, meta: w.full ? t.full : undefined, disabled: w.full }))} value={win} onChange={(v) => (setWin(v), f.clear('window'))} error={f.errors.window} />
</div>
</Unfold>
</div>
<PhoneField id={`${id}-phone`} t={t} value={phone} set={(v) => (setPhone(v), f.clear('phone'))} error={f.errors.phone} dialCode={p.dialCode} size="lg" />
<Button type="submit" size="lg" icon={mode === 'now' ? 'phone-call' : 'calendar-check'} magnetic className="w-full">
{mode === 'now' ? t.callNow : (p.submitLabel ?? t.call)}
</Button>
</div>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* inline-bar */
/* ------------------------------------------------------------------ */
function InlineBar({ p, t }: { p: CallbackRequestProps; t: Texts }) {
const id = useId();
const f = useFormState();
const [phone, setPhone] = useState('');
const inputRef = useRef<HTMLDivElement>(null);
return (
<Reveal effect="rise">
<div className="relative overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
<div aria-hidden className="pointer-events-none absolute inset-0 kit-glow opacity-60" />
<div className="relative grid gap-6 p-6 sm:p-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] lg:items-center lg:gap-12 lg:p-10">
<div>
<FlowHead eyebrow={p.eyebrow} title={p.title} description={p.description} size="card" />
{p.hours && (
<p className="mt-4 flex items-center gap-2 text-[13px] text-muted-foreground">
<span className="relative grid size-2.5 place-items-center">
<span className="absolute inset-0 rounded-full bg-success opacity-60 motion-safe:animate-ping" />
<span className="relative size-2.5 rounded-full bg-success" />
</span>
{p.hours}
</p>
)}
</div>
<div ref={inputRef}>
{f.done ? (
<div role="status" className="kit-rise flex items-center gap-4 rounded-xl border border-[color-mix(in_oklab,var(--success)_35%,var(--border))] bg-[color-mix(in_oklab,var(--success)_8%,var(--card))] p-4">
<DoneMark size={44} />
<span>
<span className="block text-[15px] font-semibold text-foreground">{t.doneInline}</span>
<span className="block text-[13.5px] text-muted-foreground">{t.doneInlineText}</span>
</span>
</div>
) : (
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
if (f.fail(isPhone(phone) ? {} : { phone: t.phoneNeeded })) {
requestAnimationFrame(() => document.getElementById(`${id}-phone`)?.focus());
return;
}
p.onSubmit?.({ phone });
f.setDone(true);
}}
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start">
<div className="min-w-0 sm:flex-1">
<PhoneField id={`${id}-phone`} t={t} value={phone} set={(v) => (setPhone(v), f.clear('phone'))} error={f.errors.phone} dialCode={p.dialCode} size="lg" label={false} />
</div>
<Button type="submit" size="lg" icon="phone-call" className="max-sm:w-full">
{p.submitLabel ?? t.call}
</Button>
</div>
{p.promise && !f.errors.phone && <p className="mt-2.5 text-[13px] text-muted-foreground">{p.promise}</p>}
</form>
)}
</div>
</div>
</div>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* topic-agent */
/* ------------------------------------------------------------------ */
function TopicAgent({ p, t }: { p: CallbackRequestProps; t: Texts }) {
const id = useId();
const f = useFormState();
const agents = p.agents ?? [];
const days = p.days ?? [];
const [topic, setTopic] = useState<string[]>([]);
const [day, setDay] = useState<string[]>(days[0] ? [days[0].value] : []);
const [win, setWin] = useState<string[]>([]);
const [phone, setPhone] = useState('');
const [name, setName] = useState('');
const [note, setNote] = useState('');
const formRef = useRef<HTMLFormElement>(null);
const summaryRef = useRef<HTMLDivElement>(null);
useFocusFirstError(formRef, f.attempt, summaryRef);
const agent = agents.find((a) => a.topic === topic[0]);
const current = days.find((d) => d.value === day[0]);
const summary = summaryOf(f.errors, [
['topic', `${id}-topic`, t.topic],
['window', `${id}-w`, t.window],
['phone', `${id}-phone`, t.phone],
]);
if (f.done)
return (
<div className="kit-card mx-auto max-w-[36rem] p-6 sm:p-10">
<Planned title={t.doneLater} text={t.doneLaterText.replace('{d}', current?.label ?? '').replace('{w}', t.between.replace('{w}', current?.windows.find((w) => w.value === win[0])?.label ?? ''))} onChange={() => f.setDone(false)} t={t} />
</div>
);
return (
<form
ref={formRef}
noValidate
className="mx-auto grid max-w-[60rem] grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_18rem] lg:items-start lg:gap-8"
onSubmit={(e) => {
e.preventDefault();
const found: Record<string, string> = {};
if (!topic.length) found.topic = t.topicNeeded;
if (!win.length) found.window = t.windowNeeded;
if (!isPhone(phone)) found.phone = t.phoneNeeded;
if (f.fail(found)) return;
p.onSubmit?.({ topic: topic[0], day: day[0], window: win[0], phone, name, note });
f.setDone(true);
}}
>
<Reveal effect="rise" className="kit-card grid min-w-0 gap-6 p-6 sm:p-8">
<ErrorSummary ref={summaryRef} errors={summary} />
<ChipGroup id={`${id}-topic`} legend={t.topic} required layout={2} options={agents.map((a) => ({ value: a.topic, label: a.label, icon: a.icon }))} value={topic} onChange={(v) => (setTopic(v), f.clear('topic'))} error={f.errors.topic} />
{/* Phones and tablets: who calls appears right under the topic */}
{agent && (
<p key={agent.topic} aria-hidden className="kit-rise -mt-2 flex items-center gap-3 rounded-xl bg-surface p-3 text-[13.5px] text-muted-foreground lg:hidden">
<span className="grid size-9 shrink-0 place-items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_16%,var(--card))] text-[12px] font-semibold text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">{agent.initials ?? agent.name.split(' ').map((s) => s[0]).join('').slice(0, 2)}</span>
<span>
<span className="font-medium text-foreground">{t.callsYou.replace('{n}', agent.name)}</span>
{agent.languages ? ` · ${t.speaks.replace('{l}', agent.languages)}` : ''}
</span>
</p>
)}
<ChipGroup legend={t.day} layout={days.length === 4 ? 4 : 'wrap'} options={days.map((d) => ({ value: d.value, label: d.label }))} value={day} onChange={(v) => (setDay(v), setWin([]))} />
<div key={day[0]} className="kit-rise">
<ChipGroup id={`${id}-w`} legend={t.window} required layout={3} options={(current?.windows ?? []).map((w) => ({ value: w.value, label: w.label, meta: w.full ? t.full : undefined, disabled: w.full }))} value={win} onChange={(v) => (setWin(v), f.clear('window'))} error={f.errors.window} />
</div>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<PhoneField id={`${id}-phone`} t={t} value={phone} set={(v) => (setPhone(v), f.clear('phone'))} error={f.errors.phone} dialCode={p.dialCode} />
<Input label={t.name} optional autoComplete="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<Textarea label={t.note} optional value={note} onChange={(e) => setNote(e.target.value)} minRows={2} maxLength={400} />
<Button type="submit" icon="phone-call" className="sm:justify-self-start max-sm:w-full">
{p.submitLabel ?? t.call}
</Button>
</Reveal>
{/* The person who calls appears with the topic, the card turns over softly */}
<Reveal effect="rise" delay={120} className="lg:sticky lg:top-24">
<aside aria-live="polite" className="rounded-2xl border border-border bg-surface p-6">
<p className="text-[13px] font-medium text-muted-foreground">{t.calls}</p>
{agent ? (
<div key={agent.topic} className="kit-rise mt-4">
<span className="grid size-16 place-items-center rounded-2xl bg-[color-mix(in_oklab,var(--primary)_16%,var(--card))] text-[20px] font-semibold text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">
{agent.initials ?? agent.name.split(' ').map((s) => s[0]).join('').slice(0, 2)}
</span>
<p className="mt-4 text-[17px] font-semibold text-foreground">{agent.name}</p>
{agent.role && <p className="text-[13.5px] text-muted-foreground">{agent.role}</p>}
{agent.languages && (
<p className="mt-3 flex items-center gap-2 text-[13px] text-muted-foreground">
<Icon name="languages" size={15} />
{t.speaks.replace('{l}', agent.languages)}
</p>
)}
</div>
) : (
<Stagger step={90} as="div" className="mt-4 flex -space-x-2">
{agents.map((a) => (
<span key={a.topic} className="grid size-11 place-items-center rounded-full border-2 border-surface bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[13px] font-semibold text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">
{a.initials ?? a.name.split(' ').map((s) => s[0]).join('').slice(0, 2)}
</span>
))}
</Stagger>
)}
{!agent && <p className="mt-3 text-[13.5px] leading-snug text-muted-foreground">{t.pickTopic}</p>}
</aside>
</Reveal>
</form>
);
}
/* ------------------------------------------------------------------ */
/* Section */
/* ------------------------------------------------------------------ */
/**
* A call back request.
* <CallbackRequest variant="time-slots" days={[{ value, label, windows: [{ value, label, full }] }]} dialCode="+49" />
* <CallbackRequest variant="inline-bar" title="Prefer to talk?" hours="Mon to Fri, 8 am to 6 pm" />
*/
export function CallbackRequest(props: CallbackRequestProps) {
const { variant = 'time-slots', eyebrow, title, description, labels, className } = props;
const t = useTexts(TEXTS, labels);
return (
<Section className={className}>
{variant !== 'inline-bar' && <FlowHead eyebrow={eyebrow} title={title} description={description} align="center" className="mb-10 sm:mb-14" />}
{variant === 'time-slots' && <TimeSlots p={props} t={t} />}
{variant === 'now-or-later' && <NowOrLater p={props} t={t} />}
{variant === 'inline-bar' && <InlineBar p={props} t={t} />}
{variant === 'topic-agent' && <TopicAgent p={props} t={t} />}
</Section>
);
}Frequent questions
How do I install Callback request?
With aiw add @ai-website/callback-request. The command puts components/ui/callback-request.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, section, motion, form-flow, input, textarea and checkbox. It needs no further npm packages.
Which variants does Callback request have?
4 variants, Time slots, Now or later, Inline bar and Topic and person. You pick one with the variant prop, for example variant="now-or-later".
Can I translate the texts of Callback request?
Yes. Callback request carries 37 texts of its own in English and German, such as “Day”, “Time window” and “Please choose a time window, so we call when it suits you.”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Callback request?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Callback request with aiw_components and install it with aiw_add @ai-website/callback-request. Name the variant in your prompt, for example variant="time-slots", and the assistant writes your content straight into the props.
Can I adjust Callback request without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching CallbackRequest code to copy.