moTimeline

Lightweight, dependency-free JavaScript library for responsive two-column timeline layouts — with the most publisher-friendly ad slot support on npm.

No jQuery Zero dependencies ESM · CJS · UMD Bootstrap-compatible Publisher-ready ad slots Custom card renderer MIT License
Get started View on npm View on GitHub

Live demo

    • API v2 released

      via Blog

      The new v2 API is live. Breaking changes are listed in the migration guide; all v1 endpoints remain available until Q3.

    • Performance update

      via Twitter

      P95 latency dropped from 340 ms to 88 ms after rolling out the new caching layer to all edge nodes.

    • New team member

      via LinkedIn

      Welcome to the team! Six engineers joined this month across frontend, backend, and infrastructure.

    • Year in review

      via Blog

      We shipped 38 features, resolved 214 issues, and grew the user base 4× in 2025. Thank you for being part of the journey.

    • v1.5 shipped

      via GitHub

      Dark mode, keyboard navigation, and a new plugin API landed in this release. Full changelog on GitHub.

    • Beta program opens

      via Email

      300 early adopters onboarded this week. Structured feedback sessions start Monday — sign up in the dashboard.

    • First public demo

      via YouTube

      Live-streamed the first public walkthrough to 1 400 viewers. Recording available on the YouTube channel.

    • Project kickoff

      via Slack

      Repository created, architecture decisions recorded, first sprint planned. Let's build something great.

    • Project kickoff

      January 2024

      Kicked off the new product roadmap. Team aligned on goals, milestones, and chosen tech stack.
    • Design system

      February 2024

      Shared component library built. Tokens for color, spacing, and typography exported to CSS custom properties.
    • API development

      March 2024

      Core REST endpoints shipped. Authentication, rate limiting, and OpenAPI documentation all live.
    • Beta launch

      April 2024

      200 early users invited. Structured feedback via in-app surveys and recorded user interviews collected.
    • User testing

      May 2024

      Five rounds of usability testing. 12 critical UX issues resolved before general availability.
    • Performance pass

      June 2024

      Lighthouse score up from 62 to 96. Bundle size halved through code splitting and tree shaking.
    • v1.0 released

      July 2024

      General availability. 1 200 signups in the first week. Featured on Product Hunt front page.
    • Roadmap planning

      August 2024

      Q3 / Q4 roadmap published. Community voting open for the next feature batch.

    Get started

    npm  ↗ npmjs.com/package/motimeline

    npm install motimeline

    ESM import

    import MoTimeline from 'motimeline';
    import 'motimeline/dist/moTimeline.css';
    
    const tl = new MoTimeline('#my-timeline', {
      showBadge: true,
      showArrow: true,
      theme:     true,
    });

    UMD (no bundler)

    <link   rel="stylesheet" href="moTimeline.css">
    <script src="moTimeline.umd.js"></script>
    <script>
      const tl = new MoTimeline('#my-timeline', { theme: true });
    </script>

    HTML structure (with theme) — what you write

    <ul id="my-timeline">
      <li>
        <div class="mo-card">
          <div class="mo-card-image">           <!-- optional -->
            <img class="mo-banner" src="...">
            <img class="mo-avatar" src="...">   <!-- optional -->
          </div>
          <div class="mo-card-body">
            <h3>Title</h3>
            <p class="mo-meta">Date</p>
            <p>Text...</p>
          </div>
        </div>
      </li>
    </ul>

    Rendered DOM (after init) — what the library produces

    Classes and elements added automatically by the library are highlighted. Right-column items also get mo-inverted js-mo-inverted on the <li>.

    <ul class="mo-timeline mo-theme mo-twocol">
      <li class="mo-item js-mo-item">          <!-- added by library -->
        <span class="mo-arrow js-mo-arrow"></span>  <!-- injected if showArrow -->
        <span class="mo-badge js-mo-badge">1</span>   <!-- injected if showBadge -->
        <div class="mo-card">
          <div class="mo-card-image">
            <img class="mo-banner" src="...">
            <img class="mo-avatar" src="...">
          </div>
          <div class="mo-card-body">
            <h3>Title</h3>
            <p class="mo-meta">Date</p>
            <p>Text...</p>
          </div>
        </div>
      </li>
      <li class="mo-item js-mo-item mo-inverted js-mo-inverted">  <!-- right-column item -->
        ...
      </li>
    </ul>

    React wrapper

    Save as Timeline.jsx and import it wherever you need a timeline.

    import { useEffect, useRef } from 'react';
    import MoTimeline from 'motimeline';
    import 'motimeline/dist/moTimeline.css';
    
    /**
     * items shape: [{ id, title, meta, text, banner, avatar, icon }]
     * All item fields are optional except a stable `id` for React keys.
     */
    export default function Timeline({ items = [], options = {} }) {
      const ulRef  = useRef(null);
      const tlRef  = useRef(null);
      const lenRef = useRef(0);
    
      // Initialise once on mount
      useEffect(() => {
        tlRef.current = new MoTimeline(ulRef.current, options);
        lenRef.current = items.length;
        return () => tlRef.current?.destroy();
      }, []); // eslint-disable-line react-hooks/exhaustive-deps
    
      // When items array grows, pick up the new <li> elements React just rendered
      useEffect(() => {
        if (!tlRef.current) return;
        if (items.length > lenRef.current) {
          tlRef.current.initNewItems();
        } else {
          // Items removed or reordered — full reinit
          tlRef.current.destroy();
          tlRef.current = new MoTimeline(ulRef.current, options);
        }
        lenRef.current = items.length;
      }, [items]); // eslint-disable-line react-hooks/exhaustive-deps
    
      return (
        <ul ref={ulRef}>
          {items.map((item) => (
            <li key={item.id} {...(item.icon && { 'data-mo-icon': item.icon })}>
              <div className="mo-card">
                {item.banner && (
                  <div className="mo-card-image">
                    <img className="mo-banner" src={item.banner} alt="" />
                    {item.avatar && <img className="mo-avatar" src={item.avatar} alt="" />}
                  </div>
                )}
                <div className="mo-card-body">
                  {item.title && <h3>{item.title}</h3>}
                  {item.meta  && <p className="mo-meta">{item.meta}</p>}
                  {item.text  && <p>{item.text}</p>}
                </div>
              </div>
            </li>
          ))}
        </ul>
      );
    }

    React — usage example

    import { useState } from 'react';
    import Timeline from './Timeline';
    
    const INITIAL = [
      { id: '1', title: 'Project kickoff', meta: 'Jan 2024', text: 'Team aligned on goals and tech stack.' },
      { id: '2', title: 'Design system',   meta: 'Feb 2024', text: 'Component library shipped.',
        banner: 'banner.jpg', avatar: 'avatar.jpg' },
    ];
    
    export default function App() {
      const [items, setItems] = useState(INITIAL);
    
      const addItem = () =>
        setItems(prev => [...prev, {
          id:    String(Date.now()),
          title: 'New event',
          meta:  'Just now',
          text:  'Added dynamically from React state.',
        }]);
    
      return (
        <>
          <Timeline
            items={items}
            options={{ showBadge: true, showArrow: true, theme: true }}
          />
          <button onClick={addItem}>Add item</button>
        </>
      );
    }

    Bootstrap

    <!-- Bootstrap is fully compatible — just wrap in a container.
         No framework option needed; moTimeline handles its own layout. -->
    <div class="container">
      <ul id="my-timeline">...</ul>
    </div>

    API

    // Initialize (also called automatically in constructor)
    const tl = new MoTimeline(elementOrSelector, options);
    
    tl.refresh();                    // re-layout (called automatically on resize)
    tl.initNewItems();            // pick up manually appended <li> elements
    tl.addItems(items);            // create <li> from an array of objects (or JSON string) and append
    tl.insertItem(item, index);   // insert a single item at index (omit for random position)
    tl.clear();                      // remove all items and ad slots, reset counters — instance stays alive
    tl.destroy();                   // remove listeners, reset DOM

    addItems / insertItem — item schema

    // All fields are optional except at least one of title / text
    tl.addItems([
      {
        title:     "Project kickoff",        // <h3> heading
        meta:      "January 2024",           // date / subtitle line
        text:      "Kicked off the roadmap.", // body paragraph
        banner:    "images/banner.jpg",       // img.mo-banner (optional)
        avatar:    "images/avatar.jpg",       // img.mo-avatar (optional)
        icon:      "images/icon.svg",         // data-mo-icon on <li>, used by showCounterStyle:'image'
        fullWidth: true                       // span both columns (insertItem only)
      },
    ]);
    
    // Insert at a specific position (or random when index is omitted):
    tl.insertItem({ title: "Featured", text: "...", fullWidth: true }, 0);
    
    // Mark any <li> as full-width manually, then refresh:
    document.querySelector('li').classList.add('mo-fullwidth');
    tl.refresh();

    Infinite scroll recipe

    moTimeline handles the layout — wire up an IntersectionObserver on a sentinel element to trigger your own data fetch, then call addItems().

    <!-- Place a sentinel element right after the <ul> -->
    <ul id="my-timeline"></ul>
    <div id="sentinel"></div>
    const tl       = new MoTimeline('#my-timeline', { theme: true, showBadge: true });
    const sentinel  = document.getElementById('sentinel');
    let   loading   = false;
    let   page      = 1;
    let   exhausted = false;
    
    const observer = new IntersectionObserver(async (entries) => {
      if (!entries[0].isIntersecting || loading || exhausted) return;
    
      loading = true;
      const items = await fetchPage(page); // your own async data fetch
    
      if (items.length === 0) {
        exhausted = true;
        observer.disconnect();
      } else {
        tl.addItems(items);              // moTimeline creates <li> and lays out
        page++;
      }
      loading = false;
    });
    
    observer.observe(sentinel);
    
    // Example fetch — replace with your real API call
    async function fetchPage(page) {
      const res  = await fetch(`/api/events?page=${page}`);
      const data = await res.json();
      return data.items; // [{ title, meta, text, banner, avatar }, …]
    }

    CSS custom properties

    #my-timeline {
      --mo-line-color:      #c7d2fe;
      --mo-badge-bg:        #4f46e5;
      --mo-badge-color:     #fff;
      --mo-badge-size:      26px;
      --mo-badge-font-size: 12px;
      --mo-arrow-color:     #c7d2fe;
      --mo-card-border-radius: 8px;
      --mo-avatar-size:     50px;
      --mo-card-margin:            0.5rem 1.25rem 0.5rem 0.5rem;
      --mo-card-margin-inverted:   0.5rem 0.5rem 0.5rem 1.25rem;
      --mo-card-margin-fullwidth:  0.5rem;
      --mo-animate-duration:        0.5s;
    }

    Options

    OptionTypeDefaultDescription
    columnCountobject{xs:1, sm:2, md:2, lg:2} Columns at each responsive breakpoint: xs < 600 px · sm < 992 px · md < 1 200 px · lg ≥ 1 200 px. Set any key to 1 to force single-column at that width. The center line, badges, and arrows are only visible in two-column mode.
    showBadgebooleanfalse Render a circular badge on the center line for every item, numbered sequentially. Badges are automatically hidden when single-column mode is active (no center line to attach to).
    showArrowbooleanfalse Render a triangle arrow pointing from each card toward the center line. Automatically hidden in single-column mode.
    themebooleanfalse Enable the built-in card theme: white cards with drop shadow, full-width image banners (160 px), overlapping circular avatars, and styled badges. Adds the mo-theme class to the container — can also be set manually in HTML.
    showCounterStylestring'counter' 'counter' — sequential item number (1, 2, 3…).
    'image' — image from data-mo-icon on the <li>; falls back to a built-in flat SVG dot if the attribute is absent.
    'none' — badge element is created (preserving center-line spacing) but rendered with opacity: 0.
    cardBorderRadiusstring'8px' Border radius of the themed card and its banner image top corners. Sets the --mo-card-border-radius CSS custom property on the container. Any valid CSS length is accepted (e.g. '0', '16px', '1rem').
    avatarSizestring'50px' Width and height of the circular avatar image. Sets the --mo-avatar-size CSS custom property on the container. Any valid CSS length is accepted (e.g. '40px', '4rem').
    cardMarginstring'0.5rem 1.25rem 0.5rem 0.5rem' Margin of left-column themed cards (the larger right value creates space toward the center line). Sets --mo-card-margin on the container.
    cardMarginInvertedstring'0.5rem 0.5rem 0.5rem 1.25rem' Margin of right-column (inverted) themed cards (the larger left value creates space toward the center line). Sets --mo-card-margin-inverted on the container.
    cardMarginFullWidthstring'0.5rem' Margin of full-width themed cards. Sets --mo-card-margin-fullwidth on the container.
    randomFullWidthnumber | boolean0 0 / false = off. A number 0–1 sets the probability that each item is randomly promoted to full-width during init. true = 33% chance. Items can also be set manually by adding the mo-fullwidth class to the <li>.
    animatestring | booleanfalse Animate items as they scroll into view using IntersectionObserver. 'fade' — fade in. 'slide' — slide in from left/right column direction. true = 'fade'. Speed via --mo-animate-duration.
    renderCardfunction | nullnull (item, cardEl) => void. When set, called for every item instead of the built-in HTML renderer. cardEl is the .mo-card div already placed inside the <li>. Populate it via innerHTML or DOM methods. The library still owns column placement, spine, badge, arrow, addItems(), and scroll pagination.
    adSlotsobject | nullnull Inject ad slot <li class="mo-ad-slot"> placeholders and observe them. Shape: { mode: 'every_n'|'random', interval: number, style: 'card'|'fullwidth', onEnterViewport: (slotEl, position) => void }. mode:'every_n' — inject after every interval real items. mode:'random' — inject one slot at a random position within each interval-item page. style:'fullwidth' — slot spans both columns. onEnterViewport fires once per slot when ≥ 50% is visible; position is the slot's 0-based child index at injection time. Slots are removed on destroy().

    Data attributes

    AttributeElementDescription
    data-mo-icon<li> URL of the image shown inside the badge when showCounterStyle: 'image'. Accepts any web-safe format including inline SVG data URIs. If omitted, the built-in flat SVG icon is used as fallback. Also set automatically by addItems() when an icon field is provided.

    CSS classes reference

    ClassApplied toDescription
    mo-timelinecontainer <ul>Core layout class. Added automatically on init; safe to add in HTML before init.
    mo-twocolcontainerPresent when two-column mode is active. Triggers the center vertical line and badge/arrow positioning.
    mo-themecontainerActivates the built-in card theme styles. Added by theme: true or set manually.
    mo-item<li>Applied to every timeline item. Controls 50 % width and float direction.
    mo-inverted<li>Added to right-column items. Flips float, badge, arrow, and avatar positions.
    mo-offset<li>Added when a badge would overlap the previous item's badge on the center line — nudges badge and arrow down to avoid collision.
    mo-badge<span>Badge circle on the center line. Style via CSS custom properties.
    mo-badge-icon<img> inside badgeImage inside the badge when showCounterStyle: 'image'.
    mo-arrow<span>Triangle arrow pointing from the card toward the center line.
    mo-card<div>Card wrapper. Receives shadow, border-radius, and margins when mo-theme is active.
    mo-card-image<div>Optional image container inside a card. Required for the avatar-over-banner overlap effect.
    mo-banner<img>Full-width banner image at the top of a themed card.
    mo-avatar<img>Circular avatar that overlaps the bottom edge of the banner. Always positioned on the right side of the card.
    mo-card-body<div>Text content area. Receives padding and typography when mo-theme is active.
    mo-meta<p>Date / subtitle line inside a card body. Muted colour, smaller font.
    js-mo-item · js-mo-inverted<li>JS-only selector mirrors of mo-item / mo-inverted. Use these in your own JavaScript queries to avoid coupling to styling class names.