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.filterByCategory(category);    // show only items whose data-categories includes category; null/'all' shows all
      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)
          categories: ["development", "architecture"] // used by filterByCategory() — also accepts a space-separated string
        },
      ]);
      
      // 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 }, …]
      }

      Category filter recipe

      Tag items with data-categories, then call filterByCategory() from your own filter buttons. The active filter is remembered — items added later via addItems() are filtered automatically.

      <!-- Filter buttons (your own markup) -->
      <div class="filters">
        <button data-cat="all">All</button>
        <button data-cat="development">Development</button>
        <button data-cat="architecture">Architecture</button>
      </div>
      
      <!-- Items tagged with categories -->
      <ul id="my-timeline"></ul>
      const tl = new MoTimeline('#my-timeline', { theme: true, showBadge: true });
      
      // Load items with categories (array or space-separated string)
      tl.addItems([
        { title: 'API cleanup',  meta: 'Jan 2025', text: '...', categories: ['development', 'architecture'] },
        { title: 'Sprint review', meta: 'Feb 2025', text: '...', categories: 'management' },
      ]);
      
      // Wire filter buttons
      document.querySelectorAll('.filters button').forEach(btn => {
        btn.addEventListener('click', () => {
          document.querySelectorAll('.filters button').forEach(b => b.classList.remove('active'));
          btn.classList.add('active');
          tl.filterByCategory(btn.dataset.cat); // 'all' → show everything
        });
      });
      
      // Active filter persists through lazy-loaded batches
      tl.addItems(moreItemsFromServer); // new items auto-filtered to match current selection

      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.
      data-categories<li> Space-separated list of category tokens this item belongs to (e.g. "development architecture"). Used by filterByCategory() to show or hide items. Set automatically by addItems() / insertItem() when a categories field is provided (array or string). Can also be set manually in HTML.

      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.
      mo-filtered-out<li>Added by filterByCategory() to items that do not match the active filter. Sets display: none and excludes the item from column-placement calculations. Removed automatically when the filter is cleared or the item's category is selected.