Künstler
Für Malerinnen, Bildhauer und Ateliers. Werke nach Serien mit rotem Punkt, ein Werk im Raum für den Maßstab, Ausstellungen mit dem, was gerade läuft, ein Statement, das beim Lesen aufleuchtet, und Auftragsarbeiten.
Eine von 3 Components in Kunst und Musik
Variants
Details
Was Künstler mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Kunst und Musik
- Bereich
- Industries
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/artist.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 33 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ArtistProps aus components/ui/artist.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalArtistVariantworksartworkexhibitionsstatementcommission
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeworksoptionalArtWork[]workoptionalArtWork & { description?: string; frames?: { id: string; label: string; price: number; /** Colour of the frame, shown around the picture */ color?: string }[] }artwork: the work shown
exhibitionsoptionalArtExhibition[]statementoptionalstringstatement
signatureoptionalstringphotooptionalReact.ReactNodesizesoptionalArtCommissionSize[]todayoptionalstringToday for the exhibition states, YYYY-MM-DD. Without it the browser date is used after loading.
currencyoptionalstringonSubmitoptional(data: Record<string, string>) => void
Eingebaute Texte
Diese Texte schreibt Künstler selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 33 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| all | Englisch All works | Deutsch Alle Werke |
| series | Englisch Series | Deutsch Serie |
| sold | Englisch Sold | Deutsch Verkauft |
| available | Englisch Available | Deutsch Verfügbar |
| enquire | Englisch Enquire | Deutsch Anfragen |
| buy | Englisch Buy for {price} | Deutsch Für {price} kaufen |
| view | Englisch View | Deutsch Ansicht |
| alone | Englisch The work | Deutsch Das Werk |
| inRoom | Englisch In a room | Deutsch Im Raum |
| frame | Englisch Frame | Deutsch Rahmen |
| size | Englisch {w} × {h} cm | Deutsch {w} × {h} cm |
| shipping | Englisch Shipped insured, with a certificate of authenticity. | Deutsch Versichert verschickt, mit Echtheitszertifikat. |
| now | Englisch Now on view | Deutsch Jetzt zu sehen |
| upcoming | Englisch Upcoming | Deutsch Demnächst |
| past | Englisch Past | Deutsch Vergangen |
| until | Englisch until {d} | Deutsch bis {d} |
| opens | Englisch opens {d} | Deutsch ab {d} |
| directions | Englisch Plan a visit | Deutsch Besuch planen |
| pick | Englisch Pick a size | Deutsch Wähle eine Größe |
| idea | Englisch Your idea | Deutsch Deine Idee |
| ideaPh | Englisch A large blue work for our stairwell, with the sea in it somewhere. | Deutsch Ein großes blaues Bild für unser Treppenhaus, irgendwo das Meer darin. |
| name | Englisch Name | Deutsch Name |
| Englisch Email | Deutsch E-Mail | |
| emailPh | Englisch you@example.com | Deutsch du@beispiel.de |
| request | Englisch Request a commission | Deutsch Auftrag anfragen |
| required | Englisch Please fill in this field. | Deutsch Bitte fülle dieses Feld aus. |
| invalid | Englisch Please enter an email address like name@example.com. | Deutsch Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein. |
| sentTitle | Englisch Thank you | Deutsch Danke |
| sentText | Englisch I answer every request myself within a week, often with a first sketch. | Deutsch Ich beantworte jede Anfrage selbst innerhalb einer Woche, oft mit einer ersten Skizze. |
| again | Englisch Send another request | Deutsch Weitere Anfrage senden |
| time | Englisch Time | Deutsch Dauer |
| price | Englisch Price | Deutsch Preis |
| scale | Englisch Scale against a person of 175 cm | Deutsch Maßstab neben einer Person von 175 cm |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Künstler reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende und Leertaste.
- Setzt aria-checked, aria-describedby, aria-invalid und aria-label, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen img, radio, radiogroup, status und tab.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 4 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 735 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Künstler?
Mit aiw add @ai-website/artist. Der Befehl legt die Datei components/ui/artist.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, motion und section. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Künstler?
5 Varianten, Werke, Werk mit Raumansicht, Ausstellungen, Statement und Auftragsarbeit. Du wählst sie mit der Prop variant, zum Beispiel variant="artwork".
Kann ich die Texte von Künstler übersetzen?
Ja. Künstler bringt 33 eigene Texte auf Deutsch und Englisch mit, etwa „Alle Werke“, „Serie“ und „Verkauft“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Künstler einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Künstler mit aiw_components und installieren sie mit aiw_add @ai-website/artist. Nenn im Prompt die Variante, etwa variant="works", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Künstler ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Artist-Code zum Kopieren.