bottega

sections

Timeline

A vertical process/history timeline with an optional eyebrow + heading, a single-column connecting line that draws downward (scaleY 0→1, transform-origin top) as the section enters view, and per-entry reveals that stagger in (fade + slide up) as the user scrolls. Each entry shows a small phase/date label, a title, and a description beside a node dot that fills with --primary and emits a ring ripple on reveal. The static --border line and all entries are always fully rendered (SSR/reduced-motion safe) — animation is additive, never structural.

How it works

From first call to finished product

Phase 01

Discover

We map your goals, users, and existing systems to define what matters most.

Phase 02

Design

Your vision takes form as a coherent design system and interactive prototype.

Phase 03

Build

Clean, documented code on modern foundations — fast, accessible, and maintainable.

Phase 04

Ship

We deploy, monitor, and hand off a polished product ready to grow with you.


Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/timeline

Usage

Usagetsx
<Timeline />

Props

PropTypeDefaultDescription
eyebrowstringOptional small eyebrow text above the main heading.
headingstringOptional section heading.
itemsTimelineItem[][ { label: "Phase 01", title: "Discover", description: "We map your goals, users, and existing systems to define what matters most.", }, { label: "Phase 02", title: "Design", description: "Your vision takes form as a coherent design system and interactive prototype.", }, { label: "Phase 03", title: "Build", description: "Clean, documented code on modern foundations — fast, accessible, and maintainable.", }, { label: "Phase 04", title: "Ship", description: "We deploy, monitor, and hand off a polished product ready to grow with you.", }, ]Timeline entries. Defaults to a four-step product/process story.
classNamestringExtra class names merged onto the outer section element.

Source

timeline.tsxtsx
"use client";

import { useEffect, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import styles from "./timeline.module.css";

// expo-out: snappy arrival, long tail — editorial feel
const EASE_EXPO_OUT = [0.16, 1, 0.3, 1] as const;

// Motion JS durations (seconds) — distinct from the --duration CSS UI-transition
// token (~300ms). These govern scene choreography, not UI micro-interactions.
const LINE_DRAW_DURATION = 1.4;
const ENTRY_DURATION = 0.55;
const DOT_DURATION = 0.35;
const ENTRY_STAGGER = 0.12;

export interface TimelineItem {
  /** Short date, phase, or step label displayed above the title. */
  label: string;
  /** Entry title — the step or milestone name. */
  title: string;
  /** One-line description of what happens at this step. */
  description: string;
}

export interface TimelineProps {
  /** Optional small eyebrow text above the main heading. */
  eyebrow?: string;
  /** Optional section heading. */
  heading?: string;
  /** Timeline entries. Defaults to a four-step product/process story. */
  items?: TimelineItem[];
  /** Extra class names merged onto the outer section element. */
  className?: string;
}

const DEFAULT_ITEMS: TimelineItem[] = [
  {
    label: "Phase 01",
    title: "Discover",
    description:
      "We map your goals, users, and existing systems to define what matters most.",
  },
  {
    label: "Phase 02",
    title: "Design",
    description:
      "Your vision takes form as a coherent design system and interactive prototype.",
  },
  {
    label: "Phase 03",
    title: "Build",
    description:
      "Clean, documented code on modern foundations — fast, accessible, and maintainable.",
  },
  {
    label: "Phase 04",
    title: "Ship",
    description:
      "We deploy, monitor, and hand off a polished product ready to grow with you.",
  },
];

function cx(...parts: Array<string | false | undefined>): string {
  return parts.filter(Boolean).join(" ");
}

// Per-entry sub-component so each holds its own useInView ref (independent reveal)
function TimelineEntry({
  item,
  index,
  animate,
}: {
  item: TimelineItem;
  index: number;
  animate: boolean;
}) {
  const ref = useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, amount: 0.5 });
  const show = animate && inView;

  // Staggered delays: content first, then dot slightly after for sequenced feel
  const contentDelay = index * ENTRY_STAGGER;
  const dotDelay = contentDelay + 0.2;
  const rippleDelay = dotDelay + DOT_DURATION + 0.05;

  return (
    <div ref={ref} className="relative pl-14 pb-10 last:pb-2">
      {/* Static border-ring node — always rendered as the reduced-motion fallback.
          This ring is always visible so the timeline never loses its structure. */}
      <div
        aria-hidden="true"
        className="absolute left-4 top-1 h-4 w-4 rounded-full border-2 border-border bg-background"
      />

      {/* Animated filled primary node — scales in when entry reveals.
          In non-animated mode (reduce/SSR): stays invisible (scale 0); the static
          ring above is the fallback. No DOM branch — same element always present. */}
      <motion.div
        aria-hidden="true"
        className="absolute left-4 top-1 h-4 w-4 rounded-full bg-primary"
        style={{ originX: 0.5, originY: 0.5 }}
        initial={{ scale: 0 }}
        animate={{
          // animate=false → scale=0 (invisible; static ring above is the fallback)
          // animate=true, not in view → scale=0
          // animate=true, in view → scale=1 (fills over the static ring)
          scale: show ? 1 : 0,
        }}
        transition={
          animate
            ? {
                duration: DOT_DURATION,
                ease: EASE_EXPO_OUT,
                delay: show ? dotDelay : 0,
              }
            : { duration: 0 }
        }
      />

      {/* Ring ripple — a border ring that expands and fades once after the dot
          fills. Only rendered post-mount when the entry is in view (show=true),
          so no SSR content or hydration concern. Not branching on `reduce` —
          `show` itself is false for reduced-motion users since animate=false. */}
      {show && (
        <motion.div
          aria-hidden="true"
          className="pointer-events-none absolute left-4 top-1 h-4 w-4 rounded-full border border-primary"
          style={{ originX: 0.5, originY: 0.5 }}
          initial={{ scale: 1, opacity: 0.65 }}
          animate={{ scale: 3, opacity: 0 }}
          transition={{ duration: 0.75, ease: "easeOut", delay: rippleDelay }}
        />
      )}

      {/* Entry content: fade + slide up on reveal. Same null-safe pattern as
          cta-banner: initial={false} on non-animated path so server HTML and
          first client render are identical (no #418 hydration mismatch). */}
      <motion.div
        initial={animate ? { opacity: 0, y: 14 } : false}
        animate={{
          opacity: show ? 1 : animate ? 0 : 1,
          y: show ? 0 : animate ? 14 : 0,
        }}
        transition={
          animate
            ? {
                duration: ENTRY_DURATION,
                ease: EASE_EXPO_OUT,
                delay: show ? contentDelay : 0,
              }
            : { duration: 0 }
        }
      >
        <span className={styles.label}>{item.label}</span>
        <h3 className="mt-1 text-base font-semibold tracking-tight text-foreground sm:text-lg">
          {item.title}
        </h3>
        <p className="mt-2 text-sm leading-relaxed text-foreground opacity-60">
          {item.description}
        </p>
      </motion.div>
    </div>
  );
}

export function Timeline({
  eyebrow,
  heading,
  items = DEFAULT_ITEMS,
  className,
}: TimelineProps) {
  const sectionRef = useRef<HTMLElement>(null);

  // Mount-gate + null-safe reduced-motion check (Bottega contract).
  // Server HTML and first client paint are identical (mounted=false on both) —
  // no React #418 hydration mismatch. Motion only turns on after hydration for
  // users who have not requested reduced motion.
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);
  const prefersReducedMotion = useReducedMotion();
  const reduce = prefersReducedMotion === null || prefersReducedMotion;
  const animate = mounted && !reduce;

  // Section-level inView drives the line-draw animation
  const sectionInView = useInView(sectionRef, { once: true, amount: 0.15 });

  return (
    <section
      ref={sectionRef}
      className={cx(
        "w-full bg-background px-6 py-20 text-foreground sm:py-28",
        className,
      )}
    >
      <div className="mx-auto max-w-2xl">
        {/* Optional header — renders only when at least one prop is provided */}
        {(eyebrow || heading) && (
          <header className="mb-16 text-center">
            {eyebrow && <p className={styles.eyebrow}>{eyebrow}</p>}
            {heading && (
              <h2 className="mt-2 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
                {heading}
              </h2>
            )}
          </header>
        )}

        {/* Timeline list */}
        <div className="relative">
          {/* Static full-height border line — always rendered. This is the
              reduced-motion (and SSR) baseline; the animated primary line draws
              on top of it. Never hidden or conditionally rendered. */}
          <div
            aria-hidden="true"
            className="absolute left-[23px] top-2 bottom-2 w-px bg-border"
          />

          {/* Animated primary draw line — scaleY 0→1 as section enters view.
              Stays at scaleY=0 in non-animated mode so the border line shows
              through. No DOM branch: same element, different animated VALUES. */}
          <motion.div
            aria-hidden="true"
            className="absolute left-[23px] top-2 bottom-2 w-px bg-primary"
            style={{ transformOrigin: "top" }}
            initial={{ scaleY: 0 }}
            animate={{
              // animate=false → scaleY=0 (border line above is the fallback)
              // animate=true, section not yet in view → scaleY=0
              // animate=true, section in view → scaleY=1 (draws over border line)
              scaleY: animate && sectionInView ? 1 : 0,
            }}
            transition={
              animate
                ? { duration: LINE_DRAW_DURATION, ease: EASE_EXPO_OUT }
                : { duration: 0 }
            }
          />

          {items.map((item, i) => (
            <TimelineEntry
              key={`${item.title}-${i}`}
              item={item}
              index={i}
              animate={animate}
            />
          ))}
        </div>
      </div>
    </section>
  );
}

Dependencies

  • motion
  • @bottega/tokens