Zum Inhalt springen
UI Kit
Accent

Infografik

Zahlen als Bilder erzählt. Von 100 in einem Feld aus Marken, Größen als echte Kreise, wohin ein Ganzes geht, vorher und nachher je Kategorie und Ströme von Quellen zu Verwendungen. Alles in Akzent und Textfarben.

Eine von 2 Components in Datengeschichten

5 Variants

Variants

Von 100

Ein Feld aus 100 Marken. Jede Aussage daneben lässt ihren Anteil Reihe für Reihe aufleuchten, Pfeiltasten wechseln.

Im Studio öffnen

Größen im Vergleich

Kreise, deren Fläche der Wert ist, damit der Vergleich ehrlich bleibt. Sie wachsen beim Hereinscrollen von der Grundlinie, der größte im Akzent.

Im Studio öffnen

Wohin es geht

Ein Balken aus Anteilen, der sich von links füllt, und eine Liste mit Betrag und Anteil. Ein Anteil leuchtet in beiden auf, die anderen treten zurück.

Im Studio öffnen

Vorher und nachher

Zwei Zeitpunkte je Kategorie. Der Punkt wandert beim Hereinscrollen vom alten zum neuen Wert, die Veränderung steht neben der Beschriftung.

Im Studio öffnen

Ströme

Von Quellen zu Verwendungen in Bändern, deren Breite die Menge ist. Sie zeichnen sich von links nach rechts, ein Name lässt seine Bänder aufleuchten.

Im Studio öffnen

Details

Was Infografik mitbringt, aus Registry und Quellcode gelesen.

Bereich
Content
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/infographic.tsx
Bringt mit
utilskiticonsection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 7 eigene Texte

Props

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

  • variantoptional
    InfographicVariant

    unitsscalebreakdowndumbbellflow

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • statementsoptional
    InfographicStatement[]

    units

  • iconoptional
    IconName

    units: the mark of one unit

  • itemsoptional
    InfographicItem[]

    scale, breakdown, dumbbell

  • flowsoptional
    InfographicFlow[]

    flow

  • unitoptional
    string

    Unit after the numbers, e.g. €, %, kg

  • periodsoptional
    [string, string]

    dumbbell: names of the two moments, e.g. ['2021', '2026']

  • sourceoptional
    string

    Where the numbers come from

Eingebaute Texte

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

Alle 7 Texte anzeigen
outOfEnglisch {n} out of 100Deutsch {n} von 100
sourceEnglisch SourceDeutsch Quelle
totalEnglisch TotalDeutsch Gesamt
fromEnglisch FromDeutsch Von
toEnglisch ToDeutsch Nach
changeEnglisch ChangeDeutsch Veränderung
pointsEnglisch {n} ptsDeutsch {n} Pkt.

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Infografik reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links und Pfeil rechts.
  • Setzt aria-controls, aria-label, aria-labelledby, aria-orientation, aria-pressed und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen img, tab, tablist und tabpanel.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • 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).
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 6 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 549 Zeilen
components/ui/infographic.tsx
'use client';

import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Eyebrow } from '@/components/ui/section';

/*
 * Numbers told as pictures. Each variant answers one kind of question and
 * draws it in the accent and the text colors, so it fits every theme.
 *
 *   units      out of 100: a field of 100 marks, switch between statements
 *              and the share lights up
 *   scale      sizes compared as circles with true areas, they grow into view
 *   breakdown  where a whole goes: one bar of shares and the list beside it,
 *              a share lights up in both
 *   dumbbell   before and after per category, the dot travels to its new value
 *   flow       from sources to uses: bands whose width is the amount
 */

export type InfographicVariant = 'units' | 'scale' | 'breakdown' | 'dumbbell' | 'flow';

export interface InfographicStatement {
  /** Out of 100 */
  value: number;
  /** e.g. pay by card */
  label: string;
  /** A sentence under the field */
  note?: string;
}

export interface InfographicItem {
  label: string;
  value: number;
  /** dumbbell: the earlier value */
  before?: number;
  /** A short line under the label */
  note?: string;
  icon?: IconName;
}

export interface InfographicFlow {
  from: string;
  to: string;
  value: number;
}

