Organism
Timeline
Requires <UIProvider> — Getting Started →
Installation
Install just this component (copies the source into your project, no npm dependency)
npx orn-ui add timelineOr install the whole package and import it
pnpm add orn-ui
import { Timeline } from 'orn-ui/timeline';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.
| Name | Type | Default | Description |
|---|---|---|---|
items | TimelineItem[] | — | The milestones, in order. Each one takes a label, an optional icon or emoji, and its status. |
curve? | number | 28 | Pixels the line bows away from the straight path. 0 draws it straight. |
spacing? | number | 84 | Vertical distance between one node and the next. |
startSide? | 'left' | 'right' | right | Side the first pill goes to; the rest alternate. |
glow? | boolean | true | Halo behind each pill. |
onItemPress? | (index: number) => void | — | Makes each pill tappable and reports its index. |
advance? | 'free' | 'sequential' | 'revisit' | free | Which taps are allowed, and whether the line may go back. |
selectedIndex? | number | — | The milestone being looked at right now, highlighted. It is not the progress: it can sit behind the line without pulling it back. |
duration? | number | 420 | Milliseconds the line takes to travel one gap when a milestone is reached. |
style? | StyleProp<ViewStyle> | — | Style for the container. |