Skip to content
UI Kit
Accent color

Artist

For painters, sculptors and studios. Works by series with the red dot, one work in a room for scale, exhibitions with what is on now, a statement that lights up while reading and commissions.

One of 3 components in Artists and musicians

5 variants

Variants

Works

Works in their real proportions, filtered by series behind a gliding switch. Title, year, medium and size under each, a red dot when sold, the picture moves closer on hover.

Open in Studio

Artwork with room view

One work large. A switch hangs it above a sofa of 220 cm so its size is clear at a glance. Frame choice, the price follows, enquire or buy.

Open in Studio

Exhibitions

What is on view now comes first with a live dot and a picture that drifts. Upcoming shows as cards, past ones by year as a quiet list.

Open in Studio

Statement

The artist statement in large type. Its words light up one by one while you scroll through, the studio picture beside it drifts slower than the page.

Open in Studio

Commission

Three sizes with price and time. A drawing puts the chosen canvas next to a person so its size feels real, the canvas grows and shrinks. A few words about the idea and a request.

Open in Studio

Details

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

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

Props

The ArtistProps interface from components/ui/artist.tsx, with the comments from the code.

  • variantoptional
    ArtistVariant

    worksartworkexhibitionsstatementcommission

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • worksoptional
    ArtWork[]
  • workoptional
    ArtWork & { description?: string; frames?: { id: string; label: string; price: number; /** Colour of the frame, shown around the picture */ color?: string }[] }

    artwork: the work shown

  • exhibitionsoptional
    ArtExhibition[]
  • statementoptional
    string

    statement

  • signatureoptional
    string
  • photooptional
    React.ReactNode
  • sizesoptional
    ArtCommissionSize[]
  • todayoptional
    string

    Today for the exhibition states, YYYY-MM-DD. Without it the browser date is used after loading.

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

Built-in texts

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

Show all 33 texts
allEnglish All worksGerman Alle Werke
seriesEnglish SeriesGerman Serie
soldEnglish SoldGerman Verkauft
availableEnglish AvailableGerman Verfügbar
enquireEnglish EnquireGerman Anfragen
buyEnglish Buy for {price}German Für {price} kaufen
viewEnglish ViewGerman Ansicht
aloneEnglish The workGerman Das Werk
inRoomEnglish In a roomGerman Im Raum
frameEnglish FrameGerman Rahmen
sizeEnglish {w} × {h} cmGerman {w} × {h} cm
shippingEnglish Shipped insured, with a certificate of authenticity.German Versichert verschickt, mit Echtheitszertifikat.
nowEnglish Now on viewGerman Jetzt zu sehen
upcomingEnglish UpcomingGerman Demnächst
pastEnglish PastGerman Vergangen
untilEnglish until {d}German bis {d}
opensEnglish opens {d}German ab {d}
directionsEnglish Plan a visitGerman Besuch planen
pickEnglish Pick a sizeGerman Wähle eine Größe
ideaEnglish Your ideaGerman Deine Idee
ideaPhEnglish A large blue work for our stairwell, with the sea in it somewhere.German Ein großes blaues Bild für unser Treppenhaus, irgendwo das Meer darin.
nameEnglish NameGerman Name
emailEnglish EmailGerman E-Mail
emailPhEnglish you@example.comGerman du@beispiel.de
requestEnglish Request a commissionGerman Auftrag anfragen
requiredEnglish Please fill in this field.German Bitte fülle dieses Feld aus.
invalidEnglish Please enter an email address like name@example.com.German Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein.
sentTitleEnglish Thank youGerman Danke
sentTextEnglish I answer every request myself within a week, often with a first sketch.German Ich beantworte jede Anfrage selbst innerhalb einer Woche, oft mit einer ersten Skizze.
againEnglish Send another requestGerman Weitere Anfrage senden
timeEnglish TimeGerman Dauer
priceEnglish PriceGerman Preis
scaleEnglish Scale against a person of 175 cmGerman Maßstab neben einer Person von 175 cm

Accessibility

Read from the source of the component.

  • Artist handles the keys Arrow up, Arrow down, Arrow left, Arrow right, Home, End and Space itself.
  • Sets aria-checked, aria-describedby, aria-invalid and aria-label, so screen readers announce purpose and state.
  • Uses the roles img, radio, radiogroup, status and tab.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 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, 735 lines
components/ui/artist.tsx
'use client';

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

