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
Variants
Details
What Consulting brings along, read from the registry and the source.
- Category
- Coaching and consulting
- Area
- Industries
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/consulting.tsx
- npm packages
- None
- Languages
- English and German, 44 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ConsultingProps interface from components/ui/consulting.tsx, with the comments from the code.
variantoptionalConsultingVariantpackagesmethodestimatorreferencesbrief
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodepackagesoptionalConsultingPackage[]phasesoptionalConsultingPhase[]referencesoptionalConsultingReference[]factorsoptionalConsultingFactor[]estimator
baseDaysoptionalnumberdayRateoptionalnumbertopicsoptionalstring[]brief: topics to pick from
currencyoptionalstringonSubmitoptional(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
| Key | English | German |
|---|---|---|
| from | English from | German ab |
| length | English Length | German Dauer |
| deliverables | English You get | German Du bekommst |
| fit | English Right for | German Passt für |
| request | English Request this package | German Paket anfragen |
| phase | English Phase {n} | German Phase {n} |
| activities | English What we do | German Was wir tun |
| result | English Result | German Ergebnis |
| estimate | English Estimate | German Schätzung |
| days | English {n} consulting days | German {n} Beratertage |
| range | English Likely between | German Voraussichtlich zwischen |
| and | English and | German und |
| weeks | English about {n} weeks | German etwa {n} Wochen |
| proposal | English Request a proposal | German Angebot anfragen |
| note | English 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. |
| challenge | English Challenge | German Ausgangslage |
| approach | English Approach | German Vorgehen |
| outcome | English Result | German Ergebnis |
| clients | English Clients | German Kunden |
| step | English Step {n} of {total} | German Schritt {n} von {total} |
| s1 | English Topic | German Thema |
| s2 | English Situation | German Lage |
| s3 | English Contact | German Kontakt |
| topicsQ | English What is it about? | German Worum geht es? |
| pickOne | English Pick at least one topic. | German Wähle mindestens ein Thema. |
| situation | English Where do you stand today? | German Wo steht ihr heute? |
| situationPh | English We are 80 people in two offices and decisions take weeks. | German Wir sind 80 Leute an zwei Standorten und Entscheidungen dauern Wochen. |
| size | English Size of the organisation | German Größe der Organisation |
| sizes | English Up to 20|20 to 100|100 to 500|More than 500 | German Bis 20|20 bis 100|100 bis 500|Mehr als 500 |
| when | English When should it start? | German Wann soll es losgehen? |
| whens | English Now|This quarter|Later this year | German Sofort|Dieses Quartal|Später im Jahr |
| name | English Name | German Name |
| company | English Organisation | German Organisation |
| English Email | German E-Mail | |
| emailPh | English you@example.com | German du@beispiel.de |
| back | English Back | German Zurück |
| next | English Next | German Weiter |
| send | English Send brief | German Briefing senden |
| required | English Please fill in this field. | German Bitte fülle dieses Feld aus. |
| invalid | English Please enter an email address like name@example.com. | German Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein. |
| sentTitle | English Brief received | German Briefing ist angekommen |
| sentText | English 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. |
| again | English Write another brief | German Weiteres Briefing schreiben |
| summary | English Your brief | German 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
'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.