Skip to content
UI Kit
Accent color

Number journey

One large number that travels through stages while the page scrolls. It counts, grows a bar, rolls over with its unit, parts ways with a second number or fills a ring, always exactly with the scroll. Without JavaScript every stage stands in a row of figures.

One of 3 components in Scroll journeys

5 variants

Variants

Counting through the years

The number counts from stage to stage and rests at each one, the rail below fills and the text of the stage rises beside it.

Open in Studio

Number and bar

The number and a long bar grow together, each stage is a mark under the bar. For savings, capacity or anything that adds up.

Open in Studio

Rolling counter with units

A number per stage with its own unit, they roll over like a counter. Made for times that shrink, from days to hours to minutes.

Open in Studio

Two numbers part ways

Two numbers change over the years in opposite directions, two lines draw beside them. The accent marks the one that matters.

Open in Studio

Ring that fills

A ring fills around a percentage while you scroll, the stages sit left and right of it and light up when the ring passes them.

Open in Studio

Details

What Number journey brings along, read from the registry and the source.

Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/number-journey.tsx
Brings along
utilskitrevealsectionscroll-scene
npm packages
None
Languages
English and German, 2 texts of its own

Props

The NumberJourneyProps interface from components/ui/number-journey.tsx, with the comments from the code. Also takes every prop of Omit<React.HTMLAttributes<HTMLElement>, 'title'>.

  • variantoptional
    NumberJourneyVariant

    countbarflipcomparering

  • eyebrowoptional
    string
  • titleoptional
    React.ReactNode
  • descriptionoptional
    string
  • stagesrequired
    NumberStage[]
  • prefixoptional
    string

    Written before and after the number, e.g. "€", "%", "+"

  • suffixoptional
    string
  • decimalsoptional
    number
  • namesoptional
    [string, string]

    compare: the names of the two lines

  • maxoptional
    number

    bar: the end of the scale, default the largest value

Built-in texts

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

Show all 2 texts
stageEnglish StageGerman Stufe
ofEnglish ofGerman von

Accessibility

Read from the source of the component.

  • Sets aria-live, so screen readers announce purpose and state.
  • Announces changes through a live region without moving focus.
  • 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).

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 361 lines
components/ui/number-journey.tsx
'use client';

import { useCallback, useMemo, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Eyebrow } from '@/components/ui/section';
import { Reveal } from '@/components/ui/reveal';
import { lerp, smooth, span, useLive, useSceneProgress } from '@/components/ui/scroll-scene';

/*
 * One large number that travels through stages while the page scrolls. The section stands still,
 * the number follows the scroll exactly (scrubbed, never on a timer), the text of the stage changes
 * beside it. The figures are written straight into their text nodes, the bars, rings and lines
 * move by transform or stroke offset, so React does not render per frame.
 *
 * Without JavaScript and with reduced motion every stage stands as a row of figures with its text.
 *
 *   count    the number counts from stage to stage, a rail with the stages fills below it
 *   bar      the number and a long bar grow together, each stage is a mark on the bar
 *   flip     a number per stage with its own unit, they roll over like a counter (14 days → 2 hours)
 *   compare  two numbers part ways over the years, two lines draw beside them
 *   ring     a ring fills around a percentage, the stages sit around it
 */

export type NumberJourneyVariant = 'count' | 'bar' | 'flip' | 'compare' | 'ring';

export interface NumberStage {
  value: number;
  /** Short name of the stage: a year, a step */
  label: string;
  text?: string;
  /** flip: unit of this stage, e.g. "days", "hours" */
  unit?: string;
  /** compare: the second value at this stage */
  other?: number;
}

export interface NumberJourneyProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
  variant?: NumberJourneyVariant;
  eyebrow?: string;
  title?: React.ReactNode;
  description?: string;
  stages: NumberStage[];
  /** Written before and after the number, e.g. "€", "%", "+" */
  prefix?: string;
  suffix?: string;
  decimals?: number;
  /** compare: the names of the two lines */
  names?: [string, string];
  /** bar: the end of the scale, default the largest value */
  max?: number;
  labels?: Partial<(typeof TEXTS)['en']>;
}

const TEXTS = {
  en: { stage: 'Stage', of: 'of' },
  de: { stage: 'Stufe', of: 'von' },
};