export interface InfographicProps {
  variant?: InfographicVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  /** units */
  statements?: InfographicStatement[];
  /** units: the mark of one unit */
  icon?: IconName;
  /** scale, breakdown, dumbbell */
  items?: InfographicItem[];
  /** flow */
  flows?: InfographicFlow[];
  /** Unit after the numbers, e.g. €, %, kg */
  unit?: string;
  /** dumbbell: names of the two moments, e.g. ['2021', '2026'] */
  periods?: [string, string];
  /** Where the numbers come from */
  source?: string;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: { outOf: '{n} out of 100', source: 'Source', total: 'Total', from: 'From', to: 'To', change: 'Change', points: '{n} pts' },
  de: { outOf: '{n} von 100', source: 'Quelle', total: 'Gesamt', from: 'Von', to: 'Nach', change: 'Veränderung', points: '{n} Pkt.' },
};

type Texts = (typeof TEXTS)['en'];

/* ------------------------------------------------------------------ */
/* Shared pieces                                                       */
/* ------------------------------------------------------------------ */

function useNum() {
  const locale = useLocale();
  const tag = locale === 'de' ? 'de-DE' : 'en-US';
  const nf = new Intl.NumberFormat(tag, { maximumFractionDigits: 1 });
  // Money keeps its cents, 0.95 € must not turn into 1 €
  const money = new Intl.NumberFormat(tag, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
  return (n: number, unit?: string) => {
    if (!unit) return nf.format(n);
    if (unit === '%') return `${nf.format(n)}${locale === 'de' ? ' %' : '%'}`;
    if (unit === '€' || unit === '$') {
      const v = Number.isInteger(n) ? nf.format(n) : money.format(n);
      return locale === 'de' ? `${v} ${unit}` : `${unit}${v}`;
    }
    return `${nf.format(n)} ${unit}`;
  };
}

/** true once the element is a third into view; true right away without JS or with reduced motion */
function useSeen<T extends Element>() {
  const ref = useRef<T>(null);
  const [v, setV] = useState(true);
  useEffect(() => {
    const el = ref.current;
    if (!el || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    if (el.getBoundingClientRect().top < window.innerHeight * 0.75) return;
    setV(false);
    const io = new IntersectionObserver(([e]) => {
      if (e.isIntersecting) {
        setV(true);
        io.disconnect();
      }
    }, { rootMargin: '0px 0px -25% 0px' });
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return [ref, v] as const;
}

/** Steps of the accent from strong to light, for shares of one whole */
const tone = (i: number, n: number) => `color-mix(in oklab, var(--primary) ${round(100 - (i / Math.max(1, n - 1)) * 72)}%, var(--background))`;

function Head({ eyebrow, title, description }: { eyebrow?: React.ReactNode; title?: React.ReactNode; description?: React.ReactNode }) {
  if (!title && !eyebrow) return null;
  return (
    <div className="mb-10 max-w-[42rem] sm:mb-12">
      {eyebrow && <Eyebrow className="kit-rise">{eyebrow}</Eyebrow>}
      {title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>{title}</h2>}
      {description && <p className="kit-rise mt-4 text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{description}</p>}
    </div>
  );
}

function Source({ text, t }: { text?: string; t: Texts }) {
  if (!text) return null;
  return (
    <p className="mt-8 text-[13px] text-muted-foreground">
      <span className="font-medium text-foreground">{t.source}</span> {text}
    </p>
  );
}

/* ------------------------------------------------------------------ */
/* units                                                               */
/* ------------------------------------------------------------------ */

function Units(p: InfographicProps & { t: Texts }) {
  const { t } = p;
  const uid = useId().replace(/:/g, '');
  const list = p.statements ?? [];
  const [i, setI] = useState(0);
  const [ref, seen] = useSeen<HTMLDivElement>();
  const s = list[i];
  const on = seen ? Math.round(s?.value ?? 0) : 0;
  const key = (e: React.KeyboardEvent) => {
    if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
    e.preventDefault();
    const n = (i + (e.key === 'ArrowDown' || e.key === 'ArrowRight' ? 1 : list.length - 1)) % list.length;
    setI(n);
    (e.currentTarget.querySelectorAll('[role="tab"]')[n] as HTMLElement | undefined)?.focus();
  };
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
        <div ref={ref} className="grid grid-cols-[minmax(0,1fr)] items-center gap-10 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:grid-cols-[minmax(0,30rem)_minmax(0,26rem)] lg:justify-between lg:gap-16">
          <div id={`${uid}-panel`} role="tabpanel" aria-labelledby={`${uid}-t${i}`} className="order-last mx-auto w-full max-w-[26rem] md:order-first md:max-w-none">
            <div role="img" aria-label={`${t.outOf.replace('{n}', String(Math.round(s?.value ?? 0)))}: ${s?.label ?? ''}`} className="grid grid-cols-10 gap-1.5 sm:gap-2">
              {Array.from({ length: 100 }, (_, k) => {
                const lit = k < on;
                return (
                  <span
                    key={k}
                    className={cn('grid aspect-square place-items-center rounded-[28%] transition-[background-color,color,transform] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', lit ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground')}
                    style={{ transitionDelay: `${(k % 10) * 18 + Math.floor(k / 10) * 26}ms` }}
                    aria-hidden
                  >
                    {p.icon && <Icon name={p.icon} size={17} strokeWidth={1.8} className="max-lg:hidden" />}
                  </span>
                );
              })}
            </div>
            {s?.note && (
              <p key={i} className="kit-rise mt-5 text-pretty text-[15px] leading-[1.6] text-muted-foreground">
                {s.note}
              </p>
            )}
          </div>
          <div role="tablist" aria-orientation="vertical" aria-label={typeof p.title === 'string' ? p.title : undefined} onKeyDown={key} className="divide-y divide-border border-y border-border">
            {list.map((x, k) => (
              <button
                key={k}
                type="button"
                role="tab"
                id={`${uid}-t${k}`}
                aria-selected={i === k}
                aria-controls={`${uid}-panel`}
                tabIndex={i === k ? 0 : -1}
                onClick={() => setI(k)}
                className={cn('group/s flex w-full items-baseline gap-5 py-5 text-left transition-colors duration-300', i === k ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}
              >
                <span className={cn('w-20 shrink-0 text-[40px] font-semibold leading-none tracking-[-0.04em] tabular-nums transition-colors duration-300 sm:text-[48px]', i === k ? 'text-primary' : '')}>{Math.round(x.value)}</span>
                <span className="min-w-0 text-pretty text-[16.5px] font-medium leading-[1.4]">{x.label}</span>
              </button>
            ))}
          </div>
        </div>
        <Source text={p.source} t={t} />
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* scale                                                               */
/* ------------------------------------------------------------------ */

function Scale(p: InfographicProps & { t: Texts }) {
  const { t } = p;
  const num = useNum();
  const items = p.items ?? [];
  const max = Math.max(1, ...items.map((x) => x.value));
  const [ref, seen] = useSeen<HTMLDivElement>();
  const [hot, setHot] = useState<number | null>(null);
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
        <div ref={ref} className="flex flex-wrap items-end justify-center gap-x-6 gap-y-10 sm:gap-x-10 lg:flex-nowrap lg:justify-between">
          {items.map((x, i) => {
            // Area is the value: the diameter grows with the square root
            const d = round(Math.sqrt(x.value / max) * 100, 2);
            return (
              <figure key={x.label} onPointerEnter={() => setHot(i)} onPointerLeave={() => setHot(null)} className="flex w-[calc(50%-12px)] flex-col items-center text-center sm:w-[calc(50%-20px)] lg:w-auto lg:flex-1">
                <div className="relative flex w-full max-w-[13rem] items-end lg:max-w-[15rem] justify-center" style={{ aspectRatio: '1 / 1' }}>
                  <span
                    className={cn('block shrink-0 rounded-full transition-[transform,background-color,box-shadow] duration-[900ms] ease-[var(--ease-kit)] motion-reduce:transition-none', i === items.length - 1 || hot === i ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--primary)_45%,var(--background))]', hot === i && 'shadow-[0_0_0_6px_color-mix(in_oklab,var(--primary)_18%,transparent)]')}
                    style={{ width: `${d}%`, aspectRatio: '1 / 1', transform: `scale(${seen ? 1 : 0.2})`, transformOrigin: '50% 100%', transitionDelay: `${i * 120}ms` }}
                    aria-hidden
                  />
                </div>
                <figcaption className="mt-4">
                  <span className="block text-[26px] font-semibold leading-none tracking-[-0.03em] text-foreground tabular-nums sm:text-[30px]">{num(x.value, p.unit)}</span>
                  <span className="mt-2 block text-pretty text-[14.5px] font-medium text-foreground">{x.label}</span>
                  {x.note && <span className="mt-0.5 block text-pretty text-[13px] text-muted-foreground">{x.note}</span>}
                </figcaption>
              </figure>
            );
          })}
        </div>
        <Source text={p.source} t={t} />
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* breakdown                                                           */
/* ------------------------------------------------------------------ */

function Breakdown(p: InfographicProps & { t: Texts }) {
  const { t } = p;
  const num = useNum();
  const items = useMemo(() => [...(p.items ?? [])].sort((a, b) => b.value - a.value), [p.items]);
  const total = items.reduce((a, x) => a + x.value, 0) || 1;
  const [ref, seen] = useSeen<HTMLDivElement>();
  const [hot, setHot] = useState<number | null>(null);
  let acc = 0;
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
        <div ref={ref}>
          {/* One bar of shares, 2 px of surface between them */}
          <div className="relative flex h-16 gap-0.5 overflow-hidden rounded-xl sm:h-20" role="img" aria-label={items.map((x) => `${x.label} ${num(round((x.value / total) * 100, 1), '%')}`).join(', ')}>
            {items.map((x, i) => {
              const w = (x.value / total) * 100;
              const start = acc;
              acc += w;
              return (
                <span
                  key={x.label}
                  onPointerEnter={() => setHot(i)}
                  onPointerLeave={() => setHot(null)}
                  className={cn('relative h-full origin-left transition-[transform,opacity,filter] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', hot !== null && hot !== i && 'opacity-40')}
                  style={{ width: `${round(w, 3)}%`, background: tone(i, items.length), transform: `scaleX(${seen ? 1 : 0})`, transitionDelay: seen ? `${round(start * 9)}ms` : '0ms' }}
                >
                  {w > 9 && <span className="absolute bottom-2 left-3 text-[13px] font-semibold tabular-nums text-[color-mix(in_oklab,var(--background)_92%,var(--foreground))] mix-blend-normal [text-shadow:0_1px_2px_rgb(0_0_0/0.15)]">{num(Math.round(w), '%')}</span>}
                </span>
              );
            })}
          </div>
          <ul className="mt-8 grid grid-cols-[minmax(0,1fr)] gap-x-10 sm:grid-cols-2 lg:grid-cols-3">
            {items.map((x, i) => (
              <li key={x.label} onPointerEnter={() => setHot(i)} onPointerLeave={() => setHot(null)} className={cn('flex items-start gap-3 border-b border-border py-4 transition-opacity duration-300', hot !== null && hot !== i && 'opacity-50')}>
                <span className="mt-1 size-3 shrink-0 rounded-[3px]" style={{ background: tone(i, items.length) }} aria-hidden />
                <span className="min-w-0 flex-1">
                  <span className="flex items-baseline justify-between gap-3">
                    <span className="text-[15.5px] font-semibold text-foreground">{x.label}</span>
                    <span className="text-[15.5px] font-semibold tabular-nums text-foreground">{num(x.value, p.unit)}</span>
                  </span>
                  <span className="mt-0.5 flex items-baseline justify-between gap-3 text-[13.5px] text-muted-foreground">
                    <span className="text-pretty">{x.note}</span>
                    <span className="shrink-0 tabular-nums">{num(round((x.value / total) * 100, 1), '%')}</span>
                  </span>
                </span>
              </li>
            ))}
          </ul>
          <p className="mt-5 flex justify-end gap-3 text-[15px] font-semibold text-foreground">
            <span className="text-muted-foreground">{t.total}</span>
            <span className="tabular-nums">{num(total, p.unit)}</span>
          </p>
        </div>
        <Source text={p.source} t={t} />
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* dumbbell                                                            */
/* ------------------------------------------------------------------ */

function Dumbbell(p: InfographicProps & { t: Texts }) {
  const { t } = p;
  const num = useNum();
  const items = p.items ?? [];
  const vals = items.flatMap((x) => [x.value, x.before ?? x.value]);
  const lo = Math.min(0, ...vals);
  // Round steps for the scale: 1, 2, 5, 10, 20, 25, 50 …
  const raw = (Math.max(...vals) - lo) / 4 || 1;
  const mag = 10 ** Math.floor(Math.log10(raw));
  const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((x) => x >= raw) ?? 10 * mag;
  const hi = lo + Math.ceil((Math.max(...vals) - lo) / step) * step || 1;
  // 2 % inset on both ends, so a dot at zero stays inside the row
  const pos = (v: number) => round(2 + ((v - lo) / (hi - lo)) * 96, 3);
  const [ref, seen] = useSeen<HTMLDivElement>();
  const [a, b] = p.periods ?? ['', ''];
  const ticks = Array.from({ length: Math.round((hi - lo) / step) + 1 }, (_, k) => round(lo + k * step, 2));
  // A change between two shares is in percentage points, with a real minus sign
  const delta = (d: number) => `${d > 0 ? '+' : d < 0 ? '−' : ''}${p.unit === '%' ? t.points.replace('{n}', num(Math.abs(d))) : num(Math.abs(d), p.unit)}`;
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
        <div className="mb-6 flex flex-wrap items-center gap-5 text-[13.5px] text-muted-foreground" aria-hidden>
          <span className="inline-flex items-center gap-2">
            <span className="size-3 rounded-full border-2 border-[color-mix(in_oklab,var(--foreground)_45%,var(--background))] bg-background" />
            {a}
          </span>
          <span className="inline-flex items-center gap-2">
            <span className="size-3 rounded-full bg-primary" />
            {b}
          </span>
        </div>
        <div ref={ref} className="relative">
          <div className="pointer-events-none absolute inset-y-0 left-0 right-0 hidden sm:left-[12rem] sm:block lg:left-[15rem]" aria-hidden>
            {ticks.map((v) => (
              <span key={v} className="absolute inset-y-0 w-px bg-border" style={{ left: `${pos(v)}%` }} />
            ))}
          </div>
          <table className="sr-only">
            <caption>{typeof p.title === 'string' ? p.title : ''}</caption>
            <thead>
              <tr>
                <th scope="col" />
                <th scope="col">{a}</th>
                <th scope="col">{b}</th>
                <th scope="col">{t.change}</th>
              </tr>
            </thead>
            <tbody>
              {items.map((x) => (
                <tr key={x.label}>
                  <th scope="row">{x.label}</th>
                  <td>{num(x.before ?? x.value, p.unit)}</td>
                  <td>{num(x.value, p.unit)}</td>
                  <td>{delta(x.value - (x.before ?? x.value))}</td>
                </tr>
              ))}
            </tbody>
          </table>
          <ul aria-hidden>
            {items.map((x, i) => {
              const from = x.before ?? x.value;
              const l = Math.min(pos(from), pos(seen ? x.value : from));
              const r = Math.max(pos(from), pos(seen ? x.value : from));
              return (
                <li key={x.label} className="group/r grid grid-cols-[minmax(0,1fr)] gap-1 border-b border-border py-4 sm:grid-cols-[12rem_minmax(0,1fr)] sm:items-center sm:gap-0 sm:py-3 lg:grid-cols-[15rem_minmax(0,1fr)]">
                  <p className="text-[15px] font-semibold text-foreground sm:pr-6">
                    {x.label}
                    <span className="ml-2 font-normal tabular-nums text-muted-foreground sm:ml-0 sm:block sm:text-[13px]">
                      {delta(x.value - from)}
                    </span>
                  </p>
                  <div className="relative h-10">
                    <span className="absolute top-1/2 h-1 -translate-y-1/2 rounded-full bg-[color-mix(in_oklab,var(--primary)_35%,var(--background))] transition-[left,width] duration-[1100ms] ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ left: `${l}%`, width: `${round(r - l, 3)}%`, transitionDelay: `${i * 90}ms` }} />
                    <span className="absolute top-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[color-mix(in_oklab,var(--foreground)_45%,var(--background))] bg-background" style={{ left: `${pos(from)}%` }} />
                    <span className="absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary shadow-[0_0_0_3px_var(--background)] transition-[left] duration-[1100ms] ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ left: `${pos(seen ? x.value : from)}%`, transitionDelay: `${i * 90}ms` }} />
                    <span className="absolute -top-2 -translate-x-1/2 whitespace-nowrap text-[12.5px] font-semibold tabular-nums text-foreground opacity-0 transition-opacity duration-300 group-hover/r:opacity-100" style={{ left: `${pos(x.value)}%` }}>
                      {num(x.value, p.unit)}
                    </span>
                  </div>
                </li>
              );
            })}
          </ul>
          <div className="relative mt-2 hidden h-5 text-[12.5px] tabular-nums text-muted-foreground sm:ml-[12rem] sm:block lg:ml-[15rem]" aria-hidden>
            {ticks.map((v, k) => (
              <span key={v} className={cn('absolute whitespace-nowrap', k === 0 ? '' : k === ticks.length - 1 ? '-translate-x-full' : '-translate-x-1/2')} style={{ left: `${pos(v)}%` }}>
                {num(v, p.unit)}
              </span>
            ))}
          </div>
        </div>
        <Source text={p.source} t={t} />
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* flow                                                                */
/* ------------------------------------------------------------------ */

function Flow(p: InfographicProps & { t: Texts }) {
  const { t } = p;
  const num = useNum();
  const flows = p.flows ?? [];
  const lefts = [...new Set(flows.map((f) => f.from))];
  const rights = [...new Set(flows.map((f) => f.to))];
  const total = flows.reduce((a, f) => a + f.value, 0) || 1;
  const [hot, setHot] = useState<string | null>(null);
  const [ref, seen] = useSeen<HTMLDivElement>();
  const H = 420;
  const GAP = 30;
  const W = 1000;
  const NODE = 14;
  const scale = (H - GAP * (Math.max(lefts.length, rights.length) - 1)) / total;
  const sum = (key: 'from' | 'to', name: string) => flows.filter((f) => f[key] === name).reduce((a, f) => a + f.value, 0);
  const place = (names: string[], key: 'from' | 'to') => {
    let y = 0;
    const used = names.reduce((a, n) => a + sum(key, n) * scale, 0) + GAP * (names.length - 1);
    y = (H - used) / 2;
    return Object.fromEntries(
      names.map((n) => {
        const h = sum(key, n) * scale;
        const r = { y, h };
        y += h + GAP;
        return [n, r];
      }),
    );
  };
  const L = place(lefts, 'from');
  const R = place(rights, 'to');
  const offL: Record<string, number> = {};
  const offR: Record<string, number> = {};
  const bands = flows.map((f) => {
    const h = f.value * scale;
    const y0 = L[f.from].y + (offL[f.from] ?? 0);
    const y1 = R[f.to].y + (offR[f.to] ?? 0);
    offL[f.from] = (offL[f.from] ?? 0) + h;
    offR[f.to] = (offR[f.to] ?? 0) + h;
    const x0 = NODE;
    const x1 = W - NODE;
    const c = (x1 - x0) * 0.5;
    const d = `M${x0} ${round(y0)} C${round(x0 + c)} ${round(y0)} ${round(x1 - c)} ${round(y1)} ${x1} ${round(y1)} L${x1} ${round(y1 + h)} C${round(x1 - c)} ${round(y1 + h)} ${round(x0 + c)} ${round(y0 + h)} ${x0} ${round(y0 + h)} Z`;
    return { f, d };
  });
  const lit = (f: InfographicFlow) => hot === null || hot === f.from || hot === f.to;
  const label = (name: string, r: { y: number; h: number }, side: 'l' | 'r', value: number) => (
    <button
      key={name}
      type="button"
      onPointerEnter={() => setHot(name)}
      onPointerLeave={() => setHot(null)}
      onFocus={() => setHot(name)}
      onBlur={() => setHot(null)}
      aria-pressed={hot === name}
      className={cn('absolute hidden -translate-y-1/2 flex-col whitespace-nowrap rounded-md px-1 text-[13px] leading-tight sm:flex transition-opacity duration-300 sm:text-[14px]', side === 'l' ? 'right-full mr-2 items-end text-right sm:mr-3' : 'left-full ml-2 items-start text-left sm:ml-3', hot !== null && hot !== name && !flows.some((f) => (f.from === hot && f.to === name) || (f.to === hot && f.from === name)) && 'opacity-40')}
      style={{ top: `${round(((r.y + r.h / 2) / H) * 100, 3)}%` }}
    >
      <span className="font-semibold text-foreground">{name}</span>
      <span className="tabular-nums text-muted-foreground">{num(value, p.unit)}</span>
    </button>
  );
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
        <div ref={ref} className="relative sm:mx-36 lg:mx-48">
          <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="block h-[22rem] w-full sm:h-[26rem]" role="img" aria-label={flows.map((f) => `${f.from} → ${f.to}: ${num(f.value, p.unit)}`).join('; ')}>
            {bands.map(({ f, d }, i) => (
              <path
                key={i}
                d={d}
                className="transition-[opacity,clip-path] duration-[1200ms] ease-[var(--ease-kit)] motion-reduce:transition-none"
                style={{ fill: `color-mix(in oklab, var(--primary) ${lit(f) && hot !== null ? 65 : 40}%, transparent)`, opacity: lit(f) ? 1 : 0.25, clipPath: seen ? 'inset(0 0 0 0)' : 'inset(0 100% 0 0)', transitionDelay: seen ? `${i * 60}ms` : '0ms' }}
              />
            ))}
            {lefts.map((n) => (
              <rect key={n} x={0} y={round(L[n].y)} width={NODE} height={round(L[n].h)} rx={3} style={{ fill: 'var(--primary)' }} />
            ))}
            {rights.map((n) => (
              <rect key={n} x={W - NODE} y={round(R[n].y)} width={NODE} height={round(R[n].h)} rx={3} style={{ fill: 'color-mix(in oklab, var(--foreground) 70%, var(--background))' }} />
            ))}
          </svg>
          {lefts.map((n) => label(n, L[n], 'l', sum('from', n)))}
          {rights.map((n) => label(n, R[n], 'r', sum('to', n)))}
        </div>
        {/* Phones: the names as two lists under the picture, sources in the accent */}
        <div className="mt-6 grid grid-cols-2 gap-6 sm:hidden">
          {[{ names: lefts, key: 'from' as const, head: t.from }, { names: rights, key: 'to' as const, head: t.to }].map((col) => (
            <div key={col.key}>
              <p className="text-[12.5px] font-semibold uppercase tracking-[0.07em] text-muted-foreground">{col.head}</p>
              <ul className="mt-2 space-y-2">
                {col.names.map((n) => (
                  <li key={n}>
                    <button type="button" aria-pressed={hot === n} onClick={() => setHot(hot === n ? null : n)} className="flex min-h-11 w-full items-center gap-2.5 text-left">
                      <span className={cn('h-6 w-1.5 shrink-0 rounded-full', col.key === 'from' ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--foreground)_70%,var(--background))]')} aria-hidden />
                      <span className="min-w-0 text-[14px] leading-tight">
                        <span className="block font-semibold text-foreground">{n}</span>
                        <span className="tabular-nums text-muted-foreground">{num(sum(col.key, n), p.unit)}</span>
                      </span>
                    </button>
                  </li>
                ))}
              </ul>
            </div>
          ))}
        </div>
        <Source text={p.source} t={t} />
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */

export function Infographic(props: InfographicProps) {
  const t = useTexts(TEXTS, props.labels);
  const { variant = 'units' } = props;
  if (variant === 'scale') return <Scale {...props} t={t} />;
  if (variant === 'breakdown') return <Breakdown {...props} t={t} />;
  if (variant === 'dumbbell') return <Dumbbell {...props} t={t} />;
  if (variant === 'flow') return <Flow {...props} t={t} />;
  return <Units {...props} t={t} />;
}

Häufige Fragen

Wie installiere ich Infografik?

Mit aiw add @ai-website/infographic. Der Befehl legt die Datei components/ui/infographic.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon und section. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Infografik?

5 Varianten, Von 100, Größen im Vergleich, Wohin es geht, Vorher und nachher und Ströme. Du wählst sie mit der Prop variant, zum Beispiel variant="scale".

Kann ich die Texte von Infografik übersetzen?

Ja. Infografik bringt 7 eigene Texte auf Deutsch und Englisch mit, etwa „Quelle“, „Gesamt“ und „Von“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Infografik einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Infografik mit aiw_components und installieren sie mit aiw_add @ai-website/infographic. Nenn im Prompt die Variante, etwa variant="units", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Infografik ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Infographic-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert