Zum Inhalt springen
UI Kit
Accent

Über uns

Geschichte, Werte, Mission, Standorte auf einer Punktkarte, Zahlen und die Gründer.

Eine von 2 Components in About

6 Variants

Variants

Geschichte als Zeitleiste

Meilensteine wechseln an einer Mittellinie, auf dem Telefon folgen sie einer Linie links.

Im Studio öffnen

Werte

Sechs Grundsätze in einem lückenlosen Raster, ein weiches Licht folgt dem Zeiger.

Im Studio öffnen

Mission als Zitat

Ein Satz, groß und mittig, unterschrieben von der Person, die ihn meint.

Im Studio öffnen

Standorte

Eine gepunktete Weltkarte aus Längen- und Breitengrad, mit Ortszeit je Standort.

Im Studio öffnen

Zahlen und Text

Die Geschichte links, vier Zahlen, die sie belegen, rechts. Sie zählen einmal hoch.

Im Studio öffnen

Gründer

Warum es begann, erzählt neben den beiden, die es begonnen haben.

Im Studio öffnen

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

Props

Die Schnittstelle AboutProps aus components/ui/about.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    AboutVariant

    story-timelinevaluesmission-quotelocationsnumbers-and-textfounders

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • bodyoptional
    string[]

    Running text, one paragraph per entry (numbers-and-text, founders)

  • milestonesoptional
    AboutMilestone[]
  • valuesoptional
    AboutValue[]
  • quoteoptional
    React.ReactNode

    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.

  • highlightoptional
    string

    The phrase of a string quote that takes the accent color

  • personoptional
    AboutPerson

    Who said it (mission-quote) or who signs it (founders)

  • signatureoptional
    React.ReactNode

    A drawn signature or any node under founders and mission-quote. Its SVG strokes draw themselves once.

  • locationsoptional
    AboutLocation[]
  • numbersoptional
    AboutNumber[]
  • peopleoptional
    AboutPerson[]

Eingebaute Texte

Diese Texte schreibt Über uns selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 5 Texte anzeigen
hqEnglisch Head officeDeutsch Hauptsitz
localEnglisch Local timeDeutsch Ortszeit
peopleEnglisch {n} peopleDeutsch {n} Personen
officesEnglisch OfficesDeutsch Standorte
mapEnglisch Map of our officesDeutsch 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
components/ui/about.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert