Skip to content
UI Kit
Accent color

Consulting

For consultants and small advisory firms. Packages that open with deliverables, the method as a ring, an effort estimator, references with numbers and a brief in three steps.

One of 2 components in Coaching and consulting

5 variants

Variants

Packages

Fixed packages as rows with scope, length and price from. One opens at a time with deliverables and the right fit, a bar grows along its edge.

Open in Studio

Method

The phases on a ring, the arc fills up to the chosen phase, an inner line keeps running. Each phase opens with activities and its result, arrows and arrow keys move through.

Open in Studio

Effort estimator

A few sliders about the project give the consulting days and a price range. Days fill a bar of blocks, numbers glide, a light runs round the result.

Open in Studio

References

One client at a time behind logo tabs. Challenge, approach and result side by side, the numbers count up, a quote from the client beside them.

Open in Studio

Brief

A brief in three steps, topic, situation and contact. A progress line fills, every step slides in from its side, a summary before sending.

Open in Studio

Details

What Consulting brings along, read from the registry and the source.

Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/consulting.tsx
Brings along
utilskitbuttoniconmotionsection
npm packages
None
Languages
English and German, 44 texts of its own

Props

The ConsultingProps interface from components/ui/consulting.tsx, with the comments from the code.

  • variantoptional
    ConsultingVariant

    packagesmethodestimatorreferencesbrief

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • packagesoptional
    ConsultingPackage[]
  • phasesoptional
    ConsultingPhase[]
  • referencesoptional
    ConsultingReference[]
  • factorsoptional
    ConsultingFactor[]

    estimator

  • baseDaysoptional
    number
  • dayRateoptional
    number
  • topicsoptional
    string[]

    brief: topics to pick from

  • currencyoptional
    string
  • onSubmitoptional
    (data: Record<string, string>) => void

Built-in texts

Consulting writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 44 texts
fromEnglish fromGerman ab
lengthEnglish LengthGerman Dauer
deliverablesEnglish You getGerman Du bekommst
fitEnglish Right forGerman Passt für
requestEnglish Request this packageGerman Paket anfragen
phaseEnglish Phase {n}German Phase {n}
activitiesEnglish What we doGerman Was wir tun
resultEnglish ResultGerman Ergebnis
estimateEnglish EstimateGerman Schätzung
daysEnglish {n} consulting daysGerman {n} Beratertage
rangeEnglish Likely betweenGerman Voraussichtlich zwischen
andEnglish andGerman und
weeksEnglish about {n} weeksGerman etwa {n} Wochen
proposalEnglish Request a proposalGerman Angebot anfragen
noteEnglish An estimate, not an offer. The proposal follows a first call.German Eine Schätzung, kein Angebot. Das Angebot folgt nach einem ersten Gespräch.
challengeEnglish ChallengeGerman Ausgangslage
approachEnglish ApproachGerman Vorgehen
outcomeEnglish ResultGerman Ergebnis
clientsEnglish ClientsGerman Kunden
stepEnglish Step {n} of {total}German Schritt {n} von {total}
s1English TopicGerman Thema
s2English SituationGerman Lage
s3English ContactGerman Kontakt
topicsQEnglish What is it about?German Worum geht es?
pickOneEnglish Pick at least one topic.German Wähle mindestens ein Thema.
situationEnglish Where do you stand today?German Wo steht ihr heute?
situationPhEnglish We are 80 people in two offices and decisions take weeks.German Wir sind 80 Leute an zwei Standorten und Entscheidungen dauern Wochen.
sizeEnglish Size of the organisationGerman Größe der Organisation
sizesEnglish Up to 20|20 to 100|100 to 500|More than 500German Bis 20|20 bis 100|100 bis 500|Mehr als 500
whenEnglish When should it start?German Wann soll es losgehen?
whensEnglish Now|This quarter|Later this yearGerman Sofort|Dieses Quartal|Später im Jahr
nameEnglish NameGerman Name
companyEnglish OrganisationGerman Organisation
emailEnglish EmailGerman E-Mail
emailPhEnglish you@example.comGerman du@beispiel.de
backEnglish BackGerman Zurück
nextEnglish NextGerman Weiter
sendEnglish Send briefGerman Briefing senden
requiredEnglish Please fill in this field.German Bitte fülle dieses Feld aus.
invalidEnglish Please enter an email address like name@example.com.German Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein.
sentTitleEnglish Brief receivedGerman Briefing ist angekommen
sentTextEnglish We read it within one working day and suggest three times for a first call.German Wir lesen es innerhalb eines Werktags und schlagen drei Termine für ein erstes Gespräch vor.
againEnglish Write another briefGerman Weiteres Briefing schreiben
summaryEnglish Your briefGerman Dein Briefing

Accessibility

Read from the source of the component.

  • Consulting handles the keys Arrow up, Arrow down, Arrow left and Arrow right itself.
  • Sets aria-controls, aria-current, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-labelledby, aria-live and aria-selected, so screen readers announce purpose and state.
  • Uses the roles status, tab, tablist and tabpanel.
  • 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.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • 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, 788 lines
components/ui/consulting.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Stagger, useScrollFade } from '@/components/ui/motion';
import { Section, SectionHeader } from '@/components/ui/section';

/*
 * Sections for consultants and small advisory firms: fixed packages, the
 * method as a cycle, an estimate of the effort, references with numbers and
 * a brief in three steps.
 *
 *   packages   packages as rows with scope, length and price from, one opens with deliverables
 *   method     the phases on a ring with a running line, each phase opens with activities and results
 *   estimator  a few sliders give days and a price range, both glide
 *   references one client at a time, challenge, approach and result with numbers that count
 *   brief      a brief in three steps with a progress line, a summary before sending
 */

export type ConsultingVariant = 'packages' | 'method' | 'estimator' | 'references' | 'brief';

export interface ConsultingPackage {
  id: string;
  title: string;
  /** One line about the scope */
  summary: string;
  /** e.g. "2 weeks" */
  length: string;
  /** Price from */
  price: number;
  /** What the client gets */
  deliverables?: string[];
  /** For whom */
  fit?: string;
  icon?: IconName;
  href?: string;
}

export interface ConsultingPhase {
  id: string;
  title: string;
  text: string;
  activities?: string[];
  result?: string;
  icon?: IconName;
}

export interface ConsultingReference {
  id: string;
  client: string;
  industry?: string;
  logo?: React.ReactNode;
  challenge: string;
  approach: string;
  result: string;
  numbers?: { value: number; suffix?: string; label: string }[];
  quote?: { text: string; name: string; role?: string };
}

export interface ConsultingFactor {
  id: string;
  label: string;
  min: number;
  max: number;
  step?: number;
  value: number;
  /** Days added per unit */
  days: number;
  /** Shown after the value, e.g. "people" */
  unit?: string;
}

export interface ConsultingProps {
  variant?: ConsultingVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  packages?: ConsultingPackage[];
  phases?: ConsultingPhase[];
  references?: ConsultingReference[];
  /** estimator */
  factors?: ConsultingFactor[];
  baseDays?: number;
  dayRate?: number;
  /** brief: topics to pick from */
  topics?: string[];
  currency?: string;
  onSubmit?: (data: Record<string, string>) => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    from: 'from',
    length: 'Length',
    deliverables: 'You get',
    fit: 'Right for',
    request: 'Request this package',
    phase: 'Phase {n}',
    activities: 'What we do',
    result: 'Result',
    estimate: 'Estimate',
    days: '{n} consulting days',
    range: 'Likely between',
    and: 'and',
    weeks: 'about {n} weeks',
    proposal: 'Request a proposal',
    note: 'An estimate, not an offer. The proposal follows a first call.',
    challenge: 'Challenge',
    approach: 'Approach',
    outcome: 'Result',
    clients: 'Clients',
    step: 'Step {n} of {total}',
    s1: 'Topic',
    s2: 'Situation',
    s3: 'Contact',
    topicsQ: 'What is it about?',
    pickOne: 'Pick at least one topic.',
    situation: 'Where do you stand today?',
    situationPh: 'We are 80 people in two offices and decisions take weeks.',
    size: 'Size of the organisation',
    sizes: 'Up to 20|20 to 100|100 to 500|More than 500',
    when: 'When should it start?',
    whens: 'Now|This quarter|Later this year',
    name: 'Name',
    company: 'Organisation',
    email: 'Email',
    emailPh: 'you@example.com',
    back: 'Back',
    next: 'Next',
    send: 'Send brief',
    required: 'Please fill in this field.',
    invalid: 'Please enter an email address like name@example.com.',
    sentTitle: 'Brief received',
    sentText: 'We read it within one working day and suggest three times for a first call.',
    again: 'Write another brief',
    summary: 'Your brief',
  },
  de: {
    from: 'ab',
    length: 'Dauer',
    deliverables: 'Du bekommst',
    fit: 'Passt für',
    request: 'Paket anfragen',
    phase: 'Phase {n}',
    activities: 'Was wir tun',
    result: 'Ergebnis',
    estimate: 'Schätzung',
    days: '{n} Beratertage',
    range: 'Voraussichtlich zwischen',
    and: 'und',
    weeks: 'etwa {n} Wochen',
    proposal: 'Angebot anfragen',
    note: 'Eine Schätzung, kein Angebot. Das Angebot folgt nach einem ersten Gespräch.',
    challenge: 'Ausgangslage',
    approach: 'Vorgehen',
    outcome: 'Ergebnis',
    clients: 'Kunden',
    step: 'Schritt {n} von {total}',
    s1: 'Thema',
    s2: 'Lage',
    s3: 'Kontakt',
    topicsQ: 'Worum geht es?',
    pickOne: 'Wähle mindestens ein Thema.',
    situation: 'Wo steht ihr heute?',
    situationPh: 'Wir sind 80 Leute an zwei Standorten und Entscheidungen dauern Wochen.',
    size: 'Größe der Organisation',
    sizes: 'Bis 20|20 bis 100|100 bis 500|Mehr als 500',
    when: 'Wann soll es losgehen?',
    whens: 'Sofort|Dieses Quartal|Später im Jahr',
    name: 'Name',
    company: 'Organisation',
    email: 'E-Mail',
    emailPh: 'du@beispiel.de',
    back: 'Zurück',
    next: 'Weiter',
    send: 'Briefing senden',
    required: 'Bitte fülle dieses Feld aus.',
    invalid: 'Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein.',
    sentTitle: 'Briefing ist angekommen',
    sentText: 'Wir lesen es innerhalb eines Werktags und schlagen drei Termine für ein erstes Gespräch vor.',
    again: 'Weiteres Briefing schreiben',
    summary: 'Dein Briefing',
  },
};
type Texts = typeof TEXTS.en;

