Skip to content
UI Kit
Accent color

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

5 variants

Variants

Out of 100

A field of 100 marks. Each statement beside it lights up its share, row by row, arrow keys switch.

Open in Studio

Sizes compared

Circles whose area is the value, so the comparison is honest. They grow from the baseline as they scroll in, the largest in the accent.

Open in Studio

Where it goes

One bar of shares that fills from the left and a list with amount and share. A share lights up in both, the others step back.

Open in Studio

Before and after

Two moments per category. The dot travels from the old value to the new one as the chart scrolls in, the change stands next to the label.

Open in Studio

Flows

From sources to uses in bands whose width is the amount. They draw from left to right, a name lights up its bands.

Open in Studio

Details

What Infographic brings along, read from the registry and the source.

Category
Data stories
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

Props

The InfographicProps interface from components/ui/infographic.tsx, with the comments from the code.

  • 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

Built-in texts

Infographic writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 7 texts
outOfEnglish {n} out of 100German {n} von 100
sourceEnglish SourceGerman Quelle
totalEnglish TotalGerman Gesamt
fromEnglish FromGerman Von
toEnglish ToGerman Nach
changeEnglish ChangeGerman Veränderung
pointsEnglish {n} ptsGerman {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
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} />;
}

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.

Often searched

Jump to

Copied to the clipboard