/*
 * Sections for artists: the works by series, one work with a view in a
 * room, exhibitions, the statement and commissions.
 *
 *   works        works by series with medium, size and a red dot when sold, a gliding filter
 *   artwork      one work large, switch to a room to see its size against a sofa, enquire or buy
 *   exhibitions  now on view with a live dot, upcoming and past by year
 *   statement    the statement, its words light up while reading, a studio picture that drifts
 *   commission   sizes with price and time, a few words about the idea, a request
 */

export type ArtistVariant = 'works' | 'artwork' | 'exhibitions' | 'statement' | 'commission';

export interface ArtWork {
  id: string;
  title: string;
  year: number;
  medium: string;
  /** Width and height in cm */
  width: number;
  height: number;
  series?: string;
  price?: number;
  sold?: boolean;
  /** e.g. "Edition of 25" */
  edition?: string;
  image?: React.ReactNode;
  href?: string;
  /** artwork: where "Enquire" leads, e.g. a contact form with the work preselected */
  enquireHref?: string;
}

export interface ArtExhibition {
  id: string;
  title: string;
  venue: string;
  city: string;
  /** YYYY-MM-DD */
  start: string;
  end: string;
  /** e.g. "Solo show" */
  kind?: string;
  image?: React.ReactNode;
  href?: string;
}

export interface ArtCommissionSize {
  id: string;
  label: string;
  /** e.g. "60 × 80 cm" */
  size: string;
  price: number;
  /** e.g. "6 weeks" */
  time: string;
  /** For the scale drawing, width and height in cm */
  w: number;
  h: number;
}

export interface ArtistProps {
  variant?: ArtistVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  works?: ArtWork[];
  /** artwork: the work shown */
  work?: ArtWork & { description?: string; frames?: { id: string; label: string; price: number; /** Colour of the frame, shown around the picture */ color?: string }[] };
  exhibitions?: ArtExhibition[];
  /** statement */
  statement?: string;
  signature?: string;
  photo?: React.ReactNode;
  sizes?: ArtCommissionSize[];
  /** Today for the exhibition states, YYYY-MM-DD. Without it the browser date is used after loading. */
  today?: string;
  currency?: string;
  onSubmit?: (data: Record<string, string>) => void;
  labels?: Partial<Texts>;
  className?: string;
}



const TEXTS = {
  en: {
    all: 'All works',
    series: 'Series',
    sold: 'Sold',
    available: 'Available',
    enquire: 'Enquire',
    buy: 'Buy for {price}',
    view: 'View',
    alone: 'The work',
    inRoom: 'In a room',
    frame: 'Frame',
    size: '{w} × {h} cm',
    shipping: 'Shipped insured, with a certificate of authenticity.',
    now: 'Now on view',
    upcoming: 'Upcoming',
    past: 'Past',
    until: 'until {d}',
    opens: 'opens {d}',
    directions: 'Plan a visit',
    pick: 'Pick a size',
    idea: 'Your idea',
    ideaPh: 'A large blue work for our stairwell, with the sea in it somewhere.',
    name: 'Name',
    email: 'Email',
    emailPh: 'you@example.com',
    request: 'Request a commission',
    required: 'Please fill in this field.',
    invalid: 'Please enter an email address like name@example.com.',
    sentTitle: 'Thank you',
    sentText: 'I answer every request myself within a week, often with a first sketch.',
    again: 'Send another request',
    time: 'Time',
    price: 'Price',
    scale: 'Scale against a person of 175 cm',
  },
  de: {
    all: 'Alle Werke',
    series: 'Serie',
    sold: 'Verkauft',
    available: 'Verfügbar',
    enquire: 'Anfragen',
    buy: 'Für {price} kaufen',
    view: 'Ansicht',
    alone: 'Das Werk',
    inRoom: 'Im Raum',
    frame: 'Rahmen',
    size: '{w} × {h} cm',
    shipping: 'Versichert verschickt, mit Echtheitszertifikat.',
    now: 'Jetzt zu sehen',
    upcoming: 'Demnächst',
    past: 'Vergangen',
    until: 'bis {d}',
    opens: 'ab {d}',
    directions: 'Besuch planen',
    pick: 'Wähle eine Größe',
    idea: 'Deine Idee',
    ideaPh: 'Ein großes blaues Bild für unser Treppenhaus, irgendwo das Meer darin.',
    name: 'Name',
    email: 'E-Mail',
    emailPh: 'du@beispiel.de',
    request: 'Auftrag anfragen',
    required: 'Bitte fülle dieses Feld aus.',
    invalid: 'Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein.',
    sentTitle: 'Danke',
    sentText: 'Ich beantworte jede Anfrage selbst innerhalb einer Woche, oft mit einer ersten Skizze.',
    again: 'Weitere Anfrage senden',
    time: 'Dauer',
    price: 'Preis',
    scale: 'Maßstab neben einer Person von 175 cm',
  },
};
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 dfmt = (iso: string, locale: Locale, o: Intl.DateTimeFormatOptions) => {
  const [y, m, d] = iso.split('-').map(Number);
  return new Intl.DateTimeFormat(TAG[locale], { timeZone: 'UTC', ...o }).format(new Date(Date.UTC(y, m - 1, d)));
};


