orn-ui
Organism

Timeline

Requires <UIProvider> — Getting Started →

Installation

Install just this component (copies the source into your project, no npm dependency)

npx orn-ui add timeline

Or install the whole package and import it

pnpm add orn-ui
import { Timeline } from 'orn-ui/timeline';
Depends on:core (theme + icons)pressable-scaletransition

Usage

  • when to use — Milestones along a path: an order being delivered, a roadmap, the steps a change goes through. For a form split into steps use Wizard, and for a plain progress indicator, Steps — this one is for a journey worth looking at.
  • items / status — Each milestone is done or pending. Progress is counted from the start and stops at the first pending one: a done milestone sitting behind a pending one does not pull the line forward, because a path is a path.
  • advance — What a tap can do. 'free' lets every milestone answer and the line follow items either way; 'sequential' only answers the one right after the line and never retreats; 'revisit' also answers everything already walked, and still never retreats.
  • selectedIndex — How far you got and what you are looking at are not the same thing. This one marks the milestone being read, with a border, and can sit behind the line without pulling it back.
  • curve / startSide / spacing — The shape of the path. curve={0} draws it straight, startSide flips which way the first pill goes, and spacing is the room each milestone gets. Leave air for the halo — it bleeds about 20px past the pill.
  • glow — The halo is many faint layers rather than three strong ones: with few steps you see rings, which is the opposite of a blur.
Demo clip not recorded yet — see MEDIA.md

Variants

default — the line bows, the pills alternate sides

<Timeline items={ROADMAP} />

curve={0} — a straight spine

<Timeline items={PIPELINE} curve={0} />

startSide="left" and a wider curve

<Timeline items={PIPELINE} startSide="left" curve={44} />

glow={false} — just the pills

<Timeline items={ROADMAP} glow={false} />

spacing={60} — tighter, inside a Card

<Card>
  <Timeline items={PIPELINE} spacing={60} curve={20} />
</Card>

a single item — no line to draw

<View>
  <Timeline items={[{ label: 'Atomic design', emoji: '⚛️' }]} />
</View>
Full demo source
function upTo(items: TimelineItem[], reached: number): TimelineItem[] {
  return items.map((item, index) => ({ ...item, status: index <= reached ? undefined : 'pending' }));
}

/**
 * Tocar un hito lo alcanza, y con él todo lo anterior: la línea viaja hasta
 * ahí. Tocar el que ya está alcanzado da marcha atrás, para poder mirar la
 * animación en los dos sentidos sin recargar.
 */
function TappableTimeline() {
  const [reached, setReached] = useState(1);

  return (
    <View style={{ gap: 8 }}>
      <Timeline
        items={upTo(ROADMAP, reached)}
        onItemPress={(index) => setReached(index === reached ? index - 1 : index)}
      />
      {/* El eco existe para el flow de Maestro: el nodo que se llena y la
          línea que viaja son píxeles, y un assert no los ve. El texto sí. */}
      <Body>Reached: {ROADMAP[reached]?.label ?? 'nothing yet'}</Body>
    </View>
  );
}

/**
 * Avanza solo y vuelve a empezar: el demo existe para mirar el movimiento.
 *
 * Sin botón de pausa a propósito. Iba uno, y caía justo donde el pager de
 * VariantList arranca su swipe (80% de la pantalla): el botón se quedaba con
 * el gesto y no se podía pasar de variante.
 */
function AutoTimeline() {
  const [reached, setReached] = useState(-1);

  useEffect(() => {
    const id = setInterval(() => {
      setReached((previous) => (previous >= ROADMAP.length - 1 ? -1 : previous + 1));
    }, 1100);
    return () => clearInterval(id);
  }, []);

  return (
    <View style={{ gap: 8 }}>
      <Timeline items={upTo(ROADMAP, reached)} duration={700} />
      <Body>Now at: {ROADMAP[reached]?.label ?? 'the start'}</Body>
    </View>
  );
}

