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
Variants
Details
Was Infografik mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Datengeschichten
- 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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle InfographicProps aus components/ui/infographic.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalInfographicVariantunitsscalebreakdowndumbbellflow
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodestatementsoptionalInfographicStatement[]units
iconoptionalIconNameunits: the mark of one unit
itemsoptionalInfographicItem[]scale, breakdown, dumbbell
flowsoptionalInfographicFlow[]flow
unitoptionalstringUnit after the numbers, e.g. €, %, kg
periodsoptional[string, string]dumbbell: names of the two moments, e.g. ['2021', '2026']
sourceoptionalstringWhere 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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| outOf | Englisch {n} out of 100 | Deutsch {n} von 100 |
| source | Englisch Source | Deutsch Quelle |
| total | Englisch Total | Deutsch Gesamt |
| from | Englisch From | Deutsch Von |
| to | Englisch To | Deutsch Nach |
| change | Englisch Change | Deutsch Veränderung |
| points | Englisch {n} pts | Deutsch {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
'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.