cards
Event Thread Timeline
A composable event history with animated connectors, status transitions, and adaptive rail layouts.
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add react-native-reanimatedAnatomy
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-variantsUsage
ProductRoadmap.tsximport { 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> );}event-thread-timeline/config.tsexport 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.tsimport { 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.tsimport 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.tsximport { 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.tsimport { 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.tsimport { 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, };}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.
