Veltrix

cards

Event Thread Timeline

A composable event history with animated connectors, status transitions, and adaptive rail layouts.

AppleiOSAndroidAndroidExpoExpo Go
reanimated
GitHubOpen in GitHub

Installation

Install the dependencies, then copy the files for your preferred renderer.

pnpm add react-native-reanimated

Anatomy

anatomy.tsx
<EventThreadTimeline>  <EventThreadTimeline.Item status="current">    <EventThreadTimeline.Leading />    <EventThreadTimeline.Rail>      <EventThreadTimeline.Marker />      <EventThreadTimeline.Connector />    </EventThreadTimeline.Rail>    <EventThreadTimeline.Content>      <EventThreadTimeline.Title />      <EventThreadTimeline.Description />    </EventThreadTimeline.Content>  </EventThreadTimeline.Item></EventThreadTimeline>

Install tailwind variants

pnpm add tailwind-variants

Usage

ProductRoadmap.tsx
import { Text } from "react-native";import { EventThreadTimeline } from "@animations/ui/components/cards/event-thread-timeline/uniwind";export function ProductRoadmap() {  return (    <EventThreadTimeline      className="w-full"      classNames={{        connector: "bg-violet-200",        title: "text-violet-950",      }}      layout="alternating"      progress={1}    >      <EventThreadTimeline.Item status="success">        <EventThreadTimeline.Leading>          <Text className="text-xs text-neutral-500">            Jan 2026          </Text>        </EventThreadTimeline.Leading>        <EventThreadTimeline.Rail>          <EventThreadTimeline.Marker />          <EventThreadTimeline.Connector />        </EventThreadTimeline.Rail>        <EventThreadTimeline.Content>          <EventThreadTimeline.Title>            Private beta          </EventThreadTimeline.Title>          <EventThreadTimeline.Description>            Invited design partners stress-test core workflows.          </EventThreadTimeline.Description>        </EventThreadTimeline.Content>      </EventThreadTimeline.Item>    </EventThreadTimeline>  );}

Component files

6 files