/** Arrow keys, Home and End move the choice inside a radio group or a tab list (APG) */
function arrowKeys(e: React.KeyboardEvent<HTMLElement>) {
  const keys = ['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', 'Home', 'End'];
  if (!keys.includes(e.key)) return;
  const items = [...e.currentTarget.querySelectorAll<HTMLButtonElement>('[role="radio"]:not(:disabled), [role="tab"]:not(:disabled)')];
  const i = items.indexOf(document.activeElement as HTMLButtonElement);
  if (i < 0) return;
  e.preventDefault();
  const step = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : -1;
  const n = e.key === 'Home' ? 0 : e.key === 'End' ? items.length - 1 : (i + step + items.length) % items.length;
  items[n].focus();
  items[n].click();
}

const MOTION_CSS = `
@keyframes kitart-in{from{opacity:0;transform:translateY(10px)}}
@keyframes kitart-swap{from{opacity:0;filter:blur(4px);transform:translateY(6px)}}
@keyframes kitart-pop{0%{transform:scale(.5);opacity:0}60%{transform:scale(1.12);opacity:1}100%{transform:scale(1)}}
@media (prefers-reduced-motion:reduce){[class*="kitart-"],.kitart-anim{animation:none!important;transition:none!important}}`;

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

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 shrink-0 items-center justify-center whitespace-nowrap rounded-lg px-4 text-[14px] font-medium transition-colors max-sm:min-h-11', focusRing, on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', on && !box && 'bg-card shadow-[var(--shadow-sm)]')}>
            {o.label}
          </button>
        );
      })}
    </div>
  );
}

/** The red dot galleries put next to a sold work */
function SoldDot({ sold, t }: { sold?: boolean; t: Texts }) {
  return (
    <span className="inline-flex items-center gap-1.5 text-[12.5px] text-muted-foreground">
      <span className={cn('size-2 rounded-full', sold ? 'bg-destructive' : 'bg-success')} />
      {sold ? t.sold : t.available}
    </span>
  );
}

/* ------------------------------------------------------------------ */
/* works                                                               */
/* ------------------------------------------------------------------ */

