Über uns
Geschichte, Werte, Mission, Standorte auf einer Punktkarte, Zahlen und die Gründer.
Eine von 2 Components in About
Variants
Details
Was Über uns mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- About
- Bereich
- Content
- Variants
- 6
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/about.tsx
- Bringt mit
- utilskitbadgeiconsectionreveal
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 5 eigene Texte
- In Sammlungen
- Portfolio
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle AboutProps aus components/ui/about.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalAboutVariantstory-timelinevaluesmission-quotelocationsnumbers-and-textfounders
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodebodyoptionalstring[]Running text, one paragraph per entry (numbers-and-text, founders)
milestonesoptionalAboutMilestone[]valuesoptionalAboutValue[]quoteoptionalReact.ReactNodeThe statement for mission-quote. As a string it rises word by word (word-reveal), `highlight` names the phrase in the accent color. As a node, wrap words in <span className="kit-accent-text"> to light them up.
highlightoptionalstringThe phrase of a string quote that takes the accent color
personoptionalAboutPersonWho said it (mission-quote) or who signs it (founders)
signatureoptionalReact.ReactNodeA drawn signature or any node under founders and mission-quote. Its SVG strokes draw themselves once.
locationsoptionalAboutLocation[]numbersoptionalAboutNumber[]peopleoptionalAboutPerson[]
Eingebaute Texte
Diese Texte schreibt Über uns selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 5 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| hq | Englisch Head office | Deutsch Hauptsitz |
| local | Englisch Local time | Deutsch Ortszeit |
| people | Englisch {n} people | Deutsch {n} Personen |
| offices | Englisch Offices | Deutsch Standorte |
| map | Englisch Map of our offices | Deutsch Karte unserer Standorte |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle img.
- 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
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 9 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 672 Zeilen
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useSpotlight, useTexts } from '@/lib/kit';
import { Badge } from '@/components/ui/badge';
import { Icon, type IconName } from '@/components/ui/icon';
import { Section, SectionHeader } from '@/components/ui/section';
import { Reveal } from '@/components/ui/reveal';
import { Stagger, useScrollProgress } from '@/components/ui/motion';
import { WordReveal } from '@/components/ui/word-reveal';
export type AboutVariant = 'story-timeline' | 'values' | 'mission-quote' | 'locations' | 'numbers-and-text' | 'founders';
export interface AboutMilestone {
year: string;
title: string;
text?: string;
image?: React.ReactNode;
}
export interface AboutValue {
title: string;
text: string;
icon?: IconName;
}
export interface AboutLocation {
city: string;
country?: string;
address?: string;
/** Longitude and latitude for the dot on the map */
lon: number;
lat: number;
/** IANA time zone for the local clock, e.g. Europe/Berlin */
timeZone?: string;
/** Marks the head office */
headquarters?: boolean;
people?: number;
}
export interface AboutNumber {
value: number;
/** Text before the number, e.g. € or $ */
prefix?: string;
/** Text after the number, e.g. % or + */
suffix?: string;
label: string;
decimals?: number;
}
export interface AboutPerson {
name: string;
role: string;
image?: React.ReactNode;
bio?: string;
}
const TEXTS = {
en: { hq: 'Head office', local: 'Local time', people: '{n} people', offices: 'Offices', map: 'Map of our offices' },
de: { hq: 'Hauptsitz', local: 'Ortszeit', people: '{n} Personen', offices: 'Standorte', map: 'Karte unserer Standorte' },
};
type Texts = (typeof TEXTS)['en'];
export interface AboutProps {
variant?: AboutVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
/** Running text, one paragraph per entry (numbers-and-text, founders) */
body?: string[];
milestones?: AboutMilestone[];
values?: AboutValue[];
/**
* The statement for mission-quote. As a string it rises word by word
* (word-reveal), `highlight` names the phrase in the accent color. As a
* node, wrap words in <span className="kit-accent-text"> to light them up.
*/
quote?: React.ReactNode;
/** The phrase of a string quote that takes the accent color */
highlight?: string;
/** Who said it (mission-quote) or who signs it (founders) */
person?: AboutPerson;
/** A drawn signature or any node under founders and mission-quote. Its SVG strokes draw themselves once. */
signature?: React.ReactNode;
locations?: AboutLocation[];
numbers?: AboutNumber[];
people?: AboutPerson[];
labels?: Partial<Texts>;
className?: string;
}
const fill = '[&>*]:size-full';
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
/**
* Draws the strokes of an SVG inside (a signature) once it scrolls in.
* The strokes stand complete on the server and without motion.
*/
function Drawn({ children, className }: { children: React.ReactNode; className?: string }) {
const [ref, inView] = useInView<HTMLDivElement>('0px 0px -12% 0px');
const done = useRef(false);
useEffect(() => {
const el = ref.current;
if (!el || !inView || done.current || prefersReducedMotion()) return;
done.current = true;
el.querySelectorAll<SVGPathElement>('path').forEach((p, i) => {
const len = Math.ceil(p.getTotalLength());
p.style.strokeDasharray = String(len);
p.animate([{ strokeDashoffset: len }, { strokeDashoffset: 0 }], { duration: 1600, delay: 250 + i * 200, easing: 'cubic-bezier(0.45, 0, 0.2, 1)', fill: 'backwards' });
});
}, [inView, ref]);
return (
<div ref={ref} className={className}>
{children}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Story timeline */
/* ------------------------------------------------------------------ */
/**
* The line fills while the reader moves down (a reading line at 55 % of
* the window), each milestone lights up as the line reaches it. The line
* scales, it never changes its height. Without JavaScript it is complete.
*/
function useReadingLine<T extends HTMLElement>(count: number) {
const ref = useRef<T>(null);
const [reached, setReached] = useState(count);
useEffect(() => {
const el = ref.current;
if (!el || prefersReducedMotion()) return;
let frame = 0;
let last = -1;
const update = () => {
frame = 0;
// at the very end of the page everything on screen counts as read, so the last step never stays dark
const end = window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2;
const read = end ? window.innerHeight : window.innerHeight * 0.55;
const line = el.querySelector<HTMLElement>('[data-seg]');
if (line) {
const r = line.getBoundingClientRect();
line.style.setProperty('--seg', clamp01((read - r.top) / Math.max(1, r.height)).toFixed(4));
}
let n = 0;
el.querySelectorAll<HTMLElement>('[data-dot]').forEach((d) => {
const r = d.getBoundingClientRect();
if (r.top + r.height / 2 <= read + 1) n++;
});
if (n !== last) setReached((last = n));
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
};
}, [count]);
return [ref, reached] as const;
}
function Year({ year, accent, className }: { year: string; accent: boolean; className?: string }) {
const drift = useScrollProgress<HTMLSpanElement>();
return (
<span
ref={drift}
aria-hidden
className={cn('block text-[clamp(3rem,1.6rem+2.6vw,4.5rem)] font-semibold leading-[0.9] tracking-[-0.05em] tabular-nums transition-opacity duration-700', accent ? 'kit-accent-text' : 'kit-gradient-text', className)}
style={{ transform: 'translate3d(0, calc((0.5 - var(--kit-progress, 0.5)) * 36px), 0)' }}
>
{year}
</span>
);
}
function MilestoneImage({ children, className }: { children: React.ReactNode; className?: string }) {
const ref = useScrollProgress<HTMLDivElement>();
return (
<div ref={ref} className={cn('group/img relative mt-6 aspect-[16/10] max-w-[26rem] overflow-hidden rounded-xl border border-border bg-muted shadow-[var(--shadow-md)]', className)}>
<div data-clip-ok className="absolute inset-0 transition-transform duration-700 ease-[var(--ease-kit)] group-hover/img:scale-[1.04] motion-reduce:transition-none">
<div className={cn('absolute inset-0 will-change-transform', fill)} style={{ transform: 'translate3d(0, calc((0.5 - var(--kit-progress, 0.5)) * 8%), 0) scale(1.12)' }}>
{children}
</div>
</div>
</div>
);
}
function Timeline({ milestones }: { milestones: AboutMilestone[] }) {
const last = milestones.length - 1;
const [ref, reached] = useReadingLine<HTMLOListElement>(milestones.length);
return (
<ol ref={ref} className="relative">
<span aria-hidden data-seg className="absolute bottom-2 left-[7px] top-2 w-px bg-gradient-to-b from-border-strong via-border to-transparent lg:left-1/2 lg:-translate-x-1/2">
<span className="absolute inset-0 origin-top bg-gradient-to-b from-primary via-[color-mix(in_oklab,var(--primary)_70%,transparent)] to-transparent [transform:scaleY(var(--seg,1))]" />
</span>
{milestones.map((m, i) => {
const right = i % 2 === 1;
const lit = i < reached;
const now = i === reached - 1 && reached <= milestones.length;
return (
<li key={m.year + m.title} className="relative grid grid-cols-1 pb-14 pl-10 last:pb-0 lg:grid-cols-2 lg:gap-x-24 lg:pb-24 lg:pl-0">
<span data-dot aria-hidden className={cn('absolute left-0 top-1.5 flex size-[15px] items-center justify-center rounded-full border bg-background transition-[border-color,transform] duration-500 ease-[var(--ease-kit)] lg:left-1/2 lg:top-3 lg:-translate-x-1/2', lit ? 'scale-110 border-primary' : 'border-border-strong')}>
{now && i !== last && <span className="absolute -inset-1.5 rounded-full border border-primary opacity-40 animate-kit-pulse motion-reduce:hidden" />}
<span className={cn('size-[7px] rounded-full transition-colors duration-500', lit ? 'bg-primary' : 'bg-border-strong')} />
</span>
{/* Desktop: the year stands large on the other side of the line and drifts a little slower than the page */}
<div className={cn('hidden lg:row-start-1 lg:block', right ? 'lg:col-start-1 lg:text-right' : 'lg:col-start-2')}>
<Year year={m.year} accent={i === last} className={lit ? 'opacity-100' : 'opacity-35'} />
</div>
<Reveal className={cn('lg:row-start-1', right ? 'lg:col-start-2' : 'lg:col-start-1 lg:text-right')}>
<p className="text-[13px] font-medium tabular-nums text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))] lg:sr-only">{m.year}</p>
<h3 className="mt-2 text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground lg:mt-1 lg:text-[24px]">{m.title}</h3>
{m.text && <p className={cn('mt-3 max-w-[30rem] text-pretty text-[15px] leading-relaxed text-muted-foreground', !right && 'lg:ml-auto')}>{m.text}</p>}
{m.image && <MilestoneImage className={cn(!right && 'lg:ml-auto')}>{m.image}</MilestoneImage>}
</Reveal>
</li>
);
})}
</ol>
);
}
/* ------------------------------------------------------------------ */
/* Values */
/* ------------------------------------------------------------------ */
function ValueCell({ value, index }: { value: AboutValue; index: number }) {
const spot = useSpotlight<HTMLDivElement>();
// Phones: icon and title share a row, the text runs below. From sm: a tall cell.
// The cell keeps its background in the hairline grid, only its content rises.
return (
<div onPointerMove={(e) => { if (e.pointerType === 'mouse') spot(e); }} className="kit-spotlight group h-full bg-background">
<Reveal delay={(index % 3) * 90} className="grid h-full grid-cols-[2.75rem_minmax(0,1fr)_auto] items-center gap-x-4 p-6 sm:grid-cols-[2.75rem_minmax(0,1fr)] sm:content-start sm:p-8">
<span className="col-start-1 row-start-1 flex size-11 items-center justify-center rounded-xl border border-border bg-surface text-foreground shadow-[var(--highlight)] transition-[color,transform,border-color] duration-500 ease-[var(--ease-kit)] group-hover:-translate-y-0.5 group-hover:-rotate-6 group-hover:border-border-strong group-hover:text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))] motion-reduce:transition-none">
<Icon name={value.icon ?? 'sparkle'} size={20} />
</span>
<span aria-hidden className="col-start-3 row-start-1 text-[13px] tabular-nums text-subtle-foreground transition-colors duration-300 group-hover:text-foreground sm:col-start-2 sm:justify-self-end">{String(index + 1).padStart(2, '0')}</span>
<h3 className="col-start-2 row-start-1 text-balance text-[17px] font-semibold tracking-[-0.02em] text-foreground sm:col-span-2 sm:col-start-1 sm:row-start-2 sm:mt-8 sm:text-[18px]">{value.title}</h3>
<p className="col-span-2 col-start-2 row-start-2 mt-2 text-pretty text-[15px] leading-relaxed text-muted-foreground sm:col-span-2 sm:col-start-1 sm:row-start-3 sm:mt-2.5">{value.text}</p>
{/* a short accent line grows under the text on hover and focus */}
<span aria-hidden className="col-span-3 col-start-1 mt-5 hidden h-px w-12 origin-left scale-x-0 bg-primary transition-transform duration-500 ease-[var(--ease-kit)] group-hover:scale-x-100 sm:col-span-2 sm:block" />
</Reveal>
</div>
);
}
function Values({ values }: { values: AboutValue[] }) {
return (
<ul className="grid grid-cols-1 gap-px overflow-hidden rounded-2xl border border-border bg-border sm:grid-cols-2 lg:grid-cols-3">
{values.map((v, i) => (
<li key={v.title} className="bg-background">
<ValueCell value={v} index={i} />
</li>
))}
</ul>
);
}
/* ------------------------------------------------------------------ */
/* Mission quote */
/* ------------------------------------------------------------------ */
function Mission({ eyebrow, quote, highlight, person, signature }: Pick<AboutProps, 'eyebrow' | 'quote' | 'highlight' | 'person' | 'signature'>) {
const quoteClass = 'mt-8 text-balance text-[clamp(2rem,1rem+3.6vw,4rem)] font-semibold leading-[1.05] tracking-[-0.04em] text-foreground';
return (
<figure className="mx-auto max-w-[60rem] text-center">
{eyebrow && (
<p className="kit-rise inline-flex items-center gap-2 rounded-full border border-border bg-background/70 px-3 py-1 text-[13px] font-medium text-muted-foreground shadow-[var(--shadow-sm)] backdrop-blur">
<span className="relative flex size-1.5" aria-hidden>
<span className="absolute inset-0 rounded-full bg-primary opacity-60 animate-kit-pulse motion-reduce:hidden" />
<span className="relative size-1.5 rounded-full bg-primary" />
</span>
{eyebrow}
</p>
)}
<blockquote>
{typeof quote === 'string' ? (
<WordReveal as="p" text={quote} highlight={highlight} stagger={80} delay={150} className={quoteClass} />
) : (
<p className={cn('kit-rise', quoteClass)} style={{ '--kit-delay': '100ms' } as React.CSSProperties}>
{quote}
</p>
)}
</blockquote>
{person && (
<figcaption className="kit-rise mt-12 flex flex-col items-center gap-4" style={{ '--kit-delay': '260ms' } as React.CSSProperties}>
{signature && <Drawn className="text-foreground opacity-80">{signature}</Drawn>}
<div className="flex items-center gap-3 text-left">
{person.image && <span data-clip-ok className={cn('relative block size-11 overflow-hidden rounded-full bg-muted ring-1 ring-border', fill)}>{person.image}</span>}
<span>
<span className="block text-[15px] font-semibold text-foreground">{person.name}</span>
<span className="block text-[13px] text-muted-foreground">{person.role}</span>
</span>
</div>
</figcaption>
)}
</figure>
);
}
/* ------------------------------------------------------------------ */
/* Locations */
/* ------------------------------------------------------------------ */
// Land as longitude ranges per 5° latitude band, from 72.5° N to 52.5° S.
// Drawn by hand for a dotted overview map, not for navigation.
const LAND: [number, [number, number][]][] = [
[72.5, [[-125, -80], [-55, -20], [55, 60], [75, 115], [125, 145]]],
[67.5, [[-165, -140], [-135, -90], [-75, -62], [-55, -25], [12, 30], [30, 180]]],
[62.5, [[-165, -140], [-140, -95], [-78, -65], [-50, -40], [-22, -14], [5, 30], [30, 180]]],
[57.5, [[-158, -152], [-130, -95], [-80, -60], [-7, -2], [6, 18], [22, 140], [155, 162]]],
[52.5, [[-128, -60], [-58, -53], [-10, 2], [0, 140], [142, 144], [156, 158]]],
[47.5, [[-125, -53], [-5, 40], [40, 47], [54, 135], [140, 145]]],
[42.5, [[-124, -70], [-9, 3], [10, 18], [19, 28], [27, 45], [53, 130], [126, 130], [139, 142]]],
[37.5, [[-123, -76], [-7, -1], [21, 24], [26, 44], [44, 75], [75, 122], [126, 129], [133, 141]]],
[32.5, [[-118, -80], [-9, 32], [35, 75], [75, 122], [130, 132]]],
[27.5, [[-112, -97], [-82, -80], [-14, 34], [35, 57], [57, 70], [70, 122], [120, 121]]],
[22.5, [[-106, -97], [-84, -75], [-16, 37], [39, 59], [69, 90], [92, 115]]],
[17.5, [[-102, -88], [-77, -65], [-16, 39], [42, 52], [73, 84], [94, 110], [120, 122]]],
[12.5, [[-90, -83], [-73, -62], [-17, 44], [75, 80], [98, 109], [122, 125]]],
[7.5, [[-82, -78], [-77, -55], [-13, 48], [80, 82], [98, 102], [122, 126]]],
[2.5, [[-80, -50], [9, 42], [96, 118], [120, 125]]],
[-2.5, [[-80, -45], [9, 41], [100, 106], [109, 117], [120, 123], [131, 150]]],
[-7.5, [[-79, -35], [12, 40], [106, 114], [138, 148]]],
[-12.5, [[-77, -38], [13, 41], [48, 50], [130, 136], [141, 144]]],
[-17.5, [[-71, -39], [12, 38], [44, 50], [122, 146]]],
[-22.5, [[-70, -41], [14, 35], [43, 48], [114, 150]]],
[-27.5, [[-71, -48], [15, 32], [114, 153]]],
[-32.5, [[-71, -52], [18, 28], [116, 124], [133, 152]]],
[-37.5, [[-73, -57], [140, 150], [174, 178]]],
[-42.5, [[-74, -63], [145, 148], [170, 174]]],
[-47.5, [[-75, -66], [167, 169]]],
[-52.5, [[-74, -68]]],
];
const MAP_W = 720;
const MAP_H = 260;
const px = (lon: number) => round((lon + 180) * 2);
const py = (lat: number) => round((75 - lat) * 2);
const DOTS = (() => {
const out: [number, number][] = [];
for (const [lat, ranges] of LAND) {
for (let lon = -177.5; lon < 180; lon += 5) {
if (ranges.some(([a, b]) => lon >= a - 1 && lon <= b + 1)) out.push([px(lon), py(lat)]);
}
}
return out;
})();
/**
* The land sweeps in from west to east once the map scrolls in, then the
* offices drop onto it. The office that is hovered or focused in the list
* grows on the map and its label lights up.
*/
function DotMap({ locations, label, active }: { locations: AboutLocation[]; label: string; active: number | null }) {
const [ref, inView] = useInView<HTMLDivElement>();
return (
<div ref={ref} className="relative">
<svg viewBox={`0 0 ${MAP_W} ${MAP_H}`} className="block h-auto w-full" role="img" aria-label={label}>
{DOTS.map(([x, y]) => (
<circle
key={`${x}-${y}`}
cx={x}
cy={y}
r="2.1"
className="transition-opacity duration-700 motion-reduce:transition-none"
style={{ fill: 'color-mix(in oklch, var(--foreground) 16%, transparent)', opacity: inView ? 1 : 0, transitionDelay: inView ? `${Math.round(x * 0.9)}ms` : '0ms' }}
/>
))}
{locations.map((l, i) => (
<g
key={l.city}
className="transition-[opacity,transform] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none"
style={{ transformBox: 'fill-box', transformOrigin: 'center', opacity: inView ? 1 : 0, transform: inView ? (active === i ? 'scale(1.5)' : 'none') : 'scale(0.2)', transitionDelay: inView && active === null ? `${700 + i * 140}ms` : '0ms' }}
>
<circle cx={px(l.lon)} cy={py(l.lat)} r="11" className="animate-kit-pulse motion-reduce:animate-none" style={{ fill: 'color-mix(in oklab, var(--primary) 22%, transparent)' }} />
<circle cx={px(l.lon)} cy={py(l.lat)} r="4.2" style={{ fill: 'var(--primary)', stroke: 'var(--background)', strokeWidth: 2 }} />
</g>
))}
</svg>
{/* Phones: numbered pins that match the list below, city names would collide */}
{locations.map((l, i) => (
<span
key={`n-${l.city}`}
aria-hidden
className={cn('pointer-events-none absolute flex size-[18px] -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-primary text-[10px] font-semibold tabular-nums text-primary-foreground shadow-[0_0_0_2px_var(--surface)] transition-[opacity,scale] duration-500 md:hidden', inView ? 'opacity-100' : 'opacity-0', active === i && 'scale-125')}
style={{ left: `${round((px(l.lon) / MAP_W) * 100)}%`, top: `${round((py(l.lat) / MAP_H) * 100)}%`, transitionDelay: inView && active === null ? `${700 + i * 140}ms` : '0ms' }}
>
{i + 1}
</span>
))}
{locations.map((l, i) => (
<span
key={l.city}
aria-hidden
className={cn(
'pointer-events-none absolute hidden -translate-x-1/2 -translate-y-[calc(100%+14px)] whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-medium shadow-[var(--shadow-md)] transition-[opacity,background-color,color,border-color] duration-300 md:block',
active === i ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-background text-foreground',
inView ? 'opacity-100' : 'opacity-0',
)}
style={{ left: `${round((px(l.lon) / MAP_W) * 100)}%`, top: `${round((py(l.lat) / MAP_H) * 100)}%`, transitionDelay: inView && active === null ? `${900 + i * 140}ms` : '0ms' }}
>
{l.city}
</span>
))}
</div>
);
}
function LocalTime({ timeZone }: { timeZone: string }) {
const locale = useLocale();
const [time, setTime] = useState<string | null>(null);
useEffect(() => {
const fmt = new Intl.DateTimeFormat(locale === 'de' ? 'de-DE' : 'en-US', { hour: locale === 'de' ? '2-digit' : 'numeric', minute: '2-digit', timeZone });
const tick = () => setTime(fmt.format(new Date()));
tick();
const id = window.setInterval(tick, 30000);
return () => window.clearInterval(id);
}, [locale, timeZone]);
return <span className="tabular-nums">{time ?? <span className="opacity-0">00:00</span>}</span>;
}
function Locations({ locations, t }: { locations: AboutLocation[]; t: Texts }) {
const [active, setActive] = useState<number | null>(null);
return (
<>
<Reveal effect="scale">
<div className="relative overflow-hidden rounded-2xl border border-border bg-surface px-3 py-8 shadow-[var(--highlight)] sm:px-8 sm:py-12">
<div aria-hidden className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[color-mix(in_oklab,var(--primary)_40%,transparent)] to-transparent" />
<DotMap locations={locations} label={t.map} active={active} />
</div>
</Reveal>
<ul className="mt-6 grid grid-cols-1 gap-px overflow-hidden rounded-2xl border border-border bg-border sm:grid-cols-2 lg:grid-cols-4" onPointerLeave={() => setActive(null)}>
{locations.map((l, i) => (
<li
key={l.city}
tabIndex={0}
onPointerEnter={() => setActive(i)}
onFocus={() => setActive(i)}
onBlur={() => setActive(null)}
onClick={() => setActive(i)}
className={cn('relative bg-background outline-none transition-colors duration-300 focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring', active === i && 'bg-surface')}
>
<span aria-hidden className={cn('absolute inset-x-0 top-0 h-0.5 origin-left bg-primary transition-transform duration-500 ease-[var(--ease-kit)]', active === i ? 'scale-x-100' : 'scale-x-0')} />
<Reveal delay={i * 70} className="flex h-full flex-col p-6">
<div className="flex items-center justify-between gap-3">
<h3 className="flex items-center gap-2.5 text-[17px] font-semibold tracking-[-0.02em] text-foreground">
<span aria-hidden className="flex size-[18px] items-center justify-center rounded-full bg-primary text-[10px] font-semibold tabular-nums tracking-normal text-primary-foreground md:hidden">
{i + 1}
</span>
{l.city}
</h3>
{l.headquarters && <Badge variant="soft">{t.hq}</Badge>}
</div>
{l.country && <p className="mt-0.5 text-[13px] text-muted-foreground">{l.country}</p>}
{l.address && <p className="mt-4 whitespace-pre-line text-[14px] leading-relaxed text-muted-foreground">{l.address}</p>}
<div className="mt-auto flex items-center justify-between gap-3 border-t border-border pt-4 text-[13px] text-muted-foreground [&:not(:first-child)]:mt-6">
{l.timeZone && (
<span className="flex items-center gap-1.5">
<Icon name="clock" size={14} />
<span className="sr-only">{t.local}</span>
<LocalTime timeZone={l.timeZone} />
</span>
)}
{l.people !== undefined && (
<span className="flex items-center gap-1.5">
<Icon name="users" size={14} />
{t.people.replace('{n}', String(l.people))}
</span>
)}
</div>
</Reveal>
</li>
))}
</ul>
</>
);
}
/* ------------------------------------------------------------------ */
/* Numbers and text */
/* ------------------------------------------------------------------ */
/**
* Counts up once it scrolls in. The final value is in the box from the
* first paint (invisible), the count sits on top, so the cell never changes width.
*/
function CountUp({ n }: { n: AboutNumber }) {
const locale = useLocale();
const [ref, inView] = useInView<HTMLSpanElement>();
const [shown, setShown] = useState(n.value);
useEffect(() => {
if (!inView) {
setShown(0);
return;
}
let frame = 0;
const start = performance.now();
const run = (now: number) => {
const p = Math.min(1, (now - start) / 1400);
const eased = 1 - Math.pow(1 - p, 3);
setShown(n.value * eased);
if (p < 1) frame = requestAnimationFrame(run);
};
if (shown !== n.value) frame = requestAnimationFrame(run);
return () => cancelAnimationFrame(frame);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inView, n.value]);
const format = (v: number) => v.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { minimumFractionDigits: n.decimals ?? 0, maximumFractionDigits: n.decimals ?? 0 });
return (
<span ref={ref} className="inline-grid tabular-nums [&>*]:[grid-area:1/1]">
<span aria-hidden className="invisible">
{n.prefix}
{format(n.value)}
{n.suffix}
</span>
<span className="justify-self-start">
{n.prefix}
{format(shown)}
{n.suffix}
</span>
</span>
);
}
function NumbersText({ eyebrow, title, body, numbers }: Pick<AboutProps, 'eyebrow' | 'title' | 'body' | 'numbers'>) {
const [grid, inView] = useInView<HTMLDListElement>();
return (
<div className="grid grid-cols-1 gap-12 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)] lg:gap-20">
<div>
{eyebrow && <p className="mb-4 text-sm font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{eyebrow}</p>}
{title && <h2 className="text-balance text-h2 font-semibold text-foreground">{title}</h2>}
<Stagger step={110} className="mt-7 space-y-5 text-pretty text-[16px] leading-relaxed text-muted-foreground sm:text-[17px]">
{body?.map((p, i) => (
<p key={i} className={cn(i === 0 && 'text-foreground')}>
{p}
</p>
))}
</Stagger>
</div>
<dl ref={grid} className="grid grid-cols-2 gap-px self-start overflow-hidden rounded-2xl border border-border bg-border">
{numbers?.map((n, i) => (
// The cell keeps its background (no fade), the count up and the line are the motion
<div key={n.label} className="group relative bg-background transition-colors duration-300 hover:bg-surface">
<span aria-hidden className={cn('absolute inset-x-0 top-0 h-0.5 origin-left bg-gradient-to-r from-primary to-transparent transition-transform duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none', inView ? 'scale-x-100' : 'scale-x-0')} style={{ transitionDelay: inView ? `${i * 140}ms` : '0ms' }} />
<div className="flex h-full flex-col justify-between gap-6 p-5 sm:p-8">
<dt className="order-2 text-pretty text-[14px] leading-snug text-muted-foreground">{n.label}</dt>
<dd className={cn('order-1 whitespace-nowrap text-[clamp(1.75rem,1.2rem+2.2vw,3.25rem)] font-semibold leading-none tracking-[-0.045em] transition-transform duration-500 ease-[var(--ease-kit)] group-hover:-translate-y-0.5', i === 0 ? 'kit-accent-text' : 'text-foreground')}>
<CountUp n={n} />
</dd>
</div>
</div>
))}
</dl>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Founders */
/* ------------------------------------------------------------------ */
/** Portrait whose picture drifts against the page, the offset column a little more */
function Portrait({ person, depth }: { person: AboutPerson; depth: number }) {
const ref = useScrollProgress<HTMLDivElement>();
return (
<figure className="group">
<div ref={ref} className="relative aspect-[4/5] overflow-hidden rounded-2xl border border-border bg-muted shadow-[var(--shadow-md)] transition-shadow duration-500 group-hover:shadow-[var(--shadow-lg)]">
<div data-clip-ok className="absolute inset-0 transition-transform duration-700 ease-[var(--ease-kit)] group-hover:scale-[1.04] motion-reduce:transition-none">
<div className={cn('absolute inset-0 will-change-transform', fill)} style={{ transform: `translate3d(0, calc((0.5 - var(--kit-progress, 0.5)) * ${depth}%), 0) scale(${1 + depth / 100 + 0.04})` }}>
{person.image}
</div>
</div>
</div>
<figcaption className="mt-4">
<p className="text-[16px] font-semibold tracking-[-0.015em] text-foreground">{person.name}</p>
<p className="mt-0.5 text-[14px] text-muted-foreground">{person.role}</p>
{person.bio && <p className="mt-3 hidden text-pretty text-[14px] leading-relaxed text-muted-foreground sm:block">{person.bio}</p>}
</figcaption>
</figure>
);
}
function Founders({ eyebrow, title, body, people, signature }: Pick<AboutProps, 'eyebrow' | 'title' | 'body' | 'people' | 'signature'>) {
// the offset column also rides slower than the page
const offset = useScrollProgress<HTMLUListElement>();
return (
<div className="grid grid-cols-1 items-start gap-14 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:gap-20">
<div className="lg:sticky lg:top-24">
{eyebrow && <p className="mb-4 text-sm font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{eyebrow}</p>}
{title && <h2 className="text-balance text-h2 font-semibold text-foreground">{title}</h2>}
<Stagger step={110} className="mt-7 space-y-5 text-pretty text-[16px] leading-relaxed text-muted-foreground sm:text-[17px]">
{body?.map((p, i) => <p key={i}>{p}</p>)}
</Stagger>
{signature && (
<div className="mt-10 flex items-center gap-4 border-t border-border pt-8">
<Drawn className="text-foreground">{signature}</Drawn>
<p className="text-[13px] text-muted-foreground">{people?.map((p) => p.name.split(' ')[0]).join(' & ')}</p>
</div>
)}
</div>
<ul ref={offset} className="grid grid-cols-2 gap-4 sm:gap-6">
{people?.map((p, i) => (
<Reveal as="li" key={p.name} delay={i * 120} className={cn(i % 2 === 1 && 'mt-10 sm:mt-16')}>
<div style={i % 2 === 1 ? { transform: 'translate3d(0, calc((0.5 - var(--kit-progress, 0.5)) * 40px), 0)' } : undefined}>
<Portrait person={p} depth={i % 2 === 1 ? 10 : 6} />
</div>
</Reveal>
))}
</ul>
</div>
);
}
/**
* Who you are and why. story-timeline tells the history, values lists what
* you stand for, mission-quote says it in one sentence, locations draws a
* dotted world map, numbers-and-text backs the story with figures, founders
* puts the people who started it next to the story.
*/
export function About({ variant = 'story-timeline', eyebrow, title, description, body, milestones = [], values = [], quote, highlight, person, signature, locations = [], numbers = [], people = [], labels, className }: AboutProps) {
const t = useTexts(TEXTS, labels);
switch (variant) {
case 'mission-quote':
return (
<Section spacing="lg" background="dots" className={className}>
<Mission eyebrow={eyebrow} quote={quote} highlight={highlight} person={person} signature={signature} />
</Section>
);
case 'numbers-and-text':
return (
<Section className={className}>
<NumbersText eyebrow={eyebrow} title={title} body={body} numbers={numbers} />
</Section>
);
case 'founders':
return (
<Section className={className}>
<Founders eyebrow={eyebrow} title={title} body={body} people={people} signature={signature} />
</Section>
);
case 'values':
return (
<Section className={className}>
{title && <SectionHeader eyebrow={eyebrow} title={title} description={description} align="split" />}
<Values values={values} />
</Section>
);
case 'locations':
return (
<Section className={className}>
{title && <SectionHeader eyebrow={eyebrow} title={title} description={description} align="center" />}
<Locations locations={locations} t={t} />
</Section>
);
default:
return (
<Section className={className}>
{title && <SectionHeader eyebrow={eyebrow} title={title} description={description} align="center" />}
<Timeline milestones={milestones} />
</Section>
);
}
}Häufige Fragen
Wie installiere ich Über uns?
Mit aiw add @ai-website/about. Der Befehl legt die Datei components/ui/about.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind badge, icon, section und reveal. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Über uns?
6 Varianten, Geschichte als Zeitleiste, Werte, Mission als Zitat, Standorte, Zahlen und Text und Gründer. Du wählst sie mit der Prop variant, zum Beispiel variant="values".
Kann ich die Texte von Über uns übersetzen?
Ja. Über uns bringt 5 eigene Texte auf Deutsch und Englisch mit, etwa „Hauptsitz“, „Ortszeit“ und „Standorte“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Über uns einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Über uns mit aiw_components und installieren sie mit aiw_add @ai-website/about. Nenn im Prompt die Variante, etwa variant="story-timeline", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Über uns ohne Code anpassen?
Ja, im Studio. Alle 6 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden About-Code zum Kopieren.