event-thread-timeline/config.ts
export const EVENT_THREAD_TIMELINE_CONFIG = {  connectorDuration: 620,  itemDuration: 360,  markerImpactLead: 52,  markerSpring: {    damping: 14,    mass: 0.7,    stiffness: 220,  },  stagger: 70,  statusTransitionDuration: 180,} as const;
event-thread-timeline/context.ts
import { createContext, useContext } from "react";import type { SharedValue } from "react-native-reanimated";import type {  EventThreadTimelineDensity,  EventThreadTimelineItemAlign,  EventThreadTimelineLayout,  EventThreadTimelineSize,  EventThreadTimelineStatus,} from "./types";export type EventThreadTimelineContextValue = {  animated: boolean;  count: number;  density: EventThreadTimelineDensity;  itemAlign: EventThreadTimelineItemAlign;  layout: EventThreadTimelineLayout;  progress: SharedValue<number>;  size: EventThreadTimelineSize;};export type EventThreadTimelineItemContextValue = {  align: EventThreadTimelineItemAlign;  index: number;  isLast: boolean;  status: EventThreadTimelineStatus;};export const EventThreadTimelineContext =  createContext<EventThreadTimelineContextValue | null>(null);export const EventThreadTimelineItemContext =  createContext<EventThreadTimelineItemContextValue | null>(null);export function useEventThreadTimelineContext() {  const value = useContext(EventThreadTimelineContext);  if (!value) {    throw new Error(      "EventThreadTimeline parts must be rendered inside EventThreadTimeline.",    );  }  return value;}export function useEventThreadTimelineItemContext() {  const value = useContext(EventThreadTimelineItemContext);  if (!value) {    throw new Error(      "EventThreadTimeline item parts must be rendered inside EventThreadTimeline.Item.",    );  }  return value;}
event-thread-timeline/types.ts
import type { ReactNode } from "react";export const EVENT_THREAD_TIMELINE_LAYOUTS = [  "left",  "center",  "alternating",] as const;export type EventThreadTimelineLayout =  (typeof EVENT_THREAD_TIMELINE_LAYOUTS)[number];export const EVENT_THREAD_TIMELINE_STATUSES = [  "default",  "muted",  "current",  "success",  "warning",  "danger",] as const;export type EventThreadTimelineStatus =  (typeof EVENT_THREAD_TIMELINE_STATUSES)[number];export type EventThreadTimelineSize = "sm" | "md" | "lg";export type EventThreadTimelineDensity =  | "compact"  | "comfortable";export type EventThreadTimelineItemAlign = "start" | "center";export type EventThreadTimelineBaseProps = {  animated?: boolean;  children: ReactNode;  density?: EventThreadTimelineDensity;  itemAlign?: EventThreadTimelineItemAlign;  /** Timeline placement: `left`, `center`, or `alternating`. */  layout?: EventThreadTimelineLayout;  /** Connector progress from `0` (none) to `1` (all except the final item). */  progress?: number;  size?: EventThreadTimelineSize;};export type EventThreadTimelineItemBaseProps = {  align?: EventThreadTimelineItemAlign;  children: ReactNode;  status?: EventThreadTimelineStatus;};
event-thread-timeline/uniwind/index.tsx
import {  Children,  cloneElement,  createContext,  isValidElement,  type ReactElement,  type ReactNode,  useContext,} from "react";import {  Text,  View,  type TextProps,  type ViewProps,} from "react-native";import Animated, {  FadeInDown,  LinearTransition,} from "react-native-reanimated";import type { SlotsToClasses } from "#shared/types/slots";import { cn } from "#shared/utils/cn";import { EVENT_THREAD_TIMELINE_CONFIG } from "../config";import {  EventThreadTimelineContext,  EventThreadTimelineItemContext,  useEventThreadTimelineContext,  useEventThreadTimelineItemContext,} from "../context";import type {  EventThreadTimelineBaseProps,  EventThreadTimelineItemBaseProps,} from "../types";import {  useEventThreadTimelineItemAnimation,  useEventThreadTimelineProgress,} from "../use-event-thread-timeline";import {  eventThreadTimelineVariants,  type EventThreadTimelineSlot,} from "./variants";type TimelinePartProps = ViewProps & {  children?: ReactNode;  className?: string;};type TimelineTextPartProps = TextProps & {  children?: ReactNode;  className?: string;};type InjectedItemProps = {  eventIndex?: number;  eventCount?: number;};export type EventThreadTimelineProps =  EventThreadTimelineBaseProps & {    className?: string;    classNames?: SlotsToClasses<EventThreadTimelineSlot>;  };export type EventThreadTimelineItemProps =  EventThreadTimelineItemBaseProps &    ViewProps & {      className?: string;    };const TimelineClassesContext =  createContext<    SlotsToClasses<EventThreadTimelineSlot> | undefined  >(undefined);function useTimelineClasses() {  return useContext(TimelineClassesContext);}function TimelineRoot({  animated = true,  children,  className,  classNames,  density = "comfortable",  itemAlign = "start",  layout = "left",  progress = 1,  size = "md",  ...viewProps}: EventThreadTimelineProps) {  const items = Children.toArray(children);  const animatedProgress = useEventThreadTimelineProgress(    Math.min(1, Math.max(0, progress)),    animated,  );  const contextValue = {    animated,    count: items.length,    density,    itemAlign,    layout,    progress: animatedProgress,    size,  } as const;  const slots = eventThreadTimelineVariants({    density,    layout,    size,  });  return (    <EventThreadTimelineContext.Provider value={contextValue}>      <TimelineClassesContext.Provider value={classNames}>        <View          {...viewProps}          className={slots.root({            className: [className, classNames?.root],          })}        >          {items.map((child, index) => {            if (!isValidElement(child)) {              return child;            }            return cloneElement(              child as ReactElement<InjectedItemProps>,              {                eventCount: items.length,                eventIndex: index,              },            );          })}        </View>      </TimelineClassesContext.Provider>    </EventThreadTimelineContext.Provider>  );}function TimelineItem({  align,  children,  className,  eventCount,  eventIndex,  status = "default",  ...viewProps}: EventThreadTimelineItemProps & InjectedItemProps) {  const timeline = useEventThreadTimelineContext();  const classNames = useTimelineClasses();  const index = eventIndex ?? 0;  const count = eventCount ?? timeline.count;  const resolvedAlign = align ?? timeline.itemAlign;  const isLast = index === count - 1;  const parts = Children.toArray(children);  const leading = parts.find(    (part) => isValidElement(part) && part.type === TimelineLeading,  );  const rail = parts.find(    (part) => isValidElement(part) && part.type === TimelineRail,  );  const content = parts.find(    (part) => isValidElement(part) && part.type === TimelineContent,  );  const odd = index % 2 === 1;  const itemContext = {    align: resolvedAlign,    index,    isLast,    status,  } as const;  const { reducedMotion } = useEventThreadTimelineItemAnimation({    animated: timeline.animated,    count,    index,    progress: timeline.progress,    status,  });  const slots = eventThreadTimelineVariants({    align: resolvedAlign,    density: timeline.density,    layout: timeline.layout,    size: timeline.size,    status,  });  const entering =    timeline.animated && !reducedMotion      ? FadeInDown.duration(          EVENT_THREAD_TIMELINE_CONFIG.itemDuration,        ).delay(index * EVENT_THREAD_TIMELINE_CONFIG.stagger)      : undefined;  function renderLayout() {    if (timeline.layout === "left") {      return (        <>          {leading}          {rail}          {content}        </>      );    }    if (timeline.layout === "center") {      return (        <>          {leading ?? <View className="flex-1" />}          {rail}          {content}        </>      );    }    return odd ? (      <>        {content}        {rail}        {leading ?? <View className="flex-1" />}      </>    ) : (      <>        {leading ?? <View className="flex-1" />}        {rail}        {content}      </>    );  }  return (    <EventThreadTimelineItemContext.Provider value={itemContext}>      <Animated.View        {...viewProps}        className={slots.item({          className: [className, classNames?.item],        })}        entering={entering}        layout={          timeline.animated && !reducedMotion            ? LinearTransition            : undefined        }      >        {renderLayout()}      </Animated.View>    </EventThreadTimelineItemContext.Provider>  );}function TimelineLeading({  children,  className,  ...viewProps}: TimelinePartProps) {  const { index } = useEventThreadTimelineItemContext();  const timeline = useEventThreadTimelineContext();  const classNames = useTimelineClasses();  const odd = index % 2 === 1;  const slots = eventThreadTimelineVariants({    density: timeline.density,    layout: timeline.layout,    size: timeline.size,  });  return (    <View      {...viewProps}      className={cn(        slots.leading(),        timeline.layout === "alternating" &&          (odd            ? "items-start pl-3.5 pr-0"            : "pr-3.5"),        classNames?.leading,        className,      )}    >      {children}    </View>  );}function TimelineRail({  children,  className,  ...viewProps}: TimelinePartProps) {  const timeline = useEventThreadTimelineContext();  const classNames = useTimelineClasses();  const slots = eventThreadTimelineVariants({    density: timeline.density,    layout: timeline.layout,    size: timeline.size,  });  return (    <View      {...viewProps}      className={slots.rail({        className: [className, classNames?.rail],      })}    >      {children}    </View>  );}function TimelineMarker({  children,  className,  ...viewProps}: TimelinePartProps) {  const timeline = useEventThreadTimelineContext();  const classNames = useTimelineClasses();  const item = useEventThreadTimelineItemContext();  const { markerAnimatedStyle } =    useEventThreadTimelineItemAnimation({      animated: timeline.animated,      count: timeline.count,      index: item.index,      progress: timeline.progress,      status: item.status,    });  const slots = eventThreadTimelineVariants({    density: timeline.density,    layout: timeline.layout,    size: timeline.size,    status: item.status,  });  return (    <Animated.View      {...viewProps}      className={slots.marker({        className: [className, classNames?.marker],      })}      style={markerAnimatedStyle}    >      {children}    </Animated.View>  );}function TimelineConnector({  children,  className,  ...viewProps}: TimelinePartProps) {  const timeline = useEventThreadTimelineContext();  const classNames = useTimelineClasses();  const item = useEventThreadTimelineItemContext();  const { connectorAnimatedStyle } =    useEventThreadTimelineItemAnimation({      animated: timeline.animated,      count: timeline.count,      index: item.index,      progress: timeline.progress,      status: item.status,    });  const slots = eventThreadTimelineVariants({    density: timeline.density,    layout: timeline.layout,    size: timeline.size,  });  return !item.isLast && (    <Animated.View      {...viewProps}      className={slots.connector({        className: [className, classNames?.connector],      })}      style={connectorAnimatedStyle}    >      {children}    </Animated.View>  );}function TimelineContent({  children,  className,  ...viewProps}: TimelinePartProps) {  const { index } = useEventThreadTimelineItemContext();  const timeline = useEventThreadTimelineContext();  const classNames = useTimelineClasses();  const odd = index % 2 === 1;  const slots = eventThreadTimelineVariants({    density: timeline.density,    layout: timeline.layout,    size: timeline.size,  });  return (    <View      {...viewProps}      className={cn(        slots.content(),        timeline.layout === "alternating" &&          (odd            ? "items-end pr-3.5"            : "pl-3.5"),        classNames?.content,        className,      )}    >      {children}    </View>  );}function TimelineTitle({  children,  className,  ...textProps}: TimelineTextPartProps) {  const timeline = useEventThreadTimelineContext();  const classNames = useTimelineClasses();  const slots = eventThreadTimelineVariants({    density: timeline.density,    layout: timeline.layout,    size: timeline.size,  });  return (    <Text      {...textProps}      className={slots.title({        className: [className, classNames?.title],      })}    >      {children}    </Text>  );}function TimelineDescription({  children,  className,  ...textProps}: TimelineTextPartProps) {  const timeline = useEventThreadTimelineContext();  const classNames = useTimelineClasses();  const slots = eventThreadTimelineVariants({    density: timeline.density,    layout: timeline.layout,    size: timeline.size,  });  return (    <Text      {...textProps}      className={slots.description({        className: [className, classNames?.description],      })}    >      {children}    </Text>  );}export const EventThreadTimeline = Object.assign(TimelineRoot, {  Connector: TimelineConnector,  Content: TimelineContent,  Description: TimelineDescription,  Item: TimelineItem,  Leading: TimelineLeading,  Marker: TimelineMarker,  Rail: TimelineRail,  Title: TimelineTitle,});
event-thread-timeline/uniwind/variants.ts
import { tv } from "tailwind-variants";export const eventThreadTimelineSlots = {  connector:    "absolute left-3.5 w-0.5 origin-top bg-[#d9d8d0]",  content: "min-w-0 flex-1 gap-0.5",  description: "text-[13px] leading-4.5 text-[#77776f]",  item: "relative w-full flex-row items-start",  leading:    "w-14.5 shrink-0 items-end pr-2.5",  marker:    "z-10 items-center justify-center border-2",  rail:    "relative w-7.5 shrink-0 self-stretch items-center",  root: "w-full",  title: "font-extrabold text-[#171713]",} as const;export type EventThreadTimelineSlot =  keyof typeof eventThreadTimelineSlots;export const eventThreadTimelineVariants = tv({  slots: eventThreadTimelineSlots,  variants: {    align: {      center: {        item: "items-center",      },      start: {},    },    density: {      comfortable: {        item: "mb-5 min-h-16.5",      },      compact: {        item: "mb-3 min-h-12.5",      },    },    layout: {      alternating: {        content: "w-1/2 flex-none",        leading: "w-1/2 flex-none",        rail:          "absolute bottom-0 left-1/2 top-0 -translate-x-3.75",      },      center: {        content: "w-1/2 flex-none pl-3.5",        leading: "w-1/2 flex-none pr-3.5",        rail:          "absolute bottom-0 left-1/2 top-0 -translate-x-3.75",      },      left: {        content: "pl-2.5",      },    },    size: {      lg: {        connector: "top-3.75",        marker: "size-7.5 rounded-[15px]",        title: "text-[17px] leading-5.5",      },      md: {        connector: "top-3",        marker: "size-6 rounded-xl",        title: "text-[15px] leading-5",      },      sm: {        connector: "top-2.5",        marker: "size-5 rounded-[10px]",        title: "text-[13px] leading-4.5",      },    },    status: {      current: {        marker: "border-[#6d5dfc] bg-[#e7e2ff]",      },      danger: {        marker: "border-[#e5524a] bg-[#ffe5e3]",      },      default: {        marker: "border-[#d5d4cd] bg-white",      },      muted: {        marker: "border-[#c6c5be] bg-[#efefe9]",      },      success: {        marker: "border-[#2ba66a] bg-[#ddf7e8]",      },      warning: {        marker: "border-[#d69520] bg-[#fff0cb]",      },    },  },  compoundVariants: [    {      class: { connector: "-bottom-8.75" },      density: "comfortable",      size: "lg",    },    {      class: { connector: "-bottom-8" },      density: "comfortable",      size: "md",    },    {      class: { connector: "-bottom-7.5" },      density: "comfortable",      size: "sm",    },    {      class: { connector: "-bottom-6.75" },      density: "compact",      size: "lg",    },    {      class: { connector: "-bottom-6" },      density: "compact",      size: "md",    },    {      class: { connector: "-bottom-5.5" },      density: "compact",      size: "sm",    },  ],});
event-thread-timeline/use-event-thread-timeline.ts
import {  Easing,  Extrapolation,  interpolate,  useAnimatedReaction,  useAnimatedStyle,  useReducedMotion,  useSharedValue,  withSequence,  withSpring,  withTiming,} from "react-native-reanimated";import { EVENT_THREAD_TIMELINE_CONFIG } from "./config";import type { EventThreadTimelineStatus } from "./types";const STATUS_INDEX: Record<EventThreadTimelineStatus, number> = {  current: 2,  danger: 5,  default: 0,  muted: 1,  success: 3,  warning: 4,};const STATUS_PULSES: Record<EventThreadTimelineStatus, boolean> = {  current: true,  danger: true,  default: false,  muted: false,  success: false,  warning: true,};export function useEventThreadTimelineProgress(  progress: number,  animated: boolean,) {  const reducedMotion = useReducedMotion();  const animatedProgress = useSharedValue(    animated && !reducedMotion ? 0 : progress,  );  useAnimatedReaction(    () => progress,    (nextProgress, previousProgress) => {      if (nextProgress === previousProgress) {        return;      }      animatedProgress.set(        animated && !reducedMotion          ? withTiming(nextProgress, {              duration:                EVENT_THREAD_TIMELINE_CONFIG.connectorDuration,              easing: Easing.linear,            })          : nextProgress,      );    },    [animated, progress, reducedMotion],  );  return animatedProgress;}export function useEventThreadTimelineItemAnimation({  animated,  count,  index,  progress,  status,}: {  animated: boolean;  count: number;  index: number;  progress: ReturnType<typeof useSharedValue<number>>;  status: EventThreadTimelineStatus;}) {  const reducedMotion = useReducedMotion();  const markerScale = useSharedValue(1);  useAnimatedReaction(    () => STATUS_INDEX[status],    (nextStatus, previousStatus) => {      if (        nextStatus === previousStatus ||        !STATUS_PULSES[status] ||        !animated ||        reducedMotion      ) {        return;      }      markerScale.set(        withSequence(          withTiming(0.72, {            duration:              EVENT_THREAD_TIMELINE_CONFIG.statusTransitionDuration,          }),          withSpring(            1,            EVENT_THREAD_TIMELINE_CONFIG.markerSpring,          ),        ),      );    },    [animated, reducedMotion, status],  );  const markerAnimatedStyle = useAnimatedStyle(() => ({    transform: [{ scale: markerScale.get() }],  }));  const connectorAnimatedStyle = useAnimatedStyle(() => {    const segmentCount = Math.max(1, count - 1);    const start = index / segmentCount;    const end = (index + 1) / segmentCount;    return {      opacity: interpolate(        progress.get(),        [start, end],        [0, 1],        Extrapolation.CLAMP,      ),      transform: [        {          scaleY: interpolate(            progress.get(),            [start, end],            [0, 1],            Extrapolation.CLAMP,          ),        },      ],    };  });  return {    connectorAnimatedStyle,    markerAnimatedStyle,    reducedMotion,  };}

API reference

Generated directly from the exported component props.

EventThreadTimeline

EventThreadTimeline.Connector

EventThreadTimeline.Connector extends all props from ViewProps, with the additional component-specific props shown below.

EventThreadTimeline.Content

EventThreadTimeline.Content extends all props from ViewProps, with the additional component-specific props shown below.

EventThreadTimeline.Description

EventThreadTimeline.Description extends all props from TextProps, with the additional component-specific props shown below.

EventThreadTimeline.Item

EventThreadTimeline.Item extends all props from ViewProps, with the additional component-specific props shown below.

EventThreadTimeline.Leading

EventThreadTimeline.Leading extends all props from ViewProps, with the additional component-specific props shown below.

EventThreadTimeline.Marker

EventThreadTimeline.Marker extends all props from ViewProps, with the additional component-specific props shown below.

EventThreadTimeline.Rail

EventThreadTimeline.Rail extends all props from ViewProps, with the additional component-specific props shown below.

EventThreadTimeline.Title

EventThreadTimeline.Title extends all props from TextProps, with the additional component-specific props shown below.