Skip to content

Activity grouped by day on a rail, where new events grow in at the top.

Identity & display@base-ui/reactmotion

01Preview

INC-214 Checkout latency

Loading activity

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install @base-ui/react motion

03Usage

import { Timeline } from "@/components/ui/timeline";

<Timeline
  events={[
    { id: "e3", date: "2026-09-22T14:02:00Z", icon: <CircleCheck />, tone: "success", title: <><b>Maya</b> resolved the incident</> },
    { id: "e2", date: "2026-09-22T13:40:00Z", actor: { name: "Leo Brandt", avatarSrc: "/leo.jpg" }, title: <><b>Leo</b> acknowledged</> },
    { id: "e1", date: "2026-09-22T13:35:00Z", icon: <Warning />, tone: "danger", title: "Latency above 2s", details: <AlertBody /> },
  ]}
  hasMore={cursor !== null}
  onLoadMore={fetchOlder}
/>

04Source

"use client";
import { Avatar } from "@base-ui/react/avatar";
import { Collapsible } from "@base-ui/react/collapsible";
import { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps } from "motion/react";
import { useMemo, useState, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { ease } from "@/lib/motion";
import { ChevronRight, Loader } from "@/lib/icons";

export type TimelineTone = "neutral" | "success" | "warning" | "danger" | "info";

export type TimelineEvent = {
  /** Stable id. New ids that arrive after mount animate in. */
  id: string;
  date: Date | string | number;
  /** The event, usually with the actor in it: “Maya merged #1290”. */
  title: React.ReactNode;
  /** One line under the title. */
  description?: React.ReactNode;
  /** Hidden behind a Details toggle: a diff, a log excerpt, the full comment. */
  details?: React.ReactNode;
  /** 14px glyph in the rail node. */
  icon?: React.ReactNode;
  /** Draws the node as this person's avatar instead of an icon. */
  actor?: { name: string; avatarSrc?: string };
  /** Colors the node's icon. Meaning only: a failed deploy, a resolved incident. */
  tone?: TimelineTone;
};

/* -------------------------------------------------------------------------------------------------
 * Time
 * -----------------------------------------------------------------------------------------------*/

// A clock shared by every timeline on the page, ticking every 30s. On the server and during
// hydration it reads null, so relative labels only ever render on the client.
const ticker = (() => {
  const listeners = new Set<() => void>();
  let now = 0;
  let timer: number | undefined;
  const read = () => Math.floor(Date.now() / 30000) * 30000;
  return {
    subscribe(fn: () => void) {
      listeners.add(fn);
      if (listeners.size === 1) {
        now = read();
        timer = window.setInterval(() => {
          const next = read();
          if (next !== now) {
            now = next;
            listeners.forEach((l) => l());
          }
        }, 5000);
      }
      return () => {
        listeners.delete(fn);
        if (!listeners.size) window.clearInterval(timer);
      };
    },
    get: () => (now ||= read()),
  };
})();

/** The current time to the nearest 30 seconds, or null before hydration. */
export function useNow() {
  return useSyncExternalStore(ticker.subscribe, ticker.get, () => null);
}

const toDate = (d: TimelineEvent["date"]) => (d instanceof Date ? d : new Date(d));
const dayKey = (d: Date, utc: boolean) => (utc ? d.toISOString().slice(0, 10) : `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`);
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();

function dayLabel(d: Date, now: number | null, locale: string) {
  if (now === null) return new Intl.DateTimeFormat(locale, { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" }).format(d);
  const days = Math.round((startOfDay(new Date(now)) - startOfDay(d)) / 86400000);
  if (days === 0 || days === 1) {
    const s = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(-days, "day");
    return s.charAt(0).toLocaleUpperCase(locale) + s.slice(1);
  }
  if (days > 1 && days < 7) return new Intl.DateTimeFormat(locale, { weekday: "long" }).format(d);
  const sameYear = new Date(now).getFullYear() === d.getFullYear();
  return new Intl.DateTimeFormat(locale, { day: "numeric", month: "short", year: sameYear ? undefined : "numeric" }).format(d);
}

function timeLabel(d: Date, now: number | null, locale: string) {
  const clock = new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", timeZone: now === null ? "UTC" : undefined }).format(d);
  if (now === null) return clock;
  const s = (now - d.getTime()) / 1000;
  // Recent events read as an age; anything older than a few hours reads as a time of day,
  // because the day header already says which day.
  if (s < 45) return "Just now";
  const rtf = new Intl.RelativeTimeFormat(locale, { style: "narrow", numeric: "always" });
  if (s < 3600) return rtf.format(-Math.max(1, Math.round(s / 60)), "minute");
  if (s < 4 * 3600) return rtf.format(-Math.floor(s / 3600), "hour");
  return clock;
}

/* -------------------------------------------------------------------------------------------------
 * Timeline
 * -----------------------------------------------------------------------------------------------*/

export type TimelineProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** Newest first. Grouped by the viewer's local day. */
  events: TimelineEvent[];
  locale?: string;
  /** Day headers stick to the top of the nearest scroll container. Set `--timeline-surface` to the background behind the timeline so they cover what scrolls under them. */
  stickyHeaders?: boolean;
  /** Skeleton rows while the first page loads. */
  loading?: boolean;
  /** Shown when there are no events and nothing is loading. */
  empty?: React.ReactNode;
  /** There is older history: the rail continues, dashed, into a Show older button. */
  hasMore?: boolean;
  onLoadMore?: () => void;
  loadingMore?: boolean;
};

export function Timeline({
  events,
  locale = "en-US",
  stickyHeaders = true,
  loading = false,
  empty = "No activity yet",
  hasMore = false,
  onLoadMore,
  loadingMore = false,
  className,
  ...rest
}: TimelineProps) {
  const now = useNow();
  const reduce = useReducedMotion();
  // The first events shown are history. Later ids newer than all of them are news and get the
  // entrance; older pages loaded at the bottom don't. The snapshot waits for loading to finish.
  const [history, setHistory] = useState<{ ids: Set<string>; newest: number } | null>(null);
  if (!loading && history === null) {
    setHistory({ ids: new Set(events.map((e) => e.id)), newest: Math.max(-Infinity, ...events.map((e) => toDate(e.date).getTime())) });
  }
  const isFresh = (e: TimelineEvent & { at: Date }) => !!history && !history.ids.has(e.id) && e.at.getTime() > history.newest;

  const groups = useMemo(() => {
    const out: { key: string; label: string; items: (TimelineEvent & { at: Date })[] }[] = [];
    for (const e of events) {
      const at = toDate(e.date);
      const key = dayKey(at, now === null);
      const last = out[out.length - 1];
      if (last?.key === key) last.items.push({ ...e, at });
      else out.push({ key, label: dayLabel(at, now, locale), items: [{ ...e, at }] });
    }
    return out;
  }, [events, now, locale]);

  if (loading) return <TimelineSkeleton className={className} {...rest} />;
  if (!events.length)
    return (
      <div className={cn("flex items-center gap-3 py-2 text-[13px] text-fg-3", className)} {...rest}>
        <span aria-hidden className="size-2 shrink-0 rounded-full border border-dashed border-fg-4 ml-2 mr-1" />
        {empty}
      </div>
    );

  // Arrivals grow from zero height, pushing history down rather than jumping it. Clipping is
  // only on while growing, so sticky headers, focus rings and the arrival ring aren't cut.
  const enter: Enter = reduce
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.2 } }
    : {
        initial: { height: 0, opacity: 0, overflow: "hidden" },
        animate: { height: "auto", opacity: 1, transitionEnd: { overflow: "visible" } },
        transition: { height: { duration: 0.34, ease: ease.out }, opacity: { duration: 0.24, delay: 0.06, ease: ease.out } },
      };

  return (
    <div data-slot="timeline" className={cn("relative text-[13px]", className)} {...rest}>
      {/* Before hydration days are grouped in UTC; the local regrouping swaps in without animating. */}
      <AnimatePresence key={now === null ? "server" : "client"} initial={false}>
        {groups.map((g, gi) => (
          <motion.section
            key={g.key}
            aria-label={g.label}
            {...enter}
            exit={{ opacity: 0, height: 0, overflow: "hidden", transition: { duration: 0.18 } }}
          >
            <h3
              className={cn(
                "relative z-[1] flex h-8 items-center pl-9 font-mono text-2xs tracking-[0.08em] text-fg-3 uppercase",
                stickyHeaders && "sticky top-0 bg-(--timeline-surface,var(--frame))",
                // The rail runs through every header but the first.
                gi > 0 && "before:absolute before:top-0 before:bottom-0 before:left-[11.5px] before:w-px before:bg-line-2",
              )}
            >
              <span suppressHydrationWarning>{g.label}</span>
            </h3>
            <ol>
              <AnimatePresence initial={false}>
                {g.items.map((e, i) => (
                  <Item
                    key={e.id}
                    event={e}
                    at={e.at}
                    now={now}
                    locale={locale}
                    fresh={isFresh(e)}
                    first={gi === 0 && i === 0}
                    last={gi === groups.length - 1 && i === g.items.length - 1 && !hasMore}
                    enter={enter}
                    reduce={!!reduce}
                  />
                ))}
              </AnimatePresence>
            </ol>
          </motion.section>
        ))}
      </AnimatePresence>

      {hasMore && (
        <div className="relative flex items-center pt-1 pl-9">
          {/* The rail keeps going, dashed: there is more above the fold of history. */}
          <span
            aria-hidden
            className="absolute top-0 left-[11.5px] h-full w-px bg-[linear-gradient(var(--line-2)_50%,transparent_50%)] bg-[length:1px_4px]"
          />
          <button
            type="button"
            onClick={onLoadMore}
            aria-busy={loadingMore || undefined}
            className={cn(
              "relative -ml-2 inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-[12px] font-medium text-fg-2 select-none",
              "outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
              "transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.97] active:duration-75",
              loadingMore && "pointer-events-none",
            )}
          >
            <span className={cn("transition-opacity duration-150", loadingMore && "opacity-0")}>Show older activity</span>
            {loadingMore && (
              <span className="absolute inset-0 grid place-items-center">
                <Loader size={14} className="animate-spin-slow" />
                <span className="sr-only">Loading older activity</span>
              </span>
            )}
          </button>
        </div>
      )}
    </div>
  );
}

