Skip to content
UI Kit
Accent color

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

5 variants

Variants

Intro with availability

The role as the headline, availability with a live dot, answer time, skills that rise in one after another. A card with portrait and three numbers that count up.

Open in Studio

Availability

The next six months week by week as booked, partly free and free. The first free week is marked, pointing at a week tells its state, cells appear month by month.

Open in Studio

Rates

Hour, day or project behind a gliding switch, the price glides and what is included changes. A calculator beside it counts days as blocks and shows the total.

Open in Studio

Project enquiry

What is needed as cards, a budget range with two handles, the timeline and a few words. The summary beside it follows every choice.

Open in Studio

A typical week

A week as a timetable, focus blocks, calls, admin and time off. The blocks grow in day by day, a red line marks now. How to reach me beside it.

Open in Studio

Details

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

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
In collections
Portfolio

Props

The FreelancerProps interface from components/ui/freelancer.tsx, with the comments from the code.

  • variantoptional
    FreelancerVariant

    introavailabilityratesinquiryweek

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • personoptional
    { name: string; role: string; photo?: React.ReactNode; location?: string; bio?: string }
  • statusoptional
    string

    e.g. "Available from 3 March"

  • responseoptional
    string

    e.g. "Answers within a day"

  • statsoptional
    { value: number; suffix?: string; label: string }[]
  • skillsoptional
    string[]
  • weeksoptional
    FreelancerWeek[]
  • ratesoptional
    FreelancerRate[]
  • dayRateoptional
    number

    rates: 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 }
  • blocksoptional
    FreelancerBlock[]
  • channelsoptional
    { label: string; detail: string; icon: IconName }[]
  • currencyoptional
    string
  • onSubmitoptional
    (data: Record<string, string>) => void
  • linksoptional
    { 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
checkEnglish Check availabilityGerman Verfügbarkeit ansehen
askWeekEnglish Ask for this weekGerman Diese Woche anfragen
rangeEnglish {a} to {b}German {a} bis {b}
writeEnglish Write to {name}German {name} schreiben
workEnglish See my workGerman Arbeiten ansehen
bookedEnglish BookedGerman Ausgebucht
partlyEnglish Partly freeGerman Teilweise frei
freeEnglish FreeGerman Frei
firstFreeEnglish First free weekGerman Erste freie Woche
weekEnglish Week of {d}German Woche ab {d}
legendEnglish LegendGerman Legende
hourEnglish HourGerman Stunde
dayEnglish DayGerman Tag
projectEnglish ProjectGerman Projekt
pricingEnglish PricingGerman Abrechnung
daysEnglish DaysGerman Tage
fewerEnglish One day lessGerman Einen Tag weniger
moreEnglish One day moreGerman Einen Tag mehr
totalEnglish Total for {n} daysGerman Gesamt für {n} Tage
discountEnglish {p}% off from 10 daysGerman {p} % Rabatt ab 10 Tagen
bookEnglish Book these daysGerman Diese Tage buchen
typeEnglish What do you need?German Was brauchst du?
budgetEnglish BudgetGerman Budget
budgetMinEnglish Lowest budgetGerman Niedrigstes Budget
budgetMaxEnglish Highest budgetGerman Höchstes Budget
timelineEnglish When do you need it?German Wann brauchst du es?
timelinesEnglish Within a month|In 1 to 3 months|Later this year|Not sure yetGerman Innerhalb eines Monats|In 1 bis 3 Monaten|Später im Jahr|Noch offen
aboutEnglish Tell me about the projectGerman Erzähl mir vom Projekt
aboutPhEnglish 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.
nameEnglish NameGerman Name
emailEnglish EmailGerman E-Mail
emailPhEnglish you@example.comGerman du@beispiel.de
sendEnglish Send enquiryGerman Anfrage 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.
pickTypeEnglish Pick what you need.German Wähle, was du brauchst.
sentTitleEnglish Thanks, I will get back to youGerman Danke, ich melde mich
sentTextEnglish 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.
againEnglish Send another enquiryGerman Weitere Anfrage senden
summaryEnglish Your enquiryGerman Deine Anfrage
openEnglish openGerman offen
focusEnglish Focus workGerman Fokusarbeit
callEnglish CallsGerman Gespräche
adminEnglish AdminGerman Verwaltung
offEnglish OffGerman Frei
reachEnglish How to reach meGerman So erreichst du mich
monEnglish MonGerman Mo
tueEnglish TueGerman Di
wedEnglish WedGerman Mi
thuEnglish ThuGerman Do
friEnglish FriGerman Fr
nowEnglish NowGerman 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
components/ui/freelancer.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 { 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.

Often searched

Jump to

Copied to the clipboard