moTimeline

Lightweight, dependency-free JavaScript timeline library — four layouts, themes, category filters, and 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.

      A slot is injected after every 3rd item. Each placeholder is a <li class="mo-item mo-ad-slot"> the library reserves space for — onEnterViewport fires once per slot at ≥ 50 % visibility and fills it. Scroll down to watch the slots load; the counter shows how many have fired.

      0 slots filled

        Real events from the Wikimedia API — no API key, fetched live and fed straight into addItems(). Switch the layout to see any design with real data.

          Content from the Wikimedia REST API, text under CC BY-SA 4.0.

          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.
          layoutstring'fill' How items are placed. 'fill' — each item goes into the shorter column, so cards pull up alongside each other and leave no gaps. 'stacked' — a single column with the spine on the left and the date in a gutter beside it. 'rows' — like 'stacked', but card and date swap sides on every item; one item per row, so nothing is pulled upward. 'stacked' and 'rows' read the date from data-mo-date (set it directly, or pass date to addItems()). See the Timestamp demo tab.
          mirrorTextbooleanfalse Right-hand cards align their text toward the center line, so the two columns read as reflections of each other. Independent of layout.
          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-item 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.
          data-mo-date<li> Short date shown in the gutter by layout: 'stacked' and layout: 'rows' (e.g. "Oct 2025"). Ignored by the other layouts. Set automatically by addItems() / insertItem() when a date 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.
          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.