type Els = Record<string, HTMLElement | SVGElement | null>;
const tf = (el: Element | null | undefined, t: string) => {
  if (el) (el as HTMLElement).style.transform = t;
};

/** Position c (0 … n-1) eased so the number rests a moment at every stage */
function stageAt(p: number, n: number) {
  const raw = p * (n - 1);
  const a = Math.floor(raw);
  const k = smooth(span(raw - a, 0.15, 0.85));
  return Math.min(n - 1, a + k);
}

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

/** A number that travels through stages while the page scrolls. */
export function NumberJourney({ variant = 'count', eyebrow, title, description, stages, prefix = '', suffix = '', decimals = 0, names, max, labels, className, ...rest }: NumberJourneyProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const live = useLive();
  const n = stages.length;
  const els = useRef<Els>({});
  const [active, setActive] = useState(n - 1);
  const set = (k: string) => (node: Element | null) => {
    els.current[k] = node as HTMLElement | null;
  };
  // One formatter per language, not one per scroll frame
  const nf = useMemo(() => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }), [locale, decimals]);
  const fmt = useCallback((v: number) => `${prefix}${nf.format(v)}${suffix}`, [nf, prefix, suffix]);
  const top = max ?? Math.max(...stages.map((s) => Math.max(s.value, s.other ?? 0)), 1);

  const apply = useCallback(
    (p: number) => {
      const e = els.current;
      const c = stageAt(p, n);
      const a = Math.floor(c);
      const b = Math.min(n - 1, a + 1);
      const k = c - a;
      setActive(Math.round(c));
      const value = lerp(stages[a].value, stages[b].value, k);
      if (e.num && variant !== 'flip') e.num.textContent = fmt(value);
      if (variant === 'count') tf(e.rail, `scaleX(${round(n > 1 ? c / (n - 1) : 1, 4)})`);
      if (variant === 'bar') tf(e.bar, `scaleX(${round(value / top, 4)})`);
      // The reel is n numbers high, one step is a 1/n of it
      if (variant === 'flip') tf(e.reel, `translate3d(0, ${round((-c * 100) / n, 3)}%, 0)`);
      if (variant === 'compare') {
        const other = lerp(stages[a].other ?? 0, stages[b].other ?? 0, k);
        if (e.num2) e.num2.textContent = fmt(other);
        // The lines are uncovered from the left by a clip on their own layer, the grid stays
        const draw = n > 1 ? c / (n - 1) : 1;
        const lines = e.lines as HTMLElement | null;
        if (lines) lines.style.clipPath = `inset(-4% ${round((1 - draw) * 100, 2)}% -4% -1%)`;
      }
      if (variant === 'ring') {
        const ring = e.ring as SVGCircleElement | null;
        if (ring) ring.style.strokeDashoffset = String(round(1 - value / top, 4));
      }
    },
    [variant, n, stages, fmt, top],
  );
  const track = useSceneProgress<HTMLDivElement>(live, { self: false, onProgress: apply });

  const last = stages[n - 1];
  const head = (
    <div className={cn('max-w-[40rem]', variant === 'ring' || variant === 'count' || variant === 'flip' ? 'mx-auto text-center' : '')}>
      {eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
      {title && <h2 className="text-balance text-[clamp(1.5rem,1.1rem+1.4vw,2.25rem)] font-semibold leading-tight tracking-[-0.02em] text-foreground">{title}</h2>}
      {description && <p className="mt-3 text-pretty text-[15.5px] leading-relaxed text-muted-foreground">{description}</p>}
    </div>
  );
  /** Large figures; the color comes separately, kit-accent-text and a text color would fight */
  const BIG = 'font-semibold leading-none tracking-[-0.05em] tabular-nums';
  const stageText = (s: NumberStage, i: number) => (
    <div key={i} className="kit-rise">
      <p className="text-[13px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
        {s.label}
        <span className="sr-only">
          {' '}
          {t.stage} {i + 1} {t.of} {n}
        </span>
      </p>
      {s.text && <p className="mt-2 text-pretty text-[16px] leading-relaxed text-muted-foreground">{s.text}</p>}
    </div>
  );

  /* ---------- Finished layout: no JavaScript, reduced motion, server ---------- */
  if (!live) {
    return (
      <section {...rest} className={cn('relative overflow-x-clip py-20 sm:py-24 lg:py-32', className)}>
        <div className="kit-container">
          <Reveal>{head}</Reveal>
          <ol className={cn('mt-12 grid grid-cols-1 gap-px overflow-hidden rounded-2xl border border-border bg-border sm:grid-cols-2', n >= 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3')}>
            {stages.map((s, i) => (
              <li key={i} className="bg-card p-6 sm:p-7">
                <p className="text-[13px] font-medium text-muted-foreground">{s.label}</p>
                <p className={cn('mt-3 text-[clamp(2.25rem,1.6rem+2vw,3.25rem)]', i === n - 1 ? 'kit-accent-text' : 'text-foreground', BIG)}>
                  {fmt(s.value)}
                  {s.unit && <span className="ml-2 text-[0.42em] font-medium tracking-normal text-muted-foreground">{s.unit}</span>}
                </p>
                {variant === 'compare' && s.other !== undefined && (
                  <p className="mt-2 text-[14px] tabular-nums text-muted-foreground">
                    {names?.[1]} {fmt(s.other)}
                  </p>
                )}
                {s.text && <p className="mt-3 text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{s.text}</p>}
              </li>
            ))}
          </ol>
        </div>
      </section>
    );
  }

  /* ---------- Live scene ---------- */
  return (
    <section {...rest} className={cn('relative overflow-x-clip', className)}>
      <div ref={track} className="relative" style={{ height: `${round(Math.max(2.6, n * 0.75) * 100, 2)}svh` }}>
        <div className="sticky top-0 flex h-svh flex-col justify-center overflow-clip py-10">
          <div className="kit-container w-full">
            {head}

            {variant === 'count' && (
              <div className="mt-8 text-center sm:mt-12">
                <p ref={set('num')} className={cn('kit-accent-text text-[clamp(4.5rem,2rem+11vw,12rem)]', BIG)} aria-hidden>
                  {fmt(last.value)}
                </p>
                <div className="mx-auto mt-6 min-h-[5.5rem] max-w-[30rem]" aria-live="polite">
                  {stageText(stages[active], active)}
                </div>
                <div className="relative mx-auto mt-8 max-w-[44rem]">
                  <span aria-hidden className="absolute inset-x-0 top-[5px] h-px bg-border" />
                  <span ref={set('rail')} aria-hidden className="absolute inset-x-0 top-[5px] h-px origin-left bg-primary" />
                  <ol className="relative flex justify-between">
                    {stages.map((s, i) => (
                      <li key={i} className="flex w-0 flex-col items-center">
                        <span className={cn('size-[11px] rounded-full border-2 transition-colors duration-500', i <= active ? 'border-primary bg-primary' : 'border-border-strong bg-background')} />
                        <span className={cn('mt-3 whitespace-nowrap text-[12.5px] tabular-nums transition-colors duration-500', i === active ? 'font-medium text-foreground' : 'text-muted-foreground')}>{s.label}</span>
                      </li>
                    ))}
                  </ol>
                </div>
              </div>
            )}

            {variant === 'bar' && (
              <div className="mt-10 sm:mt-14">
                <div className="flex flex-wrap items-end justify-between gap-x-10 gap-y-4">
                  <p ref={set('num')} className={cn('text-[clamp(4rem,2rem+8vw,10rem)] text-foreground', BIG)} aria-hidden>
                    {fmt(last.value)}
                  </p>
                  <div className="min-h-[5rem] max-w-[24rem] pb-2" aria-live="polite">
                    {stageText(stages[active], active)}
                  </div>
                </div>
                <div className="relative mt-10 h-14 overflow-hidden rounded-xl bg-surface-2">
                  <span ref={set('bar')} className="absolute inset-0 origin-left rounded-xl bg-[linear-gradient(90deg,color-mix(in_oklab,var(--primary)_55%,var(--surface-2)),var(--primary))]" />
                </div>
                <div className="relative mt-3 h-6">
                  {stages.map((s, i) => (
                    <span key={i} className={cn('absolute top-0 -translate-x-1/2 whitespace-nowrap text-[12px] tabular-nums transition-colors duration-500 first:translate-x-0', i === active ? 'font-medium text-foreground' : 'text-muted-foreground', i === n - 1 && '-translate-x-full')} style={{ left: `${round((s.value / top) * 100, 2)}%` }}>
                      {s.label}
                    </span>
                  ))}
                </div>
              </div>
            )}

            {variant === 'flip' && (
              <div className="mt-8 text-center sm:mt-12">
                {/* The reel shows one stage at a time and rolls to the next, like a counter */}
                <div aria-hidden className="mx-auto h-[1em] overflow-hidden text-[clamp(4.5rem,2rem+11vw,12rem)] leading-none">
                  <div ref={set('reel')} className="will-change-transform">
                    {stages.map((s, i) => (
                      <p key={i} className={cn('h-[1em] whitespace-nowrap', i === n - 1 ? 'kit-accent-text' : 'text-foreground', BIG)}>
                        {fmt(s.value)}
                        {s.unit && <span className="ml-[0.12em] text-[0.38em] font-medium tracking-[-0.02em] text-muted-foreground">{s.unit}</span>}
                      </p>
                    ))}
                  </div>
                </div>
                <div className="mx-auto mt-8 min-h-[5.5rem] max-w-[30rem]" aria-live="polite">
                  {stageText(stages[active], active)}
                </div>
                <ol className="mt-6 flex justify-center gap-2" aria-hidden>
                  {stages.map((_, i) => (
                    <li key={i} className={cn('h-1.5 rounded-full transition-[width,background-color] duration-500', i === active ? 'w-8 bg-primary' : 'w-1.5 bg-border-strong')} />
                  ))}
                </ol>
              </div>
            )}

            {variant === 'compare' && (
              <div className="mt-10 grid grid-cols-1 items-center gap-8 md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] md:gap-14">
                <div className="grid grid-cols-2 gap-6 md:grid-cols-1">
                  <div>
                    <p className="flex items-center gap-2 text-[13px] font-medium text-muted-foreground">
                      <span className="size-2.5 rounded-full bg-primary" />
                      {names?.[0]}
                    </p>
                    <p ref={set('num')} className={cn('kit-accent-text mt-2 text-[clamp(2.75rem,1.6rem+4vw,6rem)]', BIG)} aria-hidden>
                      {fmt(last.value)}
                    </p>
                  </div>
                  <div>
                    <p className="flex items-center gap-2 text-[13px] font-medium text-muted-foreground">
                      <span className="size-2.5 rounded-full bg-border-strong" />
                      {names?.[1]}
                    </p>
                    <p ref={set('num2')} className={cn('mt-2 text-[clamp(2.75rem,1.6rem+4vw,6rem)] text-muted-foreground', BIG)} aria-hidden>
                      {fmt(last.other ?? 0)}
                    </p>
                  </div>
                </div>
                <div>
                  <div className="relative aspect-[16/9] rounded-2xl border border-border bg-card p-4 sm:p-6">
                    <div className="relative size-full">
                      <svg viewBox="0 0 400 220" preserveAspectRatio="none" className="absolute inset-0 size-full" aria-hidden>
                        {[0, 1, 2, 3].map((g) => (
                          <line key={g} x1="0" x2="400" y1={round(20 + g * 60)} y2={round(20 + g * 60)} stroke="var(--border)" strokeWidth="1" vectorEffect="non-scaling-stroke" />
                        ))}
                      </svg>
                      <div ref={set('lines')} className="absolute inset-0">
                        <svg viewBox="0 0 400 220" preserveAspectRatio="none" className="size-full overflow-visible" aria-hidden>
                          <path d={pathOf(stages.map((s) => s.other ?? 0), top)} fill="none" stroke="var(--border-strong)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
                          <path d={pathOf(stages.map((s) => s.value), top)} fill="none" stroke="var(--primary)" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
                        </svg>
                      </div>
                    </div>
                  </div>
                  <div className="mt-3 flex justify-between text-[12px] tabular-nums text-muted-foreground">
                    {stages.map((s, i) => (
                      <span key={i} className={cn('transition-colors duration-500', i === active && 'font-medium text-foreground')}>
                        {s.label}
                      </span>
                    ))}
                  </div>
                  <div className="mt-5 min-h-[4.5rem]" aria-live="polite">
                    {stageText(stages[active], active)}
                  </div>
                </div>
              </div>
            )}

            {variant === 'ring' && (
              <div className="mt-8 grid grid-cols-1 items-center gap-8 md:grid-cols-[1fr_auto_1fr] md:gap-12">
                <ol className="hidden gap-5 text-right md:grid">
                  {stages.filter((_, i) => i % 2 === 0).map((s) => {
                    const i = stages.indexOf(s);
                    return <RingStage key={i} s={s} on={i === active} done={i <= active} fmt={fmt} />;
                  })}
                </ol>
                <div className="relative mx-auto size-[min(20rem,60vw,42svh)]">
                  <svg viewBox="0 0 100 100" className="size-full -rotate-90" aria-hidden>
                    <circle cx="50" cy="50" r="44" fill="none" stroke="var(--border)" strokeWidth="5" />
                    <circle ref={set('ring')} cx="50" cy="50" r="44" fill="none" stroke="var(--primary)" strokeWidth="5" strokeLinecap="round" pathLength={1} strokeDasharray="1 1" />
                  </svg>
                  <div className="absolute inset-0 flex flex-col items-center justify-center">
                    <p ref={set('num')} className={cn('text-[clamp(3rem,2rem+4vw,5.5rem)] text-foreground', BIG)} aria-hidden>
                      {fmt(last.value)}
                    </p>
                    <p className="mt-2 text-[13px] font-medium text-muted-foreground">{stages[active].label}</p>
                  </div>
                </div>
                <ol className="hidden gap-5 md:grid">
                  {stages.filter((_, i) => i % 2 === 1).map((s) => {
                    const i = stages.indexOf(s);
                    return <RingStage key={i} s={s} on={i === active} done={i <= active} fmt={fmt} />;
                  })}
                </ol>
                <div className="min-h-[4.5rem] text-center md:hidden" aria-live="polite">
                  {stageText(stages[active], active)}
                </div>
              </div>
            )}
            <p className="sr-only">{stages.map((s) => `${s.label} ${fmt(s.value)}${s.unit ? ` ${s.unit}` : ''}`).join(', ')}</p>
          </div>
        </div>
      </div>
    </section>
  );
}

function RingStage({ s, on, done, fmt }: { s: NumberStage; on: boolean; done: boolean; fmt: (v: number) => string }) {
  return (
    <li className={cn('transition-opacity duration-500', done ? 'opacity-100' : 'opacity-40')}>
      <p className={cn('text-[13px] font-medium', on ? 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' : 'text-muted-foreground')}>
        {s.label} · {fmt(s.value)}
      </p>
      {s.text && <p className="mt-1 text-pretty text-[14px] leading-relaxed text-muted-foreground">{s.text}</p>}
    </li>
  );
}

/** A smooth line through the values on a 400 × 220 field */
function pathOf(values: number[], top: number) {
  const pts = values.map((v, i) => [round((i / Math.max(1, values.length - 1)) * 400), round(200 - (v / top) * 180)]);
  return pts.reduce((d, [x, y], i) => {
    if (i === 0) return `M${x} ${y}`;
    const [px, py] = pts[i - 1];
    const mx = round((px + x) / 2);
    return `${d} C${mx} ${py} ${mx} ${y} ${x} ${y}`;
  }, '');
}

Frequent questions

How do I install Number journey?

With aiw add @ai-website/number-journey. The command puts components/ui/number-journey.tsx into your project, the code is yours from then on. It brings along reveal, section and scroll-scene. It needs no further npm packages.

Which variants does Number journey have?

5 variants, Counting through the years, Number and bar, Rolling counter with units, Two numbers part ways and Ring that fills. You pick one with the variant prop, for example variant="bar".

Can I translate the texts of Number journey?

Yes. Number journey carries 2 texts of its own in English and German, such as “Stage” and “of”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Number journey?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Number journey with aiw_components and install it with aiw_add @ai-website/number-journey. Name the variant in your prompt, for example variant="count", and the assistant writes your content straight into the props.

Can I adjust Number journey without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching NumberJourney code to copy.

Often searched

Jump to

Copied to the clipboard