function Works({ works = [], currency = 'EUR', t }: ArtistProps & { t: Texts }) {
  const locale = useLocale();
  const series = [...new Set(works.map((w) => w.series).filter(Boolean))] as string[];
  const [sel, setSel] = useState('all');
  const shown = works.filter((w) => sel === 'all' || w.series === sel);
  // Every work at its real size against the largest one, so a small canvas looks small
  const largest = Math.max(1, ...works.map((w) => Math.max(w.width, w.height)));
  const [row, fade] = useScrollFade<HTMLDivElement>();
  return (
    <div>
      {series.length > 0 && (
        <div ref={row} className={cn('kit-no-scrollbar -mx-6 mb-10 overflow-x-auto px-6 sm:mx-0 sm:px-0', fade)}>
          <Segmented label={t.series} value={sel} onChange={setSel} options={[{ id: 'all', label: t.all }, ...series.map((s) => ({ id: s, label: s }))]} />
        </div>
      )}
      <ul key={sel} className="grid grid-cols-2 gap-x-3 gap-y-7 sm:gap-x-6 sm:gap-y-10 lg:grid-cols-4">
        {shown.map((w, i) => (
          <li key={w.id} className="[animation:kitart-in_.55s_var(--ease-kit)_both]" style={{ animationDelay: `${i * 70}ms` }}>
            <a href={w.href ?? '#'} className={cn('group block', focusRing, 'rounded-md')}>
              <span className="relative flex aspect-square items-center justify-center overflow-hidden rounded-md bg-surface transition-colors duration-500 group-hover:bg-[color-mix(in_oklab,var(--surface)_92%,var(--foreground))]" data-clip-ok>
                <span className={cn('block shadow-[0_18px_36px_-14px_rgb(0_0_0/0.45)] transition-transform duration-700 ease-[var(--ease-kit)] group-hover:scale-[1.04] motion-reduce:transition-none', fit)} style={{ width: `${round((w.width / largest) * 80, 2)}%`, aspectRatio: `${w.width} / ${w.height}` }}>
                  {w.image}
                </span>
              </span>
              <span className="mt-3 flex flex-col gap-1.5 sm:flex-row sm:items-baseline sm:justify-between sm:gap-3">
                <span className="min-w-0">
                  <span className="block text-[14.5px] font-semibold leading-snug sm:text-[15.5px] text-foreground group-hover:underline group-hover:underline-offset-4">
                    {w.title}, <span className="font-normal tabular-nums">{w.year}</span>
                  </span>
                  <span className="mt-0.5 block text-[13px] text-muted-foreground sm:text-[13.5px]">
                    {w.medium} · {fill(t.size, { w: w.width, h: w.height })}
                  </span>
                </span>
                <span className="flex shrink-0 items-center gap-2 sm:block sm:text-right">
                  {w.price && !w.sold && <span className="block text-[14.5px] font-semibold tabular-nums text-foreground">{money(w.price, currency, locale)}</span>}
                  <SoldDot sold={w.sold} t={t} />
                </span>
              </span>
            </a>
          </li>
        ))}
      </ul>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* artwork                                                             */
/* ------------------------------------------------------------------ */

/** A living room drawn like a photo in fixed colours, with a sofa of 220 cm as the scale */
function Room({ work, frame }: { work: ArtWork; frame?: string }) {
  // The wall is 280 cm high and as wide as the 4:3 stage, the sofa 220 cm, the picture hangs with its centre at 155 cm.
  // A drawn room, like a photo: the same colours in light and dark.
  const H = 280;
  const W = (H * 4) / 3;
  const pw = round((work.width / W) * 100, 2);
  const ph = round((work.height / H) * 100, 2);
  const top = round(((H - 155 - work.height / 2) / H) * 100, 2);
  return (
    <div className="absolute inset-0 bg-[#e9e4dc]">
      <div className="absolute inset-x-0 bottom-0 h-[16%] bg-[#b9a68c]" />
      <div className="absolute left-1/2 overflow-hidden shadow-[0_18px_30px_-12px_rgb(0_0_0/0.45)] transition-[width,height,top] duration-700 ease-[var(--ease-kit)] [&>*]:size-full" style={{ width: `${pw}%`, height: `${ph}%`, top: `${top}%`, transform: 'translateX(-50%)', boxShadow: frame ? `0 0 0 2px #e9e4dc, 0 0 0 5px ${frame}, 0 18px 30px -12px rgb(0 0 0 / 0.45)` : undefined }}>
        {work.image}
      </div>
      {/* sofa, 220 cm */}
      <svg viewBox="0 0 220 90" className="absolute bottom-[10%] left-1/2 w-[58.9%] -translate-x-1/2" aria-hidden>
        <rect x="6" y="20" width="208" height="46" rx="10" fill="#6e7d86" />
        <rect x="0" y="34" width="26" height="40" rx="8" fill="#5f6e77" />
        <rect x="194" y="34" width="26" height="40" rx="8" fill="#5f6e77" />
        <rect x="22" y="44" width="176" height="26" rx="6" fill="#7d8c95" />
        <rect x="18" y="74" width="6" height="14" fill="#3b3329" />
        <rect x="196" y="74" width="6" height="14" fill="#3b3329" />
      </svg>
      {/* a plant for life */}
      <svg viewBox="0 0 60 140" className="absolute bottom-[10%] right-[6%] w-[10%]" aria-hidden>
        <rect x="18" y="104" width="24" height="34" rx="3" fill="#b87a52" />
        {[[-28, 70], [-10, 40], [10, 30], [28, 66], [0, 60]].map(([r, h], i) => (
          <path key={i} d={`M30 106 Q${30 + r} ${106 - h / 2} ${30 + r * 1.2} ${106 - h}`} stroke="#5b8a5a" strokeWidth="5" fill="none" strokeLinecap="round" />
        ))}
      </svg>
    </div>
  );
}

function Artwork({ work, currency = 'EUR', t }: ArtistProps & { t: Texts }) {
  const locale = useLocale();
  const [view, setView] = useState('alone');
  const [frame, setFrame] = useState(work?.frames?.[0]?.id ?? '');
  const spot = useSpotlight<HTMLDivElement>();
  const base = useId();
  if (!work) return null;
  const f = work.frames?.find((x) => x.id === frame);
  const total = (work.price ?? 0) + (f?.price ?? 0);
  return (
    <div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)] lg:gap-14">
      <div>
        <div onPointerMove={spot} className="kit-spotlight relative aspect-[4/3] overflow-hidden rounded-xl border border-border bg-surface" data-clip-ok>
          {view === 'alone' ? (
            <div key="a" className="absolute inset-0 flex items-center justify-center p-8 [animation:kitart-swap_.5s_var(--ease-kit)] sm:p-12">
              <div className={cn('max-h-full max-w-full shadow-[0_24px_50px_-18px_rgb(0_0_0/0.5)] transition-[outline-color,outline-width] duration-500', fit)} style={{ aspectRatio: `${work.width} / ${work.height}`, height: work.height >= work.width ? '100%' : undefined, width: work.width > work.height ? '100%' : undefined, outline: f?.color ? `10px solid ${f.color}` : '0px solid transparent', outlineOffset: f?.color ? '5px' : 0 }}>
                {work.image}
              </div>
            </div>
          ) : (
            <div key="r" className="absolute inset-0 [animation:kitart-swap_.5s_var(--ease-kit)]">
              <Room work={work} frame={f?.color} />
            </div>
          )}
        </div>
        <div className="mt-4 flex items-center justify-between gap-4">
          <Segmented label={t.view} value={view} onChange={setView} options={[{ id: 'alone', label: t.alone }, { id: 'room', label: t.inRoom }]} />
          <span className="text-[13px] tabular-nums text-muted-foreground">{fill(t.size, { w: work.width, h: work.height })}</span>
        </div>
      </div>
      <div className="lg:pt-2">
        {work.series && <p className="text-[13px] font-medium text-muted-foreground">{work.series}</p>}
        <h3 className="mt-1.5 text-balance text-[30px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
          {work.title}, <span className="font-normal tabular-nums text-muted-foreground">{work.year}</span>
        </h3>
        <p className="mt-2 text-[15px] text-muted-foreground">
          {work.medium} · {fill(t.size, { w: work.width, h: work.height })}
          {work.edition && ` · ${work.edition}`}
        </p>
        {work.description && <p className="mt-5 text-pretty text-[15.5px] leading-relaxed text-foreground/85">{work.description}</p>}
        {work.frames?.length ? (
          <fieldset className="mt-7">
            <legend className="mb-3 text-[14px] font-medium text-foreground">{t.frame}</legend>
            <div className="grid gap-2">
              {work.frames.map((x) => {
                const on = x.id === frame;
                return (
                  <label key={x.id} className={cn('flex min-h-12 cursor-pointer items-center justify-between gap-3 rounded-lg border px-4 transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring', on ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))]' : 'border-border hover:border-border-strong')}>
                    <input type="radio" name={`${base}-f`} checked={on} onChange={() => setFrame(x.id)} className="sr-only" />
                    <span className="flex items-center gap-3 text-[14.5px] text-foreground">
                      <span aria-hidden className={cn('flex size-[18px] items-center justify-center rounded-full border-2', on ? 'border-primary' : 'border-border-strong')}>
                        <span className={cn('size-2 rounded-full bg-primary transition-transform', on ? 'scale-100' : 'scale-0')} />
                      </span>
                      {x.label}
                    </span>
                    <span className="text-[14px] tabular-nums text-muted-foreground">{x.price ? `+${money(x.price, currency, locale)}` : ''}</span>
                  </label>
                );
              })}
            </div>
          </fieldset>
        ) : null}
        <div className="mt-7 flex flex-wrap items-center gap-3 border-t border-border pt-6">
          {work.sold ? (
            <>
              <SoldDot sold t={t} />
              <Button href={work.enquireHref ?? '#'} variant="outline" icon="arrow-right" className="max-sm:w-full">
                {t.enquire}
              </Button>
            </>
          ) : (
            <>
              <Magnetic className="max-sm:flex max-sm:w-full"><Button href={work.href ?? '#'} icon="arrow-right" className="max-sm:w-full">
                <span key={total} className="[animation:kitart-swap_.35s_var(--ease-kit)]">
                  {fill(t.buy, { price: money(total, currency, locale) })}
                </span>
              </Button></Magnetic>
              <Button href={work.enquireHref ?? '#'} variant="ghost" className="max-sm:w-full">
                {t.enquire}
              </Button>
            </>
          )}
        </div>
        <p className="mt-4 flex items-center gap-2 text-[13px] text-muted-foreground">
          <Icon name="shield" size={14} />
          {t.shipping}
        </p>
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* exhibitions                                                         */
/* ------------------------------------------------------------------ */

function Exhibitions({ exhibitions = [], today, t }: ArtistProps & { t: Texts }) {
  const locale = useLocale();
  const [now, setNow] = useState(today ?? '');
  useEffect(() => {
    if (today) return;
    const d = new Date();
    setNow(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
  }, [today]);
  const sorted = [...exhibitions].sort((a, b) => (a.start < b.start ? 1 : -1));
  const current = now ? sorted.filter((e) => e.start <= now && e.end >= now) : [];
  const upcoming = now ? sorted.filter((e) => e.start > now).reverse() : [];
  const past = now ? sorted.filter((e) => e.end < now) : sorted;
  const years = [...new Set(past.map((e) => e.start.slice(0, 4)))];
  const range = (e: ArtExhibition) => `${dfmt(e.start, locale, { day: 'numeric', month: 'short' })} ${locale === 'de' ? 'bis' : 'to'} ${dfmt(e.end, locale, { day: 'numeric', month: 'short', year: 'numeric' })}`;
  return (
    <div className="grid gap-12">
      {current.map((e) => (
        <article key={e.id} className="kit-card grid grid-cols-1 overflow-hidden md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] [animation:kitart-in_.5s_var(--ease-kit)]">
          <div className="relative min-h-[260px] overflow-hidden bg-muted md:min-h-[340px]" data-clip-ok>
            <Parallax speed={40} className={cn('absolute -inset-y-6 inset-x-0', fit)}>
              {e.image}
            </Parallax>
          </div>
          <div className="flex flex-col p-6 sm:p-8">
            <span className="inline-flex items-center gap-2 self-start rounded-full bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)] px-3 py-1 text-[13px] font-semibold text-foreground">
              <span className="relative flex size-2">
                <span aria-hidden className="absolute inset-0 animate-ping rounded-full bg-destructive opacity-60 motion-reduce:animate-none" />
                <span className="relative size-2 rounded-full bg-destructive" />
              </span>
              {t.now}
            </span>
            <h3 className="mt-4 text-balance text-[26px] font-semibold leading-tight tracking-[-0.02em] text-foreground">{e.title}</h3>
            <p className="mt-2 text-[15px] text-foreground">
              {e.venue}, {e.city}
            </p>
            <p className="mt-1 text-[14px] tabular-nums text-muted-foreground">
              {e.kind && `${e.kind} · `}
              {fill(t.until, { d: dfmt(e.end, locale, { day: 'numeric', month: 'long' }) })}
            </p>
            <Button href={e.href ?? '#'} icon="arrow-right" className="mt-auto self-start max-md:mt-6 max-sm:w-full max-sm:self-stretch">
              {t.directions}
            </Button>
          </div>
        </article>
      ))}
      {upcoming.length > 0 && (
        <section>
          <h3 className="mb-4 text-[13px] font-medium text-muted-foreground">{t.upcoming}</h3>
          <Stagger as="ul" step={70} className="grid grid-cols-1 gap-4 sm:grid-cols-2">
            {upcoming.map((e) => (
              <li key={e.id} className="rounded-xl border border-dashed border-border-strong p-5 transition-colors hover:border-primary">
                <p className="text-[13px] font-medium tabular-nums text-primary">{fill(t.opens, { d: dfmt(e.start, locale, { day: 'numeric', month: 'long', year: 'numeric' }) })}</p>
                <p className="mt-2 text-[17px] font-semibold text-foreground">{e.title}</p>
                <p className="mt-0.5 text-[14px] text-muted-foreground">
                  {e.venue}, {e.city}
                  {e.kind && ` · ${e.kind}`}
                </p>
              </li>
            ))}
          </Stagger>
        </section>
      )}
      {past.length > 0 && (
        <section>
          {now && <h3 className="mb-4 text-[13px] font-medium text-muted-foreground">{t.past}</h3>}
          <div className="border-t border-border">
            {years.map((y) => (
              <div key={y} className="grid grid-cols-1 items-baseline gap-x-10 border-b border-border py-5 md:grid-cols-[100px_minmax(0,1fr)]">
                <p className="text-[22px] font-semibold leading-tight tabular-nums text-foreground">{y}</p>
                <ul className="mt-2 grid gap-3 md:mt-0">
                  {past
                    .filter((e) => e.start.startsWith(y))
                    .map((e) => (
                      <li key={e.id} className="grid grid-cols-1 gap-x-6 gap-y-0.5 sm:grid-cols-[minmax(0,1fr)_auto]">
                        <span className="text-[15.5px] text-foreground">
                          <span className="font-semibold">{e.title}</span>
                          <span className="text-muted-foreground">
                            , {e.venue}, {e.city}
                          </span>
                        </span>
                        <span className="text-[13.5px] tabular-nums text-muted-foreground">{e.kind ?? range(e)}</span>
                      </li>
                    ))}
                </ul>
              </div>
            ))}
          </div>
        </section>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* statement                                                           */
/* ------------------------------------------------------------------ */

function Statement({ statement = '', signature, photo }: ArtistProps & { t: Texts }) {
  const ref = useScrollProgress<HTMLDivElement>({ start: 0.05, end: 0.38 });
  const words = statement.split(/\s+/).filter(Boolean);
  const [p, setP] = useState(1);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const mo = new MutationObserver(() => setP(Number(el.style.getPropertyValue('--kit-progress') || 1)));
    mo.observe(el, { attributes: true, attributeFilter: ['style'] });
    setP(Number(el.style.getPropertyValue('--kit-progress') || 1));
    return () => mo.disconnect();
  }, [ref]);
  const lit = Math.round(p * words.length);
  return (
    <div className="grid grid-cols-1 items-center gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)] lg:gap-16">
      {photo && (
        <div className="relative mx-auto aspect-[4/5] w-full max-w-[420px] overflow-hidden rounded-xl bg-muted" data-clip-ok>
          <Parallax speed={60} className={cn('absolute -inset-y-8 inset-x-0', fit)}>
            {photo}
          </Parallax>
        </div>
      )}
      <div ref={ref}>
        <p className="text-pretty text-[24px] font-medium leading-[1.45] tracking-[-0.01em] sm:text-[30px]">
          {words.map((w, i) => (
            <span key={i} className={cn('transition-colors duration-300', i < lit ? 'text-foreground' : 'text-[color-mix(in_oklab,var(--foreground)_28%,transparent)]')}>
              {w}{' '}
            </span>
          ))}
        </p>
        {signature && <p className="mt-8 text-[30px] italic text-foreground [font-family:inherit] [font-weight:300]">{signature}</p>}
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* commission                                                          */
/* ------------------------------------------------------------------ */

function Commission({ sizes = [], currency = 'EUR', onSubmit, t }: ArtistProps & { t: Texts }) {
  const locale = useLocale();
  const base = useId();
  const [sel, setSel] = useState(sizes[1]?.id ?? sizes[0]?.id ?? '');
  const [idea, setIdea] = useState('');
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [sent, setSent] = useState(false);
  const s = sizes.find((x) => x.id === sel) ?? sizes[0];
  const maxH = Math.max(175, ...sizes.map((x) => x.h));
  const submit = (e: React.FormEvent) => {
    e.preventDefault();
    const next: Record<string, string> = {};
    if (!idea.trim()) next.idea = t.required;
    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?.({ size: s?.id ?? '', idea, name, email });
    setSent(true);
  };
  const field = 'w-full rounded-lg border bg-background px-3 text-[15px] text-foreground placeholder:text-subtle-foreground';
  return (
    <div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] lg:gap-12">
      <div className="kit-card flex flex-col p-6 sm:p-8">
        <p className="text-[15px] font-semibold text-foreground">{t.pick}</p>
        <div role="radiogroup" aria-label={t.pick} onKeyDown={arrowKeys} className="mt-4 grid grid-cols-3 gap-2">
          {sizes.map((x) => {
            const on = x.id === s?.id;
            return (
              <button key={x.id} type="button" role="radio" aria-checked={on} tabIndex={on ? 0 : -1} onClick={() => setSel(x.id)} className={cn('flex min-h-[64px] flex-col items-center justify-center rounded-xl border px-1 text-center sm:px-2 transition-[border-color,background-color] duration-300', focusRing, on ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))]' : 'border-border hover:border-border-strong')}>
                <span className="text-[15px] font-semibold text-foreground">{x.label}</span>
                <span className="whitespace-nowrap text-[11.5px] tabular-nums text-muted-foreground sm:text-[12.5px]">{x.size}</span>
              </button>
            );
          })}
        </div>
        {/* scale drawing: the canvas next to a person */}
        <div className="relative mt-6 flex h-[220px] items-end justify-center gap-8 rounded-xl bg-surface px-6 pb-5" aria-label={t.scale} role="img">
          <span aria-hidden className="absolute inset-x-6 bottom-5 h-px bg-border-strong" />
          {s && (
            <span key={s.id} className="relative border-[3px] border-[color-mix(in_oklab,var(--foreground)_70%,transparent)] bg-[linear-gradient(135deg,color-mix(in_oklab,var(--primary)_55%,var(--card)),color-mix(in_oklab,var(--primary)_18%,var(--card)))] transition-[width,height] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: `${round((s.w / maxH) * 180, 1)}px`, height: `${round((s.h / maxH) * 180, 1)}px`, marginBottom: `${round((40 / maxH) * 180, 1)}px` }} />
          )}
          <svg viewBox="0 0 40 175" aria-hidden style={{ height: `${round((175 / maxH) * 180, 1)}px` }} className="relative text-muted-foreground">
            <circle cx="20" cy="14" r="11" fill="currentColor" />
            <rect x="8" y="29" width="24" height="70" rx="10" fill="currentColor" />
            <rect x="10" y="92" width="9" height="83" rx="4" fill="currentColor" />
            <rect x="21" y="92" width="9" height="83" rx="4" fill="currentColor" />
          </svg>
        </div>
        {s && (
          <dl className="mt-5 grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border">
            <div className="bg-card p-4">
              <dt className="text-[12.5px] text-muted-foreground">{t.price}</dt>
              <dd key={`p${s.id}`} className="mt-1 text-[22px] font-semibold tabular-nums text-foreground [animation:kitart-swap_.35s_var(--ease-kit)]">
                {money(s.price, currency, locale)}
              </dd>
            </div>
            <div className="bg-card p-4">
              <dt className="text-[12.5px] text-muted-foreground">{t.time}</dt>
              <dd key={`t${s.id}`} className="mt-1 text-[22px] font-semibold text-foreground [animation:kitart-swap_.35s_var(--ease-kit)]">
                {s.time}
              </dd>
            </div>
          </dl>
        )}
      </div>
      {sent ? (
        <div role="status" className="kit-card flex flex-col items-center justify-center p-8 text-center [animation:kitart-swap_.5s_var(--ease-kit)]">
          <span className="flex size-14 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-success [animation:kitart-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 max-w-[26rem] text-pretty text-[15px] text-muted-foreground">{t.sentText}</p>
          <Button variant="outline" className="mt-6" onClick={() => setSent(false)}>
            {t.again}
          </Button>
        </div>
      ) : (
        <form onSubmit={submit} noValidate className="grid content-start gap-5">
          <div>
            <label htmlFor={`${base}-idea`} className="mb-2 block text-[15px] font-semibold text-foreground">
              {t.idea}
            </label>
            <textarea id={`${base}-idea`} rows={6} value={idea} onChange={(e) => setIdea(e.target.value)} placeholder={t.ideaPh} aria-invalid={!!errors.idea} aria-describedby={errors.idea ? `${base}-idea-e` : undefined} className={cn(field, 'resize-y py-2.5', errors.idea ? 'border-destructive' : 'border-border-strong', focusRing)} />
            {errors.idea && (
              <p id={`${base}-idea-e`} className="mt-1.5 text-[13px] text-destructive">
                {errors.idea}
              </p>
            )}
          </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, 'h-11', 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>
          <Magnetic className="max-sm:flex max-sm:w-full sm:self-start"><Button type="submit" icon="arrow-right" className="max-sm:w-full">
            {t.request}
          </Button></Magnetic>
        </form>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Artist                                                              */
/* ------------------------------------------------------------------ */

/**
 * Sections for artists.
 *   works        works by series, sold ones with a red dot
 *   artwork      one work, alone or in a room, frame and price
 *   exhibitions  now on view, upcoming and past
 *   statement    words light up while reading
 *   commission   sizes against a person, price and time, a request
 */
export function Artist(props: ArtistProps) {
  const { variant = 'works', eyebrow, title, description, labels, className } = props;
  const t = useTexts(TEXTS, labels);
  const body = (() => {
    switch (variant) {
      case 'artwork':
        return <Artwork {...props} t={t} />;
      case 'exhibitions':
        return <Exhibitions {...props} t={t} />;
      case 'statement':
        return <Statement {...props} t={t} />;
      case 'commission':
        return <Commission {...props} t={t} />;
      default:
        return <Works {...props} t={t} />;
    }
  })();
  return (
    <Section className={className} background={variant === 'commission' ? 'surface' : 'default'}>
      <ArtMotion />
      {title ? <SectionHeader eyebrow={eyebrow} title={title} description={description} /> : null}
      {body}
    </Section>
  );
}

Frequent questions

How do I install Artist?

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

Which variants does Artist have?

5 variants, Works, Artwork with room view, Exhibitions, Statement and Commission. You pick one with the variant prop, for example variant="artwork".

Can I translate the texts of Artist?

Yes. Artist carries 33 texts of its own in English and German, such as “All works”, “Series” and “Sold”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Artist?

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

Can I adjust Artist without code?

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

Often searched

Jump to

Copied to the clipboard