Infographic
Numbers told as pictures. Out of 100 in a field of marks, sizes as true circles, where a whole goes, before and after per category and flows from sources to uses. All in the accent and the text colors.
One of 2 components in Data stories
Variants
Details
What Infographic brings along, read from the registry and the source.
- Category
- Data stories
- Area
- Content
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/infographic.tsx
- Brings along
- utilskiticonsection
- npm packages
- None
- Languages
- English and German, 7 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The InfographicProps interface from components/ui/infographic.tsx, with the comments from the code.
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
Built-in texts
Infographic writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 7 texts
| Key | English | German |
|---|---|---|
| outOf | English {n} out of 100 | German {n} von 100 |
| source | English Source | German Quelle |
| total | English Total | German Gesamt |
| from | English From | German Von |
| to | English To | German Nach |
| change | English Change | German Veränderung |
| points | English {n} pts | German {n} Pkt. |
Accessibility
Read from the source of the component.
- Infographic handles the keys Arrow up, Arrow down, Arrow left and Arrow right itself.
- Sets aria-controls, aria-label, aria-labelledby, aria-orientation, aria-pressed and aria-selected, so screen readers announce purpose and state.
- Uses the roles img, tab, tablist and tabpanel.
- Adds text for screen readers where icons and graphics stand alone.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- With reduced motion (prefers-reduced-motion) it stays still. 6 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 549 lines
'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} />;
}Frequent questions
How do I install Infographic?
With aiw add @ai-website/infographic. The command puts components/ui/infographic.tsx into your project, the code is yours from then on. It brings along icon and section. It needs no further npm packages.
Which variants does Infographic have?
5 variants, Out of 100, Sizes compared, Where it goes, Before and after and Flows. You pick one with the variant prop, for example variant="scale".
Can I translate the texts of Infographic?
Yes. Infographic carries 7 texts of its own in English and German, such as “Source”, “Total” and “From”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Infographic?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Infographic with aiw_components and install it with aiw_add @ai-website/infographic. Name the variant in your prompt, for example variant="units", and the assistant writes your content straight into the props.
Can I adjust Infographic without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching Infographic code to copy.