Freelancer
For freelancers and solo studios. An intro with live availability, the next months week by week, rates with a day calculator, a project enquiry with budget range and a typical week as a timetable.
One of 3 components in Personal brand
Variants
Details
What Freelancer brings along, read from the registry and the source.
- Category
- Personal brand
- Area
- Industries
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/freelancer.tsx
- Brings along
- utilskitbuttoniconmotionnumber-tickersection
- npm packages
- None
- Languages
- English and German, 52 texts of its own
- Studio
- All 5 variants editable
- In collections
- Portfolio
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The FreelancerProps interface from components/ui/freelancer.tsx, with the comments from the code.
variantoptionalFreelancerVariantintroavailabilityratesinquiryweek
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodepersonoptional{ name: string; role: string; photo?: React.ReactNode; location?: string; bio?: string }statusoptionalstringe.g. "Available from 3 March"
responseoptionalstringe.g. "Answers within a day"
statsoptional{ value: number; suffix?: string; label: string }[]skillsoptionalstring[]weeksoptionalFreelancerWeek[]ratesoptionalFreelancerRate[]dayRateoptionalnumberrates: price for one day, used by the calculator
typesoptional{ id: string; label: string; icon?: IconName }[]inquiry
budgetoptional{ min: number; max: number; step: number; from: number; to: number }blocksoptionalFreelancerBlock[]channelsoptional{ label: string; detail: string; icon: IconName }[]currencyoptionalstringonSubmitoptional(data: Record<string, string>) => voidlinksoptional{ availability?: string; work?: string; inquiry?: string }Where the jumps go, sections of the same page by default: #availability, #work, #inquiry
Built-in texts
Freelancer writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 52 texts
| Key | English | German |
|---|---|---|
| check | English Check availability | German Verfügbarkeit ansehen |
| askWeek | English Ask for this week | German Diese Woche anfragen |
| range | English {a} to {b} | German {a} bis {b} |
| write | English Write to {name} | German {name} schreiben |
| work | English See my work | German Arbeiten ansehen |
| booked | English Booked | German Ausgebucht |
| partly | English Partly free | German Teilweise frei |
| free | English Free | German Frei |
| firstFree | English First free week | German Erste freie Woche |
| week | English Week of {d} | German Woche ab {d} |
| legend | English Legend | German Legende |
| hour | English Hour | German Stunde |
| day | English Day | German Tag |
| project | English Project | German Projekt |
| pricing | English Pricing | German Abrechnung |
| days | English Days | German Tage |
| fewer | English One day less | German Einen Tag weniger |
| more | English One day more | German Einen Tag mehr |
| total | English Total for {n} days | German Gesamt für {n} Tage |
| discount | English {p}% off from 10 days | German {p} % Rabatt ab 10 Tagen |
| book | English Book these days | German Diese Tage buchen |
| type | English What do you need? | German Was brauchst du? |
| budget | English Budget | German Budget |
| budgetMin | English Lowest budget | German Niedrigstes Budget |
| budgetMax | English Highest budget | German Höchstes Budget |
| timeline | English When do you need it? | German Wann brauchst du es? |
| timelines | English Within a month|In 1 to 3 months|Later this year|Not sure yet | German Innerhalb eines Monats|In 1 bis 3 Monaten|Später im Jahr|Noch offen |
| about | English Tell me about the project | German Erzähl mir vom Projekt |
| aboutPh | English A new booking flow for our practice, the current one loses half the patients. | German Eine neue Terminbuchung für unsere Praxis, die jetzige verliert die Hälfte der Patienten. |
| name | English Name | German Name |
| English Email | German E-Mail | |
| emailPh | English you@example.com | German du@beispiel.de |
| send | English Send enquiry | German Anfrage 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. |
| pickType | English Pick what you need. | German Wähle, was du brauchst. |
| sentTitle | English Thanks, I will get back to you | German Danke, ich melde mich |
| sentText | English You get an answer within one working day with first questions and possible dates. | German Du bekommst innerhalb eines Werktags eine Antwort mit ersten Fragen und möglichen Terminen. |
| again | English Send another enquiry | German Weitere Anfrage senden |
| summary | English Your enquiry | German Deine Anfrage |
| open | English open | German offen |
| focus | English Focus work | German Fokusarbeit |
| call | English Calls | German Gespräche |
| admin | English Admin | German Verwaltung |
| off | English Off | German Frei |
| reach | English How to reach me | German So erreichst du mich |
| mon | English Mon | German Mo |
| tue | English Tue | German Di |
| wed | English Wed | German Mi |
| thu | English Thu | German Do |
| fri | English Fri | German Fr |
| now | English Now | German Jetzt |
Accessibility
Read from the source of the component.
- Freelancer handles the keys Arrow up, Arrow down, Arrow left and Arrow right itself.
- Sets aria-checked, aria-describedby, aria-invalid, aria-label and aria-live, so screen readers announce purpose and state.
- Uses the roles radio, 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.
- Shows a visible focus ring when used with the keyboard.
- 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. 4 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, 794 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 { Parallax, Stagger, Magnetic } from '@/components/ui/motion';
import { NumberTicker } from '@/components/ui/number-ticker';
import { Section, SectionHeader } from '@/components/ui/section';
/*
* Sections for freelancers: who is available when, what it costs, how an
* enquiry works and how a week with them looks.
*
* intro name, role, availability with a live dot, answer time, a few numbers and the main action
* availability the next months week by week, booked, partly free and free, the first free week marked
* rates hour, day or project behind a gliding switch, a small calculator for days
* inquiry project type, budget range with two handles, timeline and a summary before sending
* week a typical week as a timetable, focus blocks, calls and quiet times, how to reach them
*/
export type FreelancerVariant = 'intro' | 'availability' | 'rates' | 'inquiry' | 'week';
export interface FreelancerWeek {
/** Monday of the week, YYYY-MM-DD */
start: string;
/** 0 free, 0.5 partly, 1 booked */
load: number;
}
export interface FreelancerRate {
id: string;
label: string;
/** e.g. "per hour" */
unit: string;
price: number;
note?: string;
includes?: string[];
}
export interface FreelancerBlock {
/** 0 Monday to 4 Friday */
day: number;
/** Hours as numbers, e.g. 9 and 12.5 */
from: number;
to: number;
label: string;
kind: 'focus' | 'call' | 'admin' | 'off';
}
export interface FreelancerProps {
variant?: FreelancerVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
person?: { name: string; role: string; photo?: React.ReactNode; location?: string; bio?: string };
/** e.g. "Available from 3 March" */
status?: string;
/** e.g. "Answers within a day" */
response?: string;
stats?: { value: number; suffix?: string; label: string }[];
skills?: string[];
weeks?: FreelancerWeek[];
rates?: FreelancerRate[];
/** rates: price for one day, used by the calculator */
dayRate?: number;
/** inquiry */
types?: { id: string; label: string; icon?: IconName }[];
budget?: { min: number; max: number; step: number; from: number; to: number };
blocks?: FreelancerBlock[];
channels?: { label: string; detail: string; icon: IconName }[];
currency?: string;
onSubmit?: (data: Record<string, string>) => void;
/** Where the jumps go, sections of the same page by default: #availability, #work, #inquiry */
links?: { availability?: string; work?: string; inquiry?: string };
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
check: 'Check availability',
askWeek: 'Ask for this week',
range: '{a} to {b}',
write: 'Write to {name}',
work: 'See my work',
booked: 'Booked',
partly: 'Partly free',
free: 'Free',
firstFree: 'First free week',
week: 'Week of {d}',
legend: 'Legend',
hour: 'Hour',
day: 'Day',
project: 'Project',
pricing: 'Pricing',
days: 'Days',
fewer: 'One day less',
more: 'One day more',
total: 'Total for {n} days',
discount: '{p}% off from 10 days',
book: 'Book these days',
type: 'What do you need?',
budget: 'Budget',
budgetMin: 'Lowest budget',
budgetMax: 'Highest budget',
timeline: 'When do you need it?',
timelines: 'Within a month|In 1 to 3 months|Later this year|Not sure yet',
about: 'Tell me about the project',
aboutPh: 'A new booking flow for our practice, the current one loses half the patients.',
name: 'Name',
email: 'Email',
emailPh: 'you@example.com',
send: 'Send enquiry',
required: 'Please fill in this field.',
invalid: 'Please enter an email address like name@example.com.',
pickType: 'Pick what you need.',
sentTitle: 'Thanks, I will get back to you',
sentText: 'You get an answer within one working day with first questions and possible dates.',
again: 'Send another enquiry',
summary: 'Your enquiry',
open: 'open',
focus: 'Focus work',
call: 'Calls',
admin: 'Admin',
off: 'Off',
reach: 'How to reach me',
mon: 'Mon',
tue: 'Tue',
wed: 'Wed',
thu: 'Thu',
fri: 'Fri',
now: 'Now',
},
de: {
check: 'Verfügbarkeit ansehen',
askWeek: 'Diese Woche anfragen',
range: '{a} bis {b}',
write: '{name} schreiben',
work: 'Arbeiten ansehen',
booked: 'Ausgebucht',
partly: 'Teilweise frei',
free: 'Frei',
firstFree: 'Erste freie Woche',
week: 'Woche ab {d}',
legend: 'Legende',
hour: 'Stunde',
day: 'Tag',
project: 'Projekt',
pricing: 'Abrechnung',
days: 'Tage',
fewer: 'Einen Tag weniger',
more: 'Einen Tag mehr',
total: 'Gesamt für {n} Tage',
discount: '{p} % Rabatt ab 10 Tagen',
book: 'Diese Tage buchen',
type: 'Was brauchst du?',
budget: 'Budget',
budgetMin: 'Niedrigstes Budget',
budgetMax: 'Höchstes Budget',
timeline: 'Wann brauchst du es?',
timelines: 'Innerhalb eines Monats|In 1 bis 3 Monaten|Später im Jahr|Noch offen',
about: 'Erzähl mir vom Projekt',
aboutPh: 'Eine neue Terminbuchung für unsere Praxis, die jetzige verliert die Hälfte der Patienten.',
name: 'Name',
email: 'E-Mail',
emailPh: 'du@beispiel.de',
send: 'Anfrage senden',
required: 'Bitte fülle dieses Feld aus.',
invalid: 'Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein.',
pickType: 'Wähle, was du brauchst.',
sentTitle: 'Danke, ich melde mich',
sentText: 'Du bekommst innerhalb eines Werktags eine Antwort mit ersten Fragen und möglichen Terminen.',
again: 'Weitere Anfrage senden',
summary: 'Deine Anfrage',
open: 'offen',
focus: 'Fokusarbeit',
call: 'Gespräche',
admin: 'Verwaltung',
off: 'Frei',
reach: 'So erreichst du mich',
mon: 'Mo',
tue: 'Di',
wed: 'Mi',
thu: 'Do',
fri: 'Fr',
now: 'Jetzt',
},
};
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';
const fit = '[&>*]:size-full [&_img]:object-cover';
function money(value: number, currency: string, locale: Locale) {
return new Intl.NumberFormat(TAG[locale], { style: 'currency', currency, maximumFractionDigits: 0 }).format(value);
}
const utc = (iso: string) => {
const [y, m, d] = iso.split('-').map(Number);
return new Date(Date.UTC(y, m - 1, d));
};
const dfmt = (iso: string, locale: Locale, o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(TAG[locale], { timeZone: 'UTC', ...o }).format(utc(iso));
const MOTION_CSS = `
@keyframes kitfree-in{from{opacity:0;transform:translateY(8px)}}
@keyframes kitfree-swap{from{opacity:0;filter:blur(4px);transform:translateY(6px)}}
@keyframes kitfree-pop{0%{transform:scale(.5);opacity:0}60%{transform:scale(1.12);opacity:1}100%{transform:scale(1)}}
@keyframes kitfree-cell{from{opacity:0;transform:scale(.6)}}
@media (prefers-reduced-motion:reduce){[class*="kitfree-"],.kitfree-anim{animation:none!important;transition:none!important}}`;
function FreeMotion() {
return (
<style href="kitfree-motion" precedence="default">
{MOTION_CSS}
</style>
);
}
function useTween(value: number, ms = 450) {
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;
}
function Live({ text, tone = 'success' }: { text: string; tone?: 'success' | 'primary' }) {
const bg = tone === 'success' ? 'bg-success' : 'bg-primary';
return (
<span className={cn('inline-flex min-h-8 items-center gap-2 self-start rounded-full px-3 text-[13.5px] font-medium', tone === 'success' ? 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground' : 'bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-foreground')}>
<span className="relative flex size-2">
<span aria-hidden className={cn('absolute inset-0 animate-ping rounded-full opacity-60 motion-reduce:animate-none', bg)} />
<span className={cn('relative size-2 rounded-full', bg)} />
</span>
{text}
</span>
);
}
/** A row of choices with a marker that glides to the active one */
function Segmented({ options, value, onChange, label, className }: { options: { id: string; label: React.ReactNode }[]; value: string; onChange: (id: string) => void; label: string; className?: string }) {
const wrap = useRef<HTMLDivElement>(null);
const [box, setBox] = useState<{ x: number; w: number } | null>(null);
useEffect(() => {
const el = wrap.current;
if (!el) return;
const measure = () => {
const b = el.querySelector<HTMLElement>('[aria-checked="true"]');
if (b) setBox({ x: b.offsetLeft, w: b.offsetWidth });
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [value]);
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 = options.findIndex((o) => o.id === value);
const n = options[(i + d + options.length) % options.length];
onChange(n.id);
wrap.current?.querySelector<HTMLElement>(`[data-v="${n.id}"]`)?.focus();
};
return (
<div ref={wrap} role="radiogroup" aria-label={label} onKeyDown={key} className={cn('relative inline-flex max-w-full rounded-xl border border-border bg-muted p-1', className)}>
{box && <span aria-hidden className="absolute bottom-1 left-0 top-1 rounded-lg bg-card shadow-[var(--highlight),var(--shadow-sm)] ring-1 ring-border-strong transition-[transform,width] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: box.w, transform: `translateX(${box.x}px)` }} />}
{options.map((o) => {
const on = o.id === value;
return (
<button key={o.id} type="button" role="radio" data-v={o.id} aria-checked={on} tabIndex={on ? 0 : -1} onClick={() => onChange(o.id)} className={cn('relative z-[1] inline-flex min-h-10 flex-1 items-center justify-center whitespace-nowrap rounded-lg px-4 text-[14px] font-medium transition-colors max-sm:min-h-11 sm:flex-none', focusRing, on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', on && !box && 'bg-card shadow-[var(--shadow-sm)]')}>
{o.label}
</button>
);
})}
</div>
);
}
/* ------------------------------------------------------------------ */
/* intro */
/* ------------------------------------------------------------------ */
function Intro({ person, status, response, stats = [], skills = [], links, t }: FreelancerProps & { t: Texts }) {
if (!person) return null;
return (
<div className="grid grid-cols-1 items-center gap-12 lg:grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)] lg:gap-16">
<div>
<div className="kit-rise flex flex-wrap items-center gap-2">
{status && <Live text={status} />}
{response && (
<span className="inline-flex min-h-8 items-center gap-1.5 rounded-full border border-border px-3 text-[13.5px] text-muted-foreground">
<Icon name="clock" size={14} />
{response}
</span>
)}
</div>
<h2 className="kit-rise mt-6 text-balance text-h1 font-semibold text-foreground [--kit-delay:60ms]">{person.role}</h2>
{person.bio && <p className="kit-rise mt-5 max-w-[36rem] text-pretty text-lead text-muted-foreground [--kit-delay:120ms]">{person.bio}</p>}
<div className="kit-rise mt-8 flex flex-wrap gap-3 [--kit-delay:180ms] max-sm:flex-col">
<Magnetic className="max-sm:flex max-sm:w-full">
<Button href={links?.availability ?? '#availability'} icon="arrow-right" className="max-sm:w-full">
{t.check}
</Button>
</Magnetic>
<Button href={links?.work ?? '#work'} variant="outline" className="max-sm:w-full">
{t.work}
</Button>
</div>
{skills.length > 0 && (
<Stagger as="ul" step={40} className="mt-8 flex flex-wrap gap-1.5">
{skills.map((s) => (
<li key={s} className="rounded-full border border-border px-3 py-1 text-[13px] text-foreground transition-colors hover:border-border-strong">
{s}
</li>
))}
</Stagger>
)}
</div>
<div className="relative mx-auto w-full max-w-[440px]">
<div className="kit-card relative overflow-hidden">
<div className="relative aspect-[4/3] overflow-hidden bg-muted" data-clip-ok>
<Parallax speed={40} className={cn('absolute -inset-y-6 inset-x-0', fit)}>
{person.photo}
</Parallax>
</div>
<div className="flex items-center justify-between gap-3 border-b border-border px-5 py-4">
<div>
<p className="text-[16px] font-semibold text-foreground">{person.name}</p>
{person.location && <p className="text-[13.5px] text-muted-foreground">{person.location}</p>}
</div>
<a href={links?.inquiry ?? '#inquiry'} aria-label={fill(t.write, { name: person.name })} className={cn('flex size-11 items-center justify-center rounded-full border border-border text-foreground transition-[background-color,transform] duration-300 hover:-translate-y-0.5 hover:bg-accent', focusRing)}>
<Icon name="mail" size={18} />
</a>
</div>
{stats.length > 0 && (
<dl className="grid grid-cols-3 divide-x divide-border">
{stats.slice(0, 3).map((s, i) => (
<div key={s.label} className="flex flex-col-reverse justify-end px-4 py-4">
<dt className="mt-1 text-[12px] leading-snug text-muted-foreground">{s.label}</dt>
<dd className="text-[22px] font-semibold leading-none tracking-[-0.02em] text-foreground">
<NumberTicker value={s.value} delay={i * 120} />
{s.suffix}
</dd>
</div>
))}
</dl>
)}
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* availability */
/* ------------------------------------------------------------------ */
function Availability({ weeks = [], status, links, t }: FreelancerProps & { t: Texts }) {
const locale = useLocale();
const [ref, inView] = useInView<HTMLDivElement>();
const [hover, setHover] = useState<number | null>(null);
const firstFree = weeks.findIndex((w) => w.load === 0);
const months: { key: string; label: string; items: { w: FreelancerWeek; i: number }[] }[] = [];
weeks.forEach((w, i) => {
const key = w.start.slice(0, 7);
let m = months.find((x) => x.key === key);
if (!m) months.push((m = { key, label: dfmt(w.start, locale, months.length === 0 || key.endsWith('-01') ? { month: 'long', year: 'numeric' } : { month: 'long' }), items: [] }));
m.items.push({ w, i });
});
const tone = (load: number) => (load >= 1 ? 'bg-[color-mix(in_oklab,var(--foreground)_14%,transparent)]' : load > 0 ? 'bg-[color-mix(in_oklab,var(--primary)_20%,var(--card))] ring-1 ring-inset ring-[color-mix(in_oklab,var(--primary)_50%,transparent)]' : 'bg-primary');
const shown = hover ?? (firstFree >= 0 ? firstFree : null);
const w = shown !== null ? weeks[shown] : undefined;
const stateText = (l: number) => (l >= 1 ? t.booked : l > 0 ? t.partly : t.free);
return (
<div ref={ref} id="availability">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
{status && <Live text={status} />}
<ul aria-label={t.legend} className="flex flex-wrap gap-x-5 gap-y-2 text-[13px] text-muted-foreground">
{[1, 0.5, 0].map((l) => (
<li key={l} className="inline-flex items-center gap-2">
<span className={cn('size-3.5 rounded-[4px]', tone(l))} />
{stateText(l)}
</li>
))}
</ul>
</div>
<div className="mt-8 grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 lg:grid-cols-6">
{months.map((m, mi) => (
<div key={m.key}>
<p className="text-[14px] font-semibold capitalize text-foreground">{m.label}</p>
<ul className="mt-3 grid gap-1.5">
{m.items.map(({ w, i }) => {
const first = i === firstFree;
return (
<li key={w.start}>
<button type="button" onPointerEnter={() => setHover(i)} onPointerLeave={() => setHover(null)} onFocus={() => setHover(i)} onBlur={() => setHover(null)} aria-label={`${fill(t.week, { d: dfmt(w.start, locale, { day: 'numeric', month: 'long' }) })}, ${stateText(w.load)}`} className={cn('relative flex h-11 w-full items-center justify-between rounded-lg px-2.5 sm:h-10 text-[12.5px] font-medium tabular-nums transition-[transform,opacity] duration-500 ease-[var(--ease-kit)] hover:-translate-y-0.5 motion-reduce:transition-none', focusRing, tone(w.load), w.load === 0 ? 'text-primary-foreground' : 'text-foreground', first && 'ring-2 ring-primary ring-offset-2 ring-offset-background')} style={{ opacity: inView ? 1 : 0, transform: inView ? undefined : 'scale(0.85)', transitionDelay: inView ? `${mi * 80 + i * 18}ms` : '0ms' }}>
<span>{dfmt(w.start, locale, { day: 'numeric', month: locale === 'de' ? '2-digit' : 'numeric' })}</span>
{first && <Icon name="sparkle" size={13} />}
</button>
</li>
);
})}
</ul>
</div>
))}
</div>
<div className="mt-8 flex min-h-12 flex-wrap items-center justify-between gap-4 rounded-xl border border-border bg-card px-5 py-3">
{w && (
<p key={w.start} className="text-[14.5px] text-foreground [animation:kitfree-swap_.3s_var(--ease-kit)]" aria-live="polite">
<span className="text-muted-foreground">{hover === null ? t.firstFree : fill(t.week, { d: '' }).trim()} </span>
<span className="font-semibold">{dfmt(w.start, locale, { day: 'numeric', month: 'long', year: 'numeric' })}</span>
<span className="text-muted-foreground"> · {stateText(w.load)}</span>
</p>
)}
<Button href={links?.inquiry ?? '#inquiry'} icon="arrow-right" className="max-sm:w-full">
{t.askWeek}
</Button>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* rates */
/* ------------------------------------------------------------------ */
function Rates({ rates = [], dayRate, currency = 'EUR', links, t }: FreelancerProps & { t: Texts }) {
const locale = useLocale();
const [sel, setSel] = useState(rates[1]?.id ?? rates[0]?.id ?? '');
const r = rates.find((x) => x.id === sel) ?? rates[0];
const price = useTween(r?.price ?? 0);
const rate = dayRate ?? rates.find((x) => x.id === 'day')?.price ?? 0;
const [days, setDays] = useState(5);
const pct = days >= 10 ? 10 : 0;
const total = Math.round(days * rate * (1 - pct / 100));
const tw = useTween(total);
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)] lg:gap-8">
<div className="kit-card p-6 sm:p-8">
<Segmented label={t.pricing} value={sel} onChange={setSel} options={rates.map((x) => ({ id: x.id, label: x.label }))} className="flex w-full sm:inline-flex sm:w-auto" />
{r && (
<div className="mt-8">
<p className="flex items-baseline gap-2">
<span className="text-[52px] font-semibold leading-none tracking-[-0.035em] tabular-nums text-foreground">{money(Math.round(price), currency, locale)}</span>
<span key={r.id} className="text-[15px] text-muted-foreground [animation:kitfree-in_.35s_var(--ease-kit)]">
{r.unit}
</span>
</p>
{r.note && (
<p key={`n${r.id}`} className="mt-3 text-pretty text-[15px] text-muted-foreground [animation:kitfree-swap_.4s_var(--ease-kit)]">
{r.note}
</p>
)}
{r.includes?.length ? (
<ul key={`i${r.id}`} className="mt-6 grid gap-2.5 border-t border-border pt-6 sm:grid-cols-2">
{r.includes.map((x, i) => (
<li key={x} className="flex gap-2.5 text-[14.5px] text-foreground [animation:kitfree-in_.4s_var(--ease-kit)_both]" style={{ animationDelay: `${i * 50}ms` }}>
<Icon name="check" size={16} className="mt-[3px] text-primary" />
{x}
</li>
))}
</ul>
) : null}
</div>
)}
</div>
{rate > 0 && (
<div className="rounded-2xl border border-border bg-surface p-6 sm:p-8">
<p className="text-[15px] font-semibold text-foreground">{t.days}</p>
<div className="mt-4 flex items-center gap-3">
<button type="button" aria-label={t.fewer} disabled={days <= 1} onClick={() => setDays((d) => Math.max(1, d - 1))} className={cn('flex size-11 items-center justify-center rounded-lg border border-border bg-card text-foreground transition-colors hover:bg-accent disabled:opacity-40', focusRing)}>
<Icon name="minus" size={18} />
</button>
<output aria-live="polite" className="min-w-16 text-center text-[32px] font-semibold tabular-nums text-foreground">
<span key={days} className="inline-block [animation:kitfree-pop_.3s_var(--ease-kit)]">
{days}
</span>
</output>
<button type="button" aria-label={t.more} disabled={days >= 40} onClick={() => setDays((d) => Math.min(40, d + 1))} className={cn('flex size-11 items-center justify-center rounded-lg border border-border bg-card text-foreground transition-colors hover:bg-accent disabled:opacity-40', focusRing)}>
<Icon name="plus" size={18} />
</button>
</div>
<div className="mt-5 flex flex-wrap gap-1" aria-hidden>
{Array.from({ length: Math.min(days, 40) }, (_, i) => (
<span key={i} className="size-3 rounded-[3px] bg-primary [animation:kitfree-cell_.25s_var(--ease-kit)_both]" />
))}
</div>
<div className="mt-6 border-t border-border pt-5">
<p className="text-[13px] text-muted-foreground">{fill(t.total, { n: days })}</p>
<p className="mt-1 text-[30px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground">{money(Math.round(tw), currency, locale)}</p>
<p className={cn('mt-2 text-[13px] transition-colors', pct ? 'font-medium text-success-foreground' : 'text-muted-foreground')}>{fill(t.discount, { p: 10 })}</p>
<Button href={links?.inquiry ?? '#inquiry'} icon="arrow-right" className="mt-5 w-full">
{t.book}
</Button>
</div>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* inquiry */
/* ------------------------------------------------------------------ */
function Inquiry({ types = [], budget = { min: 1000, max: 30000, step: 500, from: 4000, to: 12000 }, currency = 'EUR', onSubmit, t }: FreelancerProps & { t: Texts }) {
const locale = useLocale();
const base = useId();
const [type, setType] = useState<string[]>([]);
const [lo, setLo] = useState(budget.from);
const [hi, setHi] = useState(budget.to);
const [time, setTime] = useState(1);
const [about, setAbout] = useState('');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const [sent, setSent] = useState(false);
const times = t.timelines.split('|');
const span = budget.max - budget.min;
const pl = round(((lo - budget.min) / span) * 100, 2);
const ph = round(((hi - budget.min) / span) * 100, 2);
const submit = (e: React.FormEvent) => {
e.preventDefault();
const next: Record<string, string> = {};
if (!type.length) next.type = t.pickType;
if (!name.trim()) next.name = t.required;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) next.email = t.invalid;
setErrors(next);
if (Object.keys(next).length) return document.getElementById(`${base}-${Object.keys(next)[0]}`)?.focus();
onSubmit?.({ type: type.join(', '), budget: `${lo}-${hi}`, time: times[time], about, name, email });
setSent(true);
};
const thumb = '[&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-6 [&::-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]:pointer-events-auto [&::-webkit-slider-thumb]:size-6 [&::-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';
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 id="inquiry" role="status" className="kit-card mx-auto max-w-[600px] p-8 text-center [animation:kitfree-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:kitfree-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)}>
{t.again}
</Button>
</div>
);
return (
<form id="inquiry" onSubmit={submit} noValidate className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)] lg:gap-12">
<div className="grid gap-8">
<fieldset>
<legend className="mb-3 text-[15px] font-semibold text-foreground">{t.type}</legend>
<div id={`${base}-type`} tabIndex={-1} className="grid grid-cols-2 gap-2.5 sm:grid-cols-3">
{types.map((x) => {
const on = type.includes(x.id);
return (
<label key={x.id} className={cn('flex min-h-[72px] cursor-pointer flex-col justify-between gap-2 rounded-xl border p-3.5 transition-[border-color,background-color,transform] duration-300 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring', on ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))]' : 'border-border bg-card hover:-translate-y-0.5 hover:border-border-strong')}>
<input type="checkbox" checked={on} onChange={() => { setType((v) => (on ? v.filter((y) => y !== x.id) : [...v, x.id])); setErrors((e) => ({ ...e, type: '' })); }} className="sr-only" />
<span className="flex items-center justify-between">
<Icon name={x.icon ?? 'sparkle'} size={19} className={on ? 'text-primary' : 'text-muted-foreground'} />
<span aria-hidden className={cn('flex size-5 items-center justify-center rounded-full border-2 transition-colors', on ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong')}>{on && <Icon name="check" size={11} strokeWidth={2.6} />}</span>
</span>
<span className="text-[14px] font-medium leading-snug text-foreground">{x.label}</span>
</label>
);
})}
</div>
{errors.type && <p className="mt-2 text-[13px] text-destructive">{errors.type}</p>}
</fieldset>
<fieldset>
<legend className="flex w-full items-baseline justify-between gap-3 text-[15px] font-semibold text-foreground">
{t.budget}
<span className="text-[15px] font-semibold tabular-nums text-foreground" aria-hidden>
{fill(t.range, { a: money(lo, currency, locale), b: money(hi, currency, locale) })}
</span>
</legend>
<div className="relative mt-4 h-11">
<div aria-hidden className="pointer-events-none absolute inset-x-3 top-1/2 h-2 -translate-y-1/2 rounded-full bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]">
<div className="absolute inset-y-0 rounded-full bg-primary" style={{ left: `${pl}%`, right: `${round(100 - ph, 2)}%` }} />
</div>
<input type="range" aria-label={t.budgetMin} min={budget.min} max={budget.max} step={budget.step} value={lo} onChange={(e) => setLo(Math.min(Number(e.target.value), hi - budget.step))} className={cn('pointer-events-none absolute inset-0 h-11 w-full appearance-none bg-transparent', thumb)} />
<input type="range" aria-label={t.budgetMax} min={budget.min} max={budget.max} step={budget.step} value={hi} onChange={(e) => setHi(Math.max(Number(e.target.value), lo + budget.step))} className={cn('pointer-events-none absolute inset-0 h-11 w-full appearance-none bg-transparent', thumb)} />
</div>
</fieldset>
<fieldset>
<legend className="mb-3 text-[15px] font-semibold text-foreground">{t.timeline}</legend>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{times.map((x, i) => (
<label key={x} className={cn('flex min-h-11 cursor-pointer items-center justify-center rounded-lg border px-2 text-center text-[13.5px] font-medium transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring', time === i ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))] font-semibold text-foreground' : 'border-border bg-card text-muted-foreground hover:border-border-strong hover:text-foreground')}>
<input type="radio" name={`${base}-time`} checked={time === i} onChange={() => setTime(i)} className="sr-only" />
{x}
</label>
))}
</div>
</fieldset>
<div>
<label htmlFor={`${base}-about`} className="mb-2 block text-[15px] font-semibold text-foreground">
{t.about}
</label>
<textarea id={`${base}-about`} rows={4} value={about} onChange={(e) => setAbout(e.target.value)} placeholder={t.aboutPh} className={cn('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>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
{(['name', '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} value={k === 'name' ? name : email} onChange={(e) => (k === 'name' ? setName : setEmail)(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>
<aside className="lg:sticky lg:top-8 lg:self-start">
<div className="kit-card overflow-hidden">
<p className="border-b border-border px-6 py-4 text-[13px] font-medium text-muted-foreground">{t.summary}</p>
<dl className="divide-y divide-border px-6">
{[
[t.type, type.length ? types.filter((x) => type.includes(x.id)).map((x) => x.label).join(', ') : t.open],
[t.budget, fill(t.range, { a: money(lo, currency, locale), b: money(hi, currency, locale) })],
[t.timeline, times[time]],
].map(([k, v]) => (
<div key={k} className="grid gap-1 py-3.5">
<dt className="text-[13px] text-muted-foreground">{k}</dt>
<dd key={v} className="text-[14.5px] font-medium text-foreground [animation:kitfree-swap_.35s_var(--ease-kit)]">
{v}
</dd>
</div>
))}
</dl>
<div className="border-t border-border p-6">
<Button type="submit" icon="send" className="w-full">
{t.send}
</Button>
</div>
</div>
</aside>
</form>
);
}
/* ------------------------------------------------------------------ */
/* week */
/* ------------------------------------------------------------------ */
function Week({ blocks = [], channels = [], t }: FreelancerProps & { t: Texts }) {
const [ref, inView] = useInView<HTMLDivElement>();
const [now, setNow] = useState<{ day: number; hour: number } | null>(null);
useEffect(() => {
const d = new Date();
const day = (d.getDay() + 6) % 7;
if (day < 5) setNow({ day, hour: d.getHours() + d.getMinutes() / 60 });
}, []);
const startH = 8;
const endH = 18;
const hours = endH - startH;
const days = [t.mon, t.tue, t.wed, t.thu, t.fri];
const kinds: Record<FreelancerBlock['kind'], { label: string; cls: string }> = {
focus: { label: t.focus, cls: 'bg-primary text-primary-foreground' },
call: { label: t.call, cls: 'bg-[color-mix(in_oklab,var(--primary)_18%,var(--card))] text-foreground ring-1 ring-inset ring-[color-mix(in_oklab,var(--primary)_40%,transparent)]' },
admin: { label: t.admin, cls: 'bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)] text-foreground' },
off: { label: t.off, cls: 'bg-[repeating-linear-gradient(135deg,color-mix(in_oklab,var(--foreground)_8%,transparent)_0_5px,transparent_5px_10px)] text-muted-foreground' },
};
const top = (h: number) => round(((h - startH) / hours) * 100, 3);
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1.7fr)_minmax(0,1fr)] lg:gap-10">
<div ref={ref} className="kit-card overflow-hidden p-4 sm:p-6">
<ul aria-label={t.legend} className="mb-5 flex flex-wrap gap-x-5 gap-y-2 text-[13px] text-muted-foreground">
{(Object.keys(kinds) as FreelancerBlock['kind'][]).map((k) => (
<li key={k} className="inline-flex items-center gap-2">
<span className={cn('size-3.5 rounded-[4px]', kinds[k].cls)} />
{kinds[k].label}
</li>
))}
</ul>
<div className="grid grid-cols-[2.25rem_repeat(5,minmax(0,1fr))] gap-x-1.5 sm:grid-cols-[2.75rem_repeat(5,minmax(0,1fr))] sm:gap-x-2">
<span />
{days.map((d, i) => (
<span key={d} className={cn('pb-2 text-center text-[13px] font-medium', now?.day === i ? 'text-primary' : 'text-muted-foreground')}>
{d}
</span>
))}
<div className="relative h-[440px] sm:h-[480px]">
{Array.from({ length: hours + 1 }, (_, i) => (
<span key={i} className="absolute right-1.5 -translate-y-1/2 text-[11.5px] tabular-nums text-subtle-foreground" style={{ top: `${top(startH + i)}%` }}>
{startH + i}
</span>
))}
</div>
{days.map((d, di) => (
<div key={d} className="relative h-[440px] rounded-lg bg-[repeating-linear-gradient(to_bottom,var(--border)_0_1px,transparent_1px_calc(100%/10))] sm:h-[480px]">
{blocks
.filter((b) => b.day === di)
.map((b, bi) => (
<div key={bi} className={cn('absolute inset-x-0.5 overflow-hidden rounded-md px-1.5 py-1 text-[11px] font-medium leading-tight transition-[transform,opacity] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none sm:px-2 sm:text-[12px]', kinds[b.kind].cls)} style={{ top: `${top(b.from)}%`, height: `${round(top(b.to) - top(b.from), 3)}%`, transformOrigin: 'top', transform: inView ? 'scaleY(1)' : 'scaleY(0)', opacity: inView ? 1 : 0, transitionDelay: inView ? `${di * 90 + bi * 60}ms` : '0ms' }}>
<span className="line-clamp-3 max-sm:sr-only">{b.label}</span>
</div>
))}
{now && now.day === di && now.hour >= startH && now.hour <= endH && (
<span aria-hidden className="absolute inset-x-0 z-[1] h-0.5 bg-destructive [animation:kitfree-in_.4s_var(--ease-kit)]" style={{ top: `${top(now.hour)}%` }}>
<span className="absolute -left-1 -top-[3px] size-2 rounded-full bg-destructive" />
</span>
)}
</div>
))}
</div>
</div>
{channels.length > 0 && (
<div>
<p className="text-[15px] font-semibold text-foreground">{t.reach}</p>
<Stagger as="ul" step={70} className="mt-4 grid gap-3">
{channels.map((c) => (
<li key={c.label} className="flex items-start gap-4 rounded-xl border border-border bg-card p-4 transition-[transform,border-color] duration-300 hover:-translate-y-0.5 hover:border-border-strong">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] text-primary">
<Icon name={c.icon} size={18} />
</span>
<span>
<span className="block text-[15px] font-semibold text-foreground">{c.label}</span>
<span className="mt-0.5 block text-pretty text-[14px] leading-relaxed text-muted-foreground">{c.detail}</span>
</span>
</li>
))}
</Stagger>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Freelancer */
/* ------------------------------------------------------------------ */
/**
* Sections for freelancers.
* intro role, live availability, answer time, numbers, skills
* availability the next months week by week, the first free week marked
* rates hour, day or project, a calculator for days
* inquiry type, budget range, timeline and a summary
* week a typical week as a timetable, channels beside it
*/
export function Freelancer(props: FreelancerProps) {
const { variant = 'intro', eyebrow, title, description, labels, className } = props;
const t = useTexts(TEXTS, labels);
const body = (() => {
switch (variant) {
case 'availability':
return <Availability {...props} t={t} />;
case 'rates':
return <Rates {...props} t={t} />;
case 'inquiry':
return <Inquiry {...props} t={t} />;
case 'week':
return <Week {...props} t={t} />;
default:
return <Intro {...props} t={t} />;
}
})();
return (
<Section className={className} background={variant === 'intro' ? 'glow' : variant === 'rates' ? 'default' : variant === 'inquiry' ? 'surface' : 'default'}>
<FreeMotion />
{title && variant !== 'intro' ? <SectionHeader eyebrow={eyebrow} title={title} description={description} /> : null}
{body}
</Section>
);
}Frequent questions
How do I install Freelancer?
With aiw add @ai-website/freelancer. The command puts components/ui/freelancer.tsx into your project, the code is yours from then on. It brings along button, icon, motion, number-ticker and section. It needs no further npm packages.
Which variants does Freelancer have?
5 variants, Intro with availability, Availability, Rates, Project enquiry and A typical week. You pick one with the variant prop, for example variant="availability".
Can I translate the texts of Freelancer?
Yes. Freelancer carries 52 texts of its own in English and German, such as “Check availability”, “Ask for this week” and “See my work”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Freelancer?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Freelancer with aiw_components and install it with aiw_add @ai-website/freelancer. Name the variant in your prompt, for example variant="intro", and the assistant writes your content straight into the props.
Can I adjust Freelancer without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching Freelancer code to copy.