Skip to content
UI Kit
Accent color

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

4 variants

Variants

Time slots

Days as tabs with a sliding marker and the free windows counted, windows as chips with booked ones paused. The line at the bottom says in words when the call comes and changes with every choice.

Open in Studio

Now or later

A live status with a breathing dot and the wait in minutes. Call now is one field and one large button, pick a time opens days and windows with a soft height.

Open in Studio

Inline bar

One row for a hero, a pricing page or a footer, the number field and the button side by side, the opening hours with a live dot. Confirms in place without leaving the page.

Open in Studio

Topic and person

The topic decides who calls, and that person appears beside the form with initials, role and languages. Before a topic is chosen the whole team waits there in a row.

Open in Studio

Details

What Callback request brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/callback-request.tsx
Brings along
utilskitbuttoniconrevealsectionmotionform-flowinputtextareacheckbox
npm packages
None
Languages
English and German, 37 texts of its own

Props

The CallbackRequestProps interface from components/ui/callback-request.tsx, with the comments from the code.

  • variantoptional
    CallbackRequestVariant

    time-slotsnow-or-laterinline-bartopic-agent

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • daysoptional
    CallbackDay[]
  • availableoptional
    boolean

    now-or-later: is someone free right now, and the wait in minutes

  • waitoptional
    number
  • hoursoptional
    string

    now-or-later and inline-bar: the opening hours line

  • agentsoptional
    CallbackAgent[]

    topic-agent

  • dialCodeoptional
    string

    Country code shown before the number, e.g. +49

  • askNameoptional
    boolean

    Ask for a name too

  • promiseoptional
    string

    inline-bar: quiet promise next to the field

  • submitLabeloptional
    string
  • doneoptional
    { 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
dayEnglish DayGerman Tag
windowEnglish Time windowGerman Zeitfenster
windowNeededEnglish 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.
fullEnglish BookedGerman Belegt
freeEnglish {n} freeGerman {n} frei
pickWindowEnglish Choose a time window that suits youGerman Wähle ein Zeitfenster, das dir passt
pickTopicEnglish Choose a topic and see who calls you.German Wähle ein Thema und sieh, wer dich anruft.
phoneEnglish Your numberGerman Deine Nummer
phoneNeededEnglish 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.
nameEnglish NameGerman Name
topicEnglish What is it about?German Worum geht es?
topicNeededEnglish Please choose a topic, it decides who calls you.German Bitte wähle ein Thema, danach richtet sich, wer dich anruft.
noteEnglish Anything we should know before the call?German Sollen wir vor dem Anruf etwas wissen?
consentEnglish I agree to be called at this number about my request.German Ich bin einverstanden, unter dieser Nummer zu meiner Anfrage angerufen zu werden.
consentNeededEnglish Please agree, so we are allowed to call you.German Bitte stimme zu, damit wir dich anrufen dürfen.
callEnglish Call me backGerman Rückruf anfordern
callNowEnglish Call me nowGerman Jetzt anrufen lassen
laterEnglish Pick a timeGerman Zeit wählen
whenEnglish When should we call?German Wann sollen wir anrufen?
nowEnglish NowGerman Sofort
availableNowEnglish Our team is free nowGerman Unser Team ist gerade frei
waitAboutEnglish about {n} minutes waitGerman etwa {n} Minuten Wartezeit
busyEnglish All lines are busy right nowGerman Gerade sind alle Leitungen belegt
busyTextEnglish Pick a time and we call you then.German Wähle eine Zeit, dann rufen wir dich an.
youGetCallEnglish We call you {when}German Wir rufen dich {when} an
betweenEnglish between {w}German zwischen {w}
doneNowEnglish We call you in about {n} minutesGerman Wir rufen dich in etwa {n} Minuten an
doneNowTextEnglish 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.
doneLaterEnglish Your call is plannedGerman Dein Anruf ist geplant
doneLaterTextEnglish 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.
doneInlineEnglish Thank you, we call you backGerman Danke, wir rufen zurück
doneInlineTextEnglish Usually within 15 minutes during opening hours.German Meist innerhalb von 15 Minuten während der Öffnungszeiten.
changeEnglish ChangeGerman Ändern
callsEnglish Who calls youGerman Wer dich anruft
consentShortEnglish Agreement to the callGerman Einverständnis zum Anruf
callsYouEnglish {n} calls youGerman {n} ruft dich an
speaksEnglish 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
components/ui/callback-request.tsx
'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.

Often searched

Jump to

Copied to the clipboard