/* ------------------------------------------------------------------ */
/* Helpers                                                             */
/* ------------------------------------------------------------------ */

const TAG: Record<Locale, string> = { en: 'en-US', de: 'de-DE' };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const focusRing = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background';
function money(value: number, currency: string, locale: Locale) {
  return new Intl.NumberFormat(TAG[locale], { style: 'currency', currency, maximumFractionDigits: 0 }).format(value);
}

const MOTION_CSS = `
@keyframes kitcons-in{from{opacity:0;transform:translateY(8px)}}
@keyframes kitcons-swap{from{opacity:0;filter:blur(4px);transform:translateY(6px)}}
@keyframes kitcons-pop{0%{transform:scale(.5);opacity:0}60%{transform:scale(1.12);opacity:1}100%{transform:scale(1)}}
@keyframes kitcons-next{from{opacity:0;transform:translateX(24px)}}
@keyframes kitcons-prev{from{opacity:0;transform:translateX(-24px)}}
@keyframes kitcons-dash{to{stroke-dashoffset:-40}}
@media (prefers-reduced-motion:reduce){[class*="kitcons-"],.kitcons-anim{animation:none!important;transition:none!important}}`;

function ConsMotion() {
  return (
    <style href="kitcons-motion" precedence="default">
      {MOTION_CSS}
    </style>
  );
}