/**
 * Avance estricto: sólo responde el hito que sigue, y lo alcanzado no se
 * devuelve. Es el onboarding que no deja saltear pasos.
 */
function SequentialTimeline() {
  const [reached, setReached] = useState(0);

  return (
    <View style={{ gap: 8 }}>
      <Timeline
        items={upTo(ROADMAP, reached)}
        advance="sequential"
        onItemPress={setReached}
      />
      <Body>
        {reached >= ROADMAP.length - 1
          ? 'All done — nothing left to tap'
          : `Only "${ROADMAP[reached + 1]?.label}" answers`}
      </Body>
    </View>
  );
}

/**
 * Progreso guardado, contenido revisitable: tocar un hito anterior cambia lo
 * que estoy mirando, no lo que llevo recorrido. La línea se queda donde
 * llegó y el borde marca dónde estoy parado.
 */
function RevisitTimeline() {
  const [reached, setReached] = useState(2);
  const [looking, setLooking] = useState(2);

  return (
    <View style={{ gap: 8 }}>
      <Timeline
        items={upTo(ROADMAP, reached)}
        advance="revisit"
        selectedIndex={looking}
        onItemPress={(index) => {
          setLooking(index);
          // Sólo el hito que sigue empuja el recorrido; los de atrás son una
          // visita.
          if (index > reached) setReached(index);
        }}
      />
      <Body>Looking at: {ROADMAP[looking]?.label}</Body>
      <Body>Progress stays at: {ROADMAP[reached]?.label}</Body>
    </View>
  );
}

export function TimelineDemo() {
  const variants: VariantDef[] = [
    {
      label: 'default — the line bows, the pills alternate sides',
      content: <Timeline items={ROADMAP} />,
    },
    {
      label: 'onItemPress — tap a milestone and the line travels there',
      content: <TappableTimeline />,
    },
    {
      label: 'advance="sequential" — only the next milestone answers, no going back',
      content: <SequentialTimeline />,
    },
    {
      label: 'advance="revisit" — revisit the past, keep the progress',
      content: <RevisitTimeline />,
    },
    {
      label: 'advancing on its own — the line draws itself, gap by gap',
      content: <AutoTimeline />,
    },
    {
      label: 'curve={0} — a straight spine',
      content: <Timeline items={PIPELINE} curve={0} />,
    },
    {
      label: 'startSide="left" and a wider curve',
      content: <Timeline items={PIPELINE} startSide="left" curve={44} />,
    },
    {
      label: 'glow={false} — just the pills',
      content: <Timeline items={ROADMAP} glow={false} />,
    },
    {
      label: 'spacing={60} — tighter, inside a Card',
      content: (
        <Card>
          <Timeline items={PIPELINE} spacing={60} curve={20} />
        </Card>
      ),
    },
    {
      label: 'a single item — no line to draw',
      content: (
        <View>
          <Timeline items={[{ label: 'Atomic design', emoji: '⚛️' }]} />
        </View>
      ),
    },
  ];
  return <VariantList variants={variants} />;

Props

Also accepts testID, forwarded to the root node. It exists for end-to-end tests (Maestro drives the app by the accessibility tree) and has no effect on how the component looks or behaves.

NameTypeDefaultDescription
itemsTimelineItem[]The milestones, in order. Each one takes a label, an optional icon or emoji, and its status.
curve?number28Pixels the line bows away from the straight path. 0 draws it straight.
spacing?number84Vertical distance between one node and the next.
startSide?'left' | 'right'rightSide the first pill goes to; the rest alternate.
glow?booleantrueHalo behind each pill.
onItemPress?(index: number) => voidMakes each pill tappable and reports its index.
advance?'free' | 'sequential' | 'revisit'freeWhich taps are allowed, and whether the line may go back.
selectedIndex?numberThe milestone being looked at right now, highlighted. It is not the progress: it can sit behind the line without pulling it back.
duration?number420Milliseconds the line takes to travel one gap when a milestone is reached.
style?StyleProp<ViewStyle>Style for the container.