const toneClass: Record<TimelineTone, string> = {
  neutral: "text-fg-2",
  success: "text-success",
  warning: "text-warning",
  danger: "text-danger",
  info: "text-info",
};

type Enter = Pick<HTMLMotionProps<"li">, "initial" | "animate" | "transition">;

function Item({
  event,
  at,
  now,
  locale,
  fresh,
  first,
  last,
  enter,
  reduce,
}: {
  event: TimelineEvent;
  at: Date;
  now: number | null;
  locale: string;
  fresh: boolean;
  first: boolean;
  last: boolean;
  enter: Enter;
  reduce: boolean;
}) {
  const { title, description, details, icon, actor, tone = "neutral" } = event;
  const absolute = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", timeZone: now === null ? "UTC" : undefined }).format(
    at,
  );

  return (
    <motion.li {...enter} exit={{ opacity: 0, height: 0, overflow: "hidden", transition: { duration: 0.18 } }} className="relative">
      {/* The rail: from this node's center down, and up to the previous one unless this is the first. */}
      <span
        aria-hidden
        className={cn("absolute left-[11.5px] w-px bg-line-2", first ? "top-3" : "top-0", last ? "h-3" : "bottom-0", first && last && "hidden")}
      />
      <motion.div
        initial={fresh && !reduce ? { y: -6, filter: "blur(2px)" } : false}
        animate={{ y: 0, filter: "blur(0px)" }}
        transition={{ duration: 0.34, ease: ease.out }}
        className="relative grid grid-cols-[24px_minmax(0,1fr)] gap-x-3 pb-4"
      >
        <Node icon={icon} actor={actor} tone={tone} fresh={fresh} reduce={reduce} />
        <div className="min-w-0 pt-0.5">
          <div className="flex items-baseline gap-3">
            <p className="min-w-0 flex-1 leading-5 text-pretty text-fg [&_b]:font-medium [&_strong]:font-medium">{title}</p>
            <time
              dateTime={at.toISOString()}
              title={absolute}
              suppressHydrationWarning
              className="shrink-0 text-[12px] leading-5 whitespace-nowrap text-fg-3 tabular"
            >
              {timeLabel(at, now, locale)}
            </time>
          </div>
          {description && <p className="mt-0.5 text-[12.5px] leading-[18px] text-pretty text-fg-2">{description}</p>}
          {details && <Details>{details}</Details>}
        </div>
      </motion.div>
    </motion.li>
  );
}