function useTween(value: number, ms = 500) {
  const [shown, setShown] = useState(value);
  const from = useRef(value);
  useEffect(() => {
    const start = from.current;
    if (start === value) return;
    if (prefersReducedMotion()) {
      from.current = value;
      setShown(value);
      return;
    }
    let raf = 0;
    const t0 = performance.now();
    const tick = (now: number) => {
      const k = Math.min(1, (now - t0) / ms);
      const v = k >= 1 ? value : start + (value - start) * (1 - Math.pow(1 - k, 3));
      from.current = v;
      setShown(v);
      if (k < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [value, ms]);
  return shown;
}

/* ------------------------------------------------------------------ */
/* packages                                                            */
/* ------------------------------------------------------------------ */

function Packages({ packages = [], currency = 'EUR', t }: ConsultingProps & { t: Texts }) {
  const locale = useLocale();
  const [open, setOpen] = useState<string | null>(packages[1]?.id ?? packages[0]?.id ?? null);
  const base = useId();
  return (
    <Stagger as="ul" step={80} className="border-t border-border">
      {packages.map((p) => {
        const on = open === p.id;
        return (
          <li key={p.id} className={cn('relative border-b border-border transition-colors duration-500', on && 'bg-[color-mix(in_oklab,var(--primary)_4%,transparent)]')}>
            <span aria-hidden className={cn('absolute inset-y-0 left-0 w-[3px] origin-top bg-primary transition-transform duration-500 ease-[var(--ease-kit)]', on ? 'scale-y-100' : 'scale-y-0')} />
            <h3>
              <button type="button" aria-expanded={on} aria-controls={`${base}-${p.id}`} onClick={() => setOpen(on ? null : p.id)} className={cn('group grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-4 gap-y-2 px-4 py-6 text-left sm:px-6 md:grid-cols-[auto_minmax(0,1.6fr)_minmax(0,0.7fr)_minmax(0,0.8fr)_auto] md:gap-x-8', focusRing, 'focus-visible:ring-inset focus-visible:ring-offset-0')}>
                <span className={cn('flex size-11 items-center justify-center rounded-xl border transition-colors duration-300', on ? 'border-transparent bg-primary text-primary-foreground' : 'border-border text-foreground group-hover:border-border-strong')}>
                  <Icon name={p.icon ?? 'briefcase'} size={19} />
                </span>
                <span className="min-w-0">
                  <span className="block text-[18px] font-semibold tracking-[-0.01em] text-foreground">{p.title}</span>
                  <span className="mt-0.5 block text-pretty text-[14px] text-muted-foreground">{p.summary}</span>
                </span>
                <span className="col-start-2 text-[14px] text-muted-foreground md:col-start-3 md:row-start-1">
                  <span className="sr-only">{t.length} </span>
                  <span className="inline-flex items-center gap-1.5">
                    <Icon name="clock" size={14} />
                    {p.length}
                  </span>
                </span>
                <span className="col-start-2 text-[14px] md:col-start-4 md:row-start-1 md:text-right">
                  <span className="text-muted-foreground">{t.from} </span>
                  <span className="text-[17px] font-semibold tabular-nums text-foreground">{money(p.price, currency, locale)}</span>
                </span>
                <Icon name="chevron-down" size={18} className={cn('col-start-3 row-start-1 text-muted-foreground transition-transform duration-300 md:col-start-5', on && 'rotate-180')} />
              </button>
            </h3>
            <div id={`${base}-${p.id}`} hidden={!on} className="px-4 pb-8 sm:px-6 md:pl-[6.25rem]">
              {on && (
                <div className="grid grid-cols-1 gap-6 [animation:kitcons-swap_.45s_var(--ease-kit)] md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)] md:gap-10">
                  {p.deliverables?.length ? (
                    <div>
                      <p className="text-[13px] font-medium text-muted-foreground">{t.deliverables}</p>
                      <ul className="mt-3 grid gap-2 sm:grid-cols-2">
                        {p.deliverables.map((d, i) => (
                          <li key={d} className="flex gap-2.5 text-[14.5px] text-foreground [animation:kitcons-in_.4s_var(--ease-kit)_both]" style={{ animationDelay: `${100 + i * 60}ms` }}>
                            <Icon name="file-check" size={16} className="mt-[3px] text-primary" />
                            {d}
                          </li>
                        ))}
                      </ul>
                    </div>
                  ) : null}
                  <div className="flex flex-col gap-4">
                    {p.fit && (
                      <p className="text-[14.5px] text-foreground">
                        <span className="block text-[13px] font-medium text-muted-foreground">{t.fit}</span>
                        {p.fit}
                      </p>
                    )}
                    <Button href={p.href ?? '#'} icon="arrow-right" className="self-start max-sm:w-full">
                      {t.request}
                    </Button>
                  </div>
                </div>
              )}
            </div>
          </li>
        );
      })}
    </Stagger>
  );
}

/* ------------------------------------------------------------------ */
/* method                                                              */
/* ------------------------------------------------------------------ */

function Method({ phases = [], t }: ConsultingProps & { t: Texts }) {
  const [sel, setSel] = useState(0);
  const [ref, inView] = useInView<HTMLDivElement>();
  const n = phases.length || 1;
  const size = 360;
  const c = size / 2;
  const r = 132;
  const pts = phases.map((_, i) => {
    const a = (i / n) * Math.PI * 2 - Math.PI / 2;
    return { x: round(c + r * Math.cos(a), 2), y: round(c + r * Math.sin(a), 2) };
  });
  const p = phases[sel];
  const key = (e: React.KeyboardEvent) => {
    const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
    if (!d) return;
    e.preventDefault();
    const i = (sel + d + n) % n;
    setSel(i);
    (e.currentTarget as HTMLElement).querySelector<HTMLElement>(`[data-i="${i}"]`)?.focus();
  };
  const arc = round(2 * Math.PI * r, 2);
  return (
    <div className="grid grid-cols-1 items-center gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-16">
      <div ref={ref} role="tablist" aria-label={t.activities} onKeyDown={key} className="relative mx-auto aspect-square w-full max-w-[420px]">
        <svg viewBox={`0 0 ${size} ${size}`} className="absolute inset-0 size-full" aria-hidden>
          <circle cx={c} cy={c} r={r} fill="none" stroke="var(--border)" strokeWidth="1.5" />
          <circle cx={c} cy={c} r={r} fill="none" stroke="var(--primary)" strokeWidth="2" strokeLinecap="round" transform={`rotate(-90 ${c} ${c})`} strokeDasharray={arc} strokeDashoffset={round(inView ? arc * (1 - (sel + 1) / n) : arc, 2)} className="transition-[stroke-dashoffset] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" />
          <circle cx={c} cy={c} r={r - 26} fill="none" stroke="color-mix(in oklab, var(--primary) 35%, transparent)" strokeWidth="1" strokeDasharray="3 7" className="kitcons-anim [animation:kitcons-dash_3s_linear_infinite]" />
        </svg>
        <div className="absolute inset-[30%] flex flex-col items-center justify-center rounded-full text-center">
          <span key={sel} className="text-[13px] font-medium text-primary [animation:kitcons-in_.4s_var(--ease-kit)]">
            {fill(t.phase, { n: sel + 1 })}
          </span>
          <span key={`t${sel}`} className="mt-1 text-balance text-[17px] font-semibold leading-tight text-foreground [animation:kitcons-in_.45s_var(--ease-kit)]">
            {p?.title}
          </span>
        </div>
        {phases.map((ph, i) => {
          const on = i === sel;
          const done = i < sel;
          return (
            <button key={ph.id} data-i={i} type="button" role="tab" aria-selected={on} aria-label={`${fill(t.phase, { n: i + 1 })}, ${ph.title}`} tabIndex={on ? 0 : -1} onClick={() => setSel(i)} className={cn('absolute flex size-14 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border-2 bg-background transition-[border-color,background-color,color,transform] duration-500 hover:scale-105', focusRing, on ? 'scale-110 border-primary bg-primary text-primary-foreground shadow-[0_0_0_8px_color-mix(in_oklab,var(--primary)_16%,transparent)]' : done ? 'border-primary text-primary' : 'border-border-strong text-muted-foreground')} style={{ left: `${round((pts[i].x / size) * 100, 3)}%`, top: `${round((pts[i].y / size) * 100, 3)}%` }}>
              <Icon name={ph.icon ?? 'circle'} size={20} />
            </button>
          );
        })}
      </div>
      {p && (
        <div key={sel} role="tabpanel" className="[animation:kitcons-swap_.5s_var(--ease-kit)]">
          <p className="text-[13px] font-medium text-muted-foreground">{fill(t.phase, { n: sel + 1 })}</p>
          <h3 className="mt-1.5 text-[28px] font-semibold tracking-[-0.02em] text-foreground">{p.title}</h3>
          <p className="mt-3 text-pretty text-[16px] leading-relaxed text-muted-foreground">{p.text}</p>
          {p.activities?.length ? (
            <>
              <p className="mt-7 text-[13px] font-medium text-muted-foreground">{t.activities}</p>
              <ul className="mt-3 grid gap-2.5">
                {p.activities.map((a, i) => (
                  <li key={a} className="flex items-start gap-3 text-[15px] text-foreground [animation:kitcons-in_.4s_var(--ease-kit)_both]" style={{ animationDelay: `${120 + i * 60}ms` }}>
                    <span className="mt-[9px] h-px w-4 shrink-0 bg-primary" />
                    {a}
                  </li>
                ))}
              </ul>
            </>
          ) : null}
          {p.result && (
            <p className="mt-7 flex items-start gap-3 rounded-xl border border-border bg-card p-4 text-[15px] text-foreground">
              <Icon name="flag" size={18} className="mt-0.5 shrink-0 text-primary" />
              <span>
                <span className="block text-[13px] font-medium text-muted-foreground">{t.result}</span>
                {p.result}
              </span>
            </p>
          )}
          <div className="mt-6 flex gap-2">
            <Button variant="outline" size="sm" iconLeft="arrow-left" disabled={sel === 0} onClick={() => setSel((v) => Math.max(0, v - 1))} aria-label={t.back}>
              <span className="sr-only">{t.back}</span>
            </Button>
            <Button variant="outline" size="sm" icon="arrow-right" disabled={sel === n - 1} onClick={() => setSel((v) => Math.min(n - 1, v + 1))}>
              {t.next}
            </Button>
          </div>
        </div>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* estimator                                                           */
/* ------------------------------------------------------------------ */

function Estimator({ factors = [], baseDays = 4, dayRate = 1400, currency = 'EUR', t }: ConsultingProps & { t: Texts }) {
  const locale = useLocale();
  const [values, setValues] = useState<Record<string, number>>(Object.fromEntries(factors.map((f) => [f.id, f.value])));
  const days = Math.round(baseDays + factors.reduce((a, f) => a + (values[f.id] ?? f.value) * f.days, 0));
  const low = Math.round((days * dayRate * 0.9) / 500) * 500;
  const high = Math.round((days * dayRate * 1.2) / 500) * 500;
  const d = useTween(days);
  const lo = useTween(low);
  const hi = useTween(high);
  const weeks = Math.max(1, Math.round(days / 2.5));
  const nf = new Intl.NumberFormat(TAG[locale]);
  return (
    <div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] lg:gap-10">
      <div className="kit-card grid gap-7 p-6 sm:p-8">
        {factors.map((f) => {
          const v = values[f.id] ?? f.value;
          const pct = round(((v - f.min) / (f.max - f.min)) * 100, 2);
          return (
            <div key={f.id}>
              <div className="flex items-baseline justify-between gap-4">
                <label htmlFor={`est-${f.id}`} className="text-[15px] font-medium text-foreground">
                  {f.label}
                </label>
                <span className="text-[15px] font-semibold tabular-nums text-foreground">
                  {nf.format(v)}
                  {f.unit && <span className="ml-1 font-normal text-muted-foreground">{f.unit}</span>}
                </span>
              </div>
              <div className="relative mt-3">
                <div aria-hidden className="pointer-events-none absolute inset-x-2.5 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]">
                  <div className="h-full rounded-full bg-primary" style={{ width: `${pct}%` }} />
                </div>
                <input id={`est-${f.id}`} type="range" min={f.min} max={f.max} step={f.step ?? 1} value={v} onChange={(e) => setValues((s) => ({ ...s, [f.id]: Number(e.target.value) }))} className="relative z-[1] h-11 w-full cursor-pointer appearance-none bg-transparent [&::-moz-range-thumb]:size-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:shadow-[0_0_0_2px_var(--primary),var(--shadow-md)] [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_var(--primary),var(--shadow-md)] focus-visible:outline-none focus-visible:[&::-webkit-slider-thumb]:ring-4 focus-visible:[&::-webkit-slider-thumb]:ring-ring" />
              </div>
              <div className="flex justify-between text-[12px] tabular-nums text-subtle-foreground" aria-hidden>
                <span>{nf.format(f.min)}</span>
                <span>{nf.format(f.max)}</span>
              </div>
            </div>
          );
        })}
      </div>
      <aside className="lg:sticky lg:top-8 lg:self-start">
        <div className="kit-card kit-beam overflow-hidden">
          <div className="p-6 sm:p-8">
            <p className="text-[13px] font-medium text-muted-foreground">{t.estimate}</p>
            <p className="mt-2 text-[44px] font-semibold leading-none tracking-[-0.03em] tabular-nums text-foreground" aria-live="polite">
              {Math.round(d)}
            </p>
            <p className="mt-1 text-[15px] text-muted-foreground">
              {fill(t.days, { n: '' }).trim()} · {fill(t.weeks, { n: weeks })}
            </p>
            <div className="mt-6 flex gap-1" aria-hidden>
              {Array.from({ length: 30 }, (_, i) => (
                <span key={i} className={cn('h-6 flex-1 rounded-[3px] transition-colors duration-300', i < Math.min(30, days) ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]')} style={{ transitionDelay: `${i * 12}ms` }} />
              ))}
            </div>
          </div>
          <div className="border-t border-border bg-surface p-6 sm:p-8">
            <p className="text-[13px] text-muted-foreground">{t.range}</p>
            <p className="mt-1 text-[24px] font-semibold tracking-[-0.02em] tabular-nums text-foreground">
              {money(Math.round(lo / 100) * 100, currency, locale)} <span className="text-[15px] font-normal text-muted-foreground">{t.and}</span> {money(Math.round(hi / 100) * 100, currency, locale)}
            </p>
            <Button href="#" icon="arrow-right" className="mt-5 w-full">
              {t.proposal}
            </Button>
            <p className="mt-3 text-[12.5px] leading-snug text-muted-foreground">{t.note}</p>
          </div>
        </div>
      </aside>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* references                                                          */
/* ------------------------------------------------------------------ */

function Count({ value, run, locale }: { value: number; run: boolean; locale: Locale }) {
  const [v, setV] = useState(value);
  useEffect(() => {
    if (!run || prefersReducedMotion()) return setV(value);
    let raf = 0;
    const t0 = performance.now();
    const tick = (now: number) => {
      const k = Math.min(1, (now - t0) / 1200);
      setV(value * (1 - Math.pow(1 - k, 3)));
      if (k < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [run, value]);
  return <>{new Intl.NumberFormat(TAG[locale]).format(Math.round(v))}</>;
}

function References({ references = [], t }: ConsultingProps & { t: Texts }) {
  const locale = useLocale();
  const [sel, setSel] = useState(0);
  const [ref, inView] = useInView<HTMLDivElement>();
  const [row, fade] = useScrollFade<HTMLDivElement>();
  const r = references[sel];
  const base = useId();
  return (
    <div ref={ref}>
      <div ref={row} className={cn('kit-no-scrollbar -mx-6 overflow-x-auto px-6 sm:mx-0 sm:px-0', fade)}>
        <div role="tablist" aria-label={t.clients} className="flex gap-1 border-b border-border sm:gap-2">
          {references.map((x, i) => {
            const on = i === sel;
            return (
              <button key={x.id} type="button" role="tab" id={`${base}-t${i}`} aria-selected={on} aria-controls={`${base}-p`} onClick={() => setSel(i)} onKeyDown={(e) => { const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0; if (!d) return; e.preventDefault(); const n = (sel + d + references.length) % references.length; setSel(n); document.getElementById(`${base}-t${n}`)?.focus(); }} tabIndex={on ? 0 : -1} className={cn('relative -mb-px flex min-h-14 shrink-0 items-center gap-2.5 border-b-2 px-3 pb-3 pt-2 text-[15px] font-medium transition-colors max-sm:flex-1 max-sm:justify-center max-sm:px-1.5', focusRing, on ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground')}>
                {x.logo && <span className={cn('flex h-6 items-center transition-opacity [&_svg]:h-5', on ? 'opacity-100' : 'opacity-60 grayscale')}>{x.logo}</span>}
                {!x.logo && x.client}
                {x.logo && <span className="sr-only">{x.client}</span>}
              </button>
            );
          })}
        </div>
      </div>
      {r && (
        <div key={sel} id={`${base}-p`} role="tabpanel" aria-labelledby={`${base}-t${sel}`} className="mt-10 [animation:kitcons-swap_.5s_var(--ease-kit)]">
          <p className="text-[14px] text-muted-foreground">
            <span className="font-semibold text-foreground">{r.client}</span>
            {r.industry && ` · ${r.industry}`}
          </p>
          <div className="mt-6 grid grid-cols-1 gap-px overflow-hidden rounded-2xl border border-border bg-border md:grid-cols-3">
            {[
              [t.challenge, r.challenge, 'alert-circle'],
              [t.approach, r.approach, 'route'],
              [t.outcome, r.result, 'trending-up'],
            ].map(([label, text, icon], i) => (
              <div key={label} className="bg-card p-6">
                <div className="[animation:kitcons-in_.45s_var(--ease-kit)_both]" style={{ animationDelay: `${i * 90}ms` }}>
                  <span className={cn('flex size-9 items-center justify-center rounded-lg', i === 2 ? 'bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-primary' : 'bg-muted text-foreground')}>
                    <Icon name={icon as IconName} size={17} />
                  </span>
                  <p className="mt-4 text-[13px] font-medium text-muted-foreground">{label}</p>
                  <p className="mt-1.5 text-pretty text-[15px] leading-relaxed text-foreground">{text}</p>
                </div>
              </div>
            ))}
          </div>
          {(r.numbers?.length || r.quote) && (
            <div className="mt-10 grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
              {r.numbers?.length ? (
                <dl className="grid grid-cols-3 gap-4">
                  {r.numbers.map((n) => (
                    <div key={n.label} className="flex flex-col-reverse justify-end">
                      <dt className="mt-1.5 text-[13px] leading-snug text-muted-foreground">{n.label}</dt>
                      <dd className="text-[30px] font-semibold leading-none tracking-[-0.03em] tabular-nums text-foreground sm:text-[36px]">
                        <Count value={n.value} run={inView} locale={locale} />
                        {n.suffix}
                      </dd>
                    </div>
                  ))}
                </dl>
              ) : null}
              {r.quote && (
                <figure className="border-l-2 border-primary pl-5">
                  <blockquote className="text-pretty text-[16px] italic leading-relaxed text-foreground">{r.quote.text}</blockquote>
                  <figcaption className="mt-2 text-[13.5px] text-muted-foreground">
                    {r.quote.name}
                    {r.quote.role && `, ${r.quote.role}`}
                  </figcaption>
                </figure>
              )}
            </div>
          )}
        </div>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* brief                                                               */
/* ------------------------------------------------------------------ */

function Brief({ topics = [], onSubmit, t }: ConsultingProps & { t: Texts }) {
  const base = useId();
  const [step, setStep] = useState(0);
  const [dir, setDir] = useState<'next' | 'prev'>('next');
  const [picked, setPicked] = useState<string[]>([]);
  const [situation, setSituation] = useState('');
  const [size, setSize] = useState(1);
  const [when, setWhen] = useState(1);
  const [contact, setContact] = useState({ name: '', company: '', email: '' });
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [sent, setSent] = useState(false);
  const sizes = t.sizes.split('|');
  const whens = t.whens.split('|');
  const steps = [t.s1, t.s2, t.s3];
  const go = (n: number) => {
    setDir(n > step ? 'next' : 'prev');
    setStep(n);
  };
  const next = () => {
    const e: Record<string, string> = {};
    if (step === 0 && !picked.length) e.topics = t.pickOne;
    if (step === 2) {
      if (!contact.name.trim()) e.name = t.required;
      if (!contact.company.trim()) e.company = t.required;
      if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contact.email)) e.email = t.invalid;
    }
    setErrors(e);
    if (Object.keys(e).length) return;
    if (step < 2) return go(step + 1);
    onSubmit?.({ topics: picked.join(', '), situation, size: sizes[size], when: whens[when], ...contact });
    setSent(true);
  };
  const field = 'h-11 w-full rounded-lg border bg-background px-3 text-[15px] text-foreground placeholder:text-subtle-foreground';
  if (sent)
    return (
      <div role="status" className="kit-card mx-auto max-w-[620px] p-8 text-center [animation:kitcons-swap_.5s_var(--ease-kit)] sm:p-12">
        <span className="mx-auto flex size-14 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-success [animation:kitcons-pop_.5s_var(--ease-kit)]">
          <Icon name="check" size={26} />
        </span>
        <h3 className="mt-5 text-h3 font-semibold text-foreground">{t.sentTitle}</h3>
        <p className="mt-2 text-pretty text-[15px] text-muted-foreground">{t.sentText}</p>
        <Button variant="outline" className="mt-6" onClick={() => { setSent(false); setStep(0); }}>
          {t.again}
        </Button>
      </div>
    );
  return (
    <div className="kit-card mx-auto max-w-[760px] overflow-hidden">
      <div className="border-b border-border px-6 pt-6 sm:px-8">
        <ol className="flex gap-2">
          {steps.map((s, i) => (
            <li key={s} className="min-w-0 flex-1">
              <button type="button" disabled={i > step} onClick={() => go(i)} aria-current={i === step ? 'step' : undefined} className={cn('w-full pb-4 text-left', focusRing, 'rounded-sm disabled:cursor-default')}>
                <span className="relative block h-1 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]">
                  <span className="absolute inset-y-0 left-0 w-full origin-left rounded-full bg-primary transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${i <= step ? 1 : 0})` }} />
                </span>
                <span className={cn('mt-2.5 block truncate text-[13px] font-medium', i === step ? 'text-foreground' : 'text-muted-foreground')}>
                  <span className="tabular-nums">{i + 1}</span> · {s}
                </span>
              </button>
            </li>
          ))}
        </ol>
      </div>
      <div key={step} className={cn('min-h-[300px] p-6 sm:p-8', dir === 'next' ? '[animation:kitcons-next_.45s_var(--ease-kit)]' : '[animation:kitcons-prev_.45s_var(--ease-kit)]')}>
        <p className="sr-only" aria-live="polite">
          {fill(t.step, { n: step + 1, total: 3 })}
        </p>
        {step === 0 && (
          <fieldset>
            <legend className="text-[19px] font-semibold text-foreground">{t.topicsQ}</legend>
            <div className="mt-5 flex flex-wrap gap-2">
              {topics.map((x) => {
                const on = picked.includes(x);
                return (
                  <label key={x} className={cn('inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-full border px-4 text-[14.5px] font-medium transition-[background-color,border-color,color] duration-300 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring', on ? 'border-transparent bg-foreground text-background' : 'border-border text-foreground hover:border-border-strong')}>
                    <input type="checkbox" checked={on} onChange={() => { setPicked((v) => (on ? v.filter((y) => y !== x) : [...v, x])); setErrors({}); }} className="sr-only" />
                    {on && <Icon name="check" size={15} className="[animation:kitcons-pop_.3s_var(--ease-kit)]" />}
                    {x}
                  </label>
                );
              })}
            </div>
            {errors.topics && <p className="mt-3 text-[13px] text-destructive">{errors.topics}</p>}
          </fieldset>
        )}
        {step === 1 && (
          <div className="grid gap-6">
            <div>
              <label htmlFor={`${base}-sit`} className="text-[19px] font-semibold text-foreground">
                {t.situation}
              </label>
              <textarea id={`${base}-sit`} rows={3} value={situation} onChange={(e) => setSituation(e.target.value)} placeholder={t.situationPh} className={cn('mt-4 w-full resize-y rounded-lg border border-border-strong bg-background px-3 py-2.5 text-[15px] text-foreground placeholder:text-subtle-foreground', focusRing)} />
            </div>
            {[
              { label: t.size, list: sizes, value: size, set: setSize },
              { label: t.when, list: whens, value: when, set: setWhen },
            ].map((g) => (
              <fieldset key={g.label}>
                <legend className="mb-2.5 text-[14px] font-medium text-foreground">{g.label}</legend>
                <div className={cn('grid gap-2', g.list.length === 4 ? 'grid-cols-2 sm:grid-cols-4' : 'grid-cols-1 sm:grid-cols-3')}>
                  {g.list.map((s, i) => (
                    <label key={s} className={cn('flex min-h-11 cursor-pointer items-center justify-center rounded-lg border px-2 text-center text-[14px] font-medium transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring', g.value === i ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))] text-foreground' : 'border-border text-muted-foreground hover:text-foreground')}>
                      <input type="radio" name={`${base}-${g.label}`} checked={g.value === i} onChange={() => g.set(i)} className="sr-only" />
                      {s}
                    </label>
                  ))}
                </div>
              </fieldset>
            ))}
          </div>
        )}
        {step === 2 && (
          <div className="grid grid-cols-1 gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,0.85fr)]">
            <div className="grid content-start gap-5">
              {(['name', 'company', 'email'] as const).map((k) => (
                <div key={k}>
                  <label htmlFor={`${base}-${k}`} className="mb-2 block text-[14px] font-medium text-foreground">
                    {t[k]}
                  </label>
                  <input id={`${base}-${k}`} type={k === 'email' ? 'email' : 'text'} autoComplete={k === 'company' ? 'organization' : k} value={contact[k]} onChange={(e) => setContact((c) => ({ ...c, [k]: e.target.value }))} placeholder={k === 'email' ? t.emailPh : undefined} aria-invalid={!!errors[k]} aria-describedby={errors[k] ? `${base}-${k}-e` : undefined} className={cn(field, errors[k] ? 'border-destructive' : 'border-border-strong', focusRing)} />
                  {errors[k] && (
                    <p id={`${base}-${k}-e`} className="mt-1.5 text-[13px] text-destructive">
                      {errors[k]}
                    </p>
                  )}
                </div>
              ))}
            </div>
            <div className="self-start rounded-xl border border-border bg-surface p-5">
              <p className="text-[13px] font-medium text-muted-foreground">{t.summary}</p>
              <ul className="mt-3 flex flex-wrap gap-1.5">
                {picked.map((x) => (
                  <li key={x} className="rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] px-2.5 py-1 text-[12.5px] font-medium text-foreground">
                    {x}
                  </li>
                ))}
              </ul>
              {situation && <p className="mt-3 line-clamp-3 text-[13.5px] text-foreground/85">{situation}</p>}
              <p className="mt-3 text-[13px] text-muted-foreground">
                {sizes[size]} · {whens[when]}
              </p>
            </div>
          </div>
        )}
      </div>
      <div className="flex items-center justify-between gap-3 border-t border-border px-6 py-4 sm:px-8">
        <Button variant="ghost" iconLeft="arrow-left" onClick={() => go(Math.max(0, step - 1))} className={cn(step === 0 && 'invisible max-sm:hidden')}>
          {t.back}
        </Button>
        <span className="text-[13px] tabular-nums text-muted-foreground max-sm:hidden">{fill(t.step, { n: step + 1, total: 3 })}</span>
        <Button icon={step === 2 ? 'send' : 'arrow-right'} onClick={next} className="max-sm:flex-1">
          {step === 2 ? t.send : t.next}
        </Button>
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Consulting                                                          */
/* ------------------------------------------------------------------ */

/**
 * Sections for consultants and advisory firms.
 *   packages    fixed packages as rows that open with deliverables
 *   method      the phases on a ring, each with activities and a result
 *   estimator   sliders give days and a price range
 *   references  one client at a time with challenge, approach, result and numbers
 *   brief       a brief in three steps with a summary
 */
export function Consulting(props: ConsultingProps) {
  const { variant = 'packages', eyebrow, title, description, labels, className } = props;
  const t = useTexts(TEXTS, labels);
  const body = (() => {
    switch (variant) {
      case 'method':
        return <Method {...props} t={t} />;
      case 'estimator':
        return <Estimator {...props} t={t} />;
      case 'references':
        return <References {...props} t={t} />;
      case 'brief':
        return <Brief {...props} t={t} />;
      default:
        return <Packages {...props} t={t} />;
    }
  })();
  return (
    <Section className={className} background={variant === 'brief' ? 'surface' : variant === 'estimator' ? 'glow' : 'default'}>
      <ConsMotion />
      {title ? <SectionHeader eyebrow={eyebrow} title={title} description={description} align={variant === 'brief' ? 'center' : variant === 'packages' ? 'split' : 'left'} /> : null}
      {body}
    </Section>
  );
}

Frequent questions

How do I install Consulting?

With aiw add @ai-website/consulting. The command puts components/ui/consulting.tsx into your project, the code is yours from then on. It brings along button, icon, motion and section. It needs no further npm packages.

Which variants does Consulting have?

5 variants, Packages, Method, Effort estimator, References and Brief. You pick one with the variant prop, for example variant="method".

Can I translate the texts of Consulting?

Yes. Consulting carries 44 texts of its own in English and German, such as “from”, “Length” and “You get”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Consulting?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Consulting with aiw_components and install it with aiw_add @ai-website/consulting. Name the variant in your prompt, for example variant="packages", and the assistant writes your content straight into the props.

Can I adjust Consulting without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching Consulting code to copy.

Often searched

Jump to

Copied to the clipboard