orn-ui
Organism

Timeline

Requiere <UIProvider> — Primeros pasos →

Instalación

Instala solo este componente (copia el código fuente a tu proyecto, sin dependencia npm)

npx orn-ui add timeline

O instala el paquete completo e impórtalo

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

Uso

  • cuándo usarlo — Hitos a lo largo de un camino: un pedido que se entrega, un roadmap, los pasos por los que pasa un cambio. Para un formulario partido en pasos está Wizard, y para un indicador de progreso pelado, Steps — éste es para un recorrido que vale la pena mirar.
  • items / status — Cada hito está cumplido o pendiente. El progreso se cuenta desde el arranque y se corta en el primer pendiente: un hito cumplido detrás de uno pendiente no tira de la línea, porque un camino es un camino.
  • advance — Qué puede hacer un toque. 'free' deja que responda cualquier hito y que la línea siga a items para donde vaya; 'sequential' sólo responde el que sigue a la línea y nunca retrocede; 'revisit' responde además todo lo ya recorrido, y tampoco retrocede.
  • selectedIndex — Hasta dónde llegaste y qué estás mirando no son lo mismo. Éste marca con un borde el hito que se está leyendo, y puede quedar detrás de la línea sin tirarla para atrás.
  • curve / startSide / spacing — La forma del camino. curve={0} lo dibuja recto, startSide da vuelta hacia qué lado sale la primera píldora, y spacing es el aire que recibe cada hito. Dejale lugar al halo — sobresale unos 20px de la píldora.
  • glow — El halo son muchas capas tenues y no tres fuertes: con pocos escalones se ven los anillos, que es justo lo contrario de un difuminado.
Clip del demo todavía sin grabar — ver MEDIA.md

Variantes

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>
Código completo del demo
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

Además acepta testID, que se reenvía al nodo raíz. Existe para los tests end-to-end (Maestro maneja la app por el árbol de accesibilidad) y no cambia en nada cómo se ve ni cómo se comporta el componente.

NombreTipoPor defectoDescripción
itemsTimelineItem[]Los hitos, en orden. Cada uno lleva una etiqueta, opcionalmente un ícono o un emoji, y su estado.
curve?number28Píxeles que la línea se arquea respecto del camino recto. 0 la dibuja derecha.
spacing?number84Distancia vertical entre un nodo y el siguiente.
startSide?'left' | 'right'rightLado hacia el que sale la primera píldora; las demás alternan.
glow?booleantrueHalo detrás de cada píldora.
onItemPress?(index: number) => voidHace tocable cada píldora y avisa su índice.
advance?'free' | 'sequential' | 'revisit'freeQué toques se permiten, y si la línea puede volver atrás.
selectedIndex?numberEl hito que se está mirando ahora, resaltado. No es el progreso: puede quedar detrás de la línea sin tirarla para atrás.
duration?number420Milisegundos que tarda la línea en recorrer un vano cuando se alcanza un hito.
style?StyleProp<ViewStyle>Estilo del contenedor.