function Node({
  icon,
  actor,
  tone,
  fresh,
  reduce,
}: {
  icon?: React.ReactNode;
  actor?: TimelineEvent["actor"];
  tone: TimelineTone;
  fresh: boolean;
  reduce: boolean;
}) {
  return (
    <span className="relative grid size-6 place-items-center">
      {fresh && !reduce && (
        // One ring, once, on arrival: it marks what's new without looping for attention.
        <motion.span
          aria-hidden
          className="absolute inset-0 rounded-full border border-fg-3"
          initial={{ scale: 1, opacity: 0.6 }}
          animate={{ scale: 1.9, opacity: 0 }}
          transition={{ duration: 0.8, ease: ease.out, delay: 0.2 }}
        />
      )}
      <motion.span
        initial={fresh && !reduce ? { scale: 0.6 } : false}
        animate={{ scale: 1 }}
        transition={{ type: "spring", stiffness: 520, damping: 30, mass: 0.7, delay: 0.08 }}
        className={cn(
          "relative grid size-6 place-items-center overflow-hidden rounded-full border border-line-2 bg-raised",
          !actor && toneClass[tone],
          tone !== "neutral" && !actor && "border-current/30",
        )}
      >
        {actor ? (
          <Avatar.Root className="grid size-full place-items-center bg-fg/[0.1] text-[9.5px] font-medium text-fg-2 select-none">
            <Avatar.Image src={actor.avatarSrc} alt="" width={24} height={24} className="col-start-1 row-start-1 size-full object-cover" />
            <Avatar.Fallback className="col-start-1 row-start-1">{initials(actor.name)}</Avatar.Fallback>
          </Avatar.Root>
        ) : (
          <span className="grid place-items-center [&>svg]:size-3.5">{icon ?? <span className="size-1.5 rounded-full bg-current" />}</span>
        )}
      </motion.span>
    </span>
  );
}

function Details({ children }: { children: React.ReactNode }) {
  return (
    <Collapsible.Root className="mt-0.5 -mb-1">
      <Collapsible.Trigger
        className={cn(
          "group/details relative -ml-1.5 inline-flex h-6 items-center gap-1 rounded-md px-1.5 text-[12px] text-fg-3 select-none",
          "outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3",
          "transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg-2 active:scale-[0.97] active:duration-75 data-panel-open:text-fg-2",
          "before:absolute before:-inset-y-2.5 before:inset-x-0 before:content-[''] pointer-fine:before:hidden",
        )}
      >
        <ChevronRight
          size={14}
          className="-ml-0.5 transition-transform duration-200 ease-out-quart group-data-panel-open/details:rotate-90 motion-reduce:transition-none"
        />
        Details
      </Collapsible.Trigger>
      <Collapsible.Panel
        className={cn(
          "h-(--collapsible-panel-height) overflow-hidden",
          "transition-[height,opacity] duration-220 ease-out-quart data-ending-style:duration-150",
          "data-starting-style:h-0 data-starting-style:opacity-0 data-ending-style:h-0 data-ending-style:opacity-0",
          "motion-reduce:transition-[opacity]",
        )}
      >
        <div className="pt-1.5 pb-0.5">
          <div className="rounded-lg border border-line bg-fg/[0.025] px-3 py-2.5 text-[12.5px] leading-[18px] text-fg-2">{children}</div>
        </div>
      </Collapsible.Panel>
    </Collapsible.Root>
  );
}

function initials(name: string) {
  const words = name
    .trim()
    .split(/[\s._]+/)
    .filter(Boolean);
  const first = (w: string) => Array.from(w)[0] ?? "";
  return (words.length > 1 ? first(words[0]) + first(words[words.length - 1]) : first(words[0] ?? "")).toUpperCase();
}

// Three rows in the item's own geometry: node, title line with a time, a description line.
function TimelineSkeleton({ className, ...rest }: React.ComponentProps<"div">) {
  const fill = "bg-fg/[0.07] animate-pulse-soft motion-reduce:animate-none";
  return (
    <div aria-busy className={cn("relative", className)} {...rest}>
      <span className="sr-only">Loading activity</span>
      <div className="flex h-8 items-center pl-9">
        <span className={cn(fill, "h-2.5 w-14 rounded-[4px]")} />
      </div>
      {[0.72, 0.56, 0.64].map((w, i) => (
        <div key={i} aria-hidden className="relative grid grid-cols-[24px_minmax(0,1fr)] gap-x-3 pb-4">
          {i < 2 && <span className={cn("absolute left-[11.5px] w-px bg-line-2", i === 0 ? "top-3" : "top-0", "bottom-0")} />}
          {i === 2 && <span className="absolute top-0 left-[11.5px] h-3 w-px bg-line-2" />}
          <span className={cn(fill, "relative size-6 rounded-full")} />
          <div className="pt-0.5">
            <div className="flex h-5 items-center gap-3">
              <span className={cn(fill, "h-3 rounded-[4px]")} style={{ width: `${w * 100}%` }} />
              <span className={cn(fill, "ml-auto h-2.5 w-10 rounded-[4px]")} />
            </div>
            <div className="mt-0.5 flex h-[18px] items-center">
              <span className={cn(fill, "h-2.5 rounded-[4px]")} style={{ width: `${w * 60}%` }} />
            </div>
          </div>
        </div>
      ))}
    </div>
  );
}

05Props

Timeline

PropTypeDefaultDescription
events*TimelineEvent[]Newest first. Grouped under Today, Yesterday, a weekday, then a date, in the viewer's time zone.
hasMorebooleanfalseContinues the rail, dashed, into a Show older activity button.
onLoadMore() => voidCalled by Show older activity.
loadingMorebooleanfalseSpinner over the button label, at the same width.
loadingbooleanfalseSkeleton rows in the item's geometry.
emptyReactNode"No activity yet"Shown when there are no events.
stickyHeadersbooleantrueDay headers stick in the scroll container. Set --timeline-surface to the background behind them.
localestring"en-US"For day labels, times and relative ages.

TimelineEvent

PropTypeDefaultDescription
id*stringStable key. Ids that arrive later, and are newer than everything shown, animate in.
date*Date | string | numberWhen it happened.
title*ReactNodeThe event; <b> inside it is set at medium weight for the actor.
descriptionReactNodeOne line under the title.
detailsReactNodeTucked behind a Details disclosure.
iconReactNode14px glyph in the rail node. A dot when omitted.
actor{ name: string; avatarSrc?: string }Draws the node as an avatar instead.
tone"neutral" | "success" | "warning" | "danger" | "info""neutral"Colors the node icon, for meaning only.

useNow

PropTypeDefaultDescription
(none)() => number | nullA shared 30-second clock; null on the server and during hydration.

06Notes

Behavior

  • Ages read as “Just now”, “4m ago” and “2h ago” for four hours, then as a time of day, since the header already names the day. The absolute date and time is on hover, and every timeline shares one 30-second clock.
  • Labels are computed on the client only: the server renders UTC, and the local regrouping swaps in at hydration without an animation.
  • Only news animates. Events present when the list first shows (after loading) are history; older pages appended at the bottom don't pulse either.
  • The rail starts at the first node, runs through day headers and stops at the last node, or goes on dashed when there is more history to load.

Motion

  • A new event grows from zero height in 340ms on ease-out-expo, pushing history down instead of jumping it; its content drops 6px and unblurs, the node springs from 0.6, and one ring expands once to mark it.
  • Details open with the panel height and opacity over 220ms (150ms closing) while the chevron turns 90°. Clipping is only on while things grow, so sticky headers and focus rings are never cut.
  • Reduced motion swaps growth for a 200ms fade, and drops the drop, blur, spring, ring and the height transition on Details.

Accessibility

  • Each day is a section named by its label, with an ordered list of events. Times are <time> elements with a machine-readable dateTime and the full date as a title.
  • Details is a disclosure button with aria-expanded, reachable in reading order; the loading skeleton is aria-busy and says “Loading activity”.
  • Tone never carries meaning alone: the icon and the words in the title say what happened.