Skip to content

MattOpen/moTimeline

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

103 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

moTimeline

Responsive two-column timeline layout library — plain JavaScript, zero dependencies, MIT licensed.

Live demo & docs → mattopen.github.io/moTimeline

npm MIT License

Preview


Features

  • Zero dependencies — no jQuery, no frameworks required
  • Responsive — two columns on desktop, single column on mobile
  • Configurable breakpoints — control column count at xs / sm / md / lg
  • Badges & arrows — numbered badges on the center line, directional arrows
  • Optional theme — built-in card theme with image banners and overlapping avatars
  • CSS custom properties — override colors and sizes with one line of CSS
  • Dynamic items — append, insert, or inject <li> elements at any time via initNewItems(), addItems(), or insertItem()
  • Custom card renderer — pass renderCard(item, cardEl) to inject any HTML, vanilla JS, or full React components into each card slot; the library handles everything else
  • Publisher-ready ad slots — the most publisher-friendly timeline on npm: adSlots injects viewport-triggered <li> placeholders at configurable cadences (every_n or random), fires onEnterViewport exactly once per slot at ≥ 50% visibility, works seamlessly with infinite scroll, and cleans up on destroy(). Drop in AdSense, house ads, or any network with three lines of code.
  • Bootstrap compatible — wrap the <ul> in a Bootstrap .container, no config needed
  • ESM · CJS · UMD — works with any bundler or as a plain <script> tag

Installation

npm install motimeline

ESM

import MoTimeline from 'motimeline';
import 'motimeline/dist/moTimeline.css';

UMD (no bundler)

<link rel="stylesheet" href="moTimeline.css">
<script src="moTimeline.umd.js"></script>

Quick start

<ul id="my-timeline">
  <li>
    <div class="mo-card">
      <div class="mo-card-body">
        <h3>Title</h3>
        <p class="mo-meta">Date</p>
        <p>Text…</p>
      </div>
    </div>
  </li>
  <!-- more <li> items -->
</ul>

<script type="module">
  import MoTimeline from 'motimeline';

  const tl = new MoTimeline('#my-timeline', {
    showBadge: true,
    showArrow: true,
    theme:     true,
  });
</script>

With banner image and avatar (theme: true)

<li>
  <div class="mo-card">
    <div class="mo-card-image">
      <img class="mo-banner" src="banner.jpg" alt="">
      <img class="mo-avatar" src="avatar.jpg" alt="">  <!-- optional -->
    </div>
    <div class="mo-card-body">
      <h3>Title</h3>
      <p class="mo-meta">Date</p>
      <p>Text…</p>
    </div>
  </div>
</li>

Rendered DOM (after init)

The library injects classes and elements into your markup. Here is what a fully rendered item looks like:

<!-- Container gets mo-timeline, mo-theme, mo-twocol added -->
<ul class="mo-timeline mo-theme mo-twocol">

  <!-- Left-column item: mo-item + js-mo-item added to every <li> -->
  <li class="mo-item js-mo-item">
    <span class="mo-arrow js-mo-arrow"></span>   <!-- injected when showArrow: true -->
    <span class="mo-badge js-mo-badge">1</span>  <!-- injected when showBadge: true -->
    <div class="mo-card">
      <div class="mo-card-image">
        <img class="mo-banner" src="banner.jpg" alt="">
        <img class="mo-avatar" src="avatar.jpg" alt="">
      </div>
      <div class="mo-card-body">
        <h3>Title</h3>
        <p class="mo-meta">Date</p>
        <p>Text…</p>
      </div>
    </div>
  </li>

  <!-- Right-column item: also gets mo-inverted + js-mo-inverted -->
  <li class="mo-item js-mo-item mo-inverted js-mo-inverted">
    ...
  </li>

</ul>

js-mo-* classes are JS-only selector mirrors of their mo-* counterparts — use them in your own scripts to avoid coupling to styling class names.


Options

Option Type Default Description
columnCount object {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.
showBadge boolean false Render a circular badge on the center line for every item, numbered sequentially. Badges are automatically hidden when single-column mode is active.
showArrow boolean false Render a triangle arrow pointing from each card toward the center line. Automatically hidden in single-column mode.
theme boolean false Enable the built-in card theme: white cards with drop shadow, full-width image banners (160 px), overlapping circular avatars, and styled badges. Adds mo-theme to the container — can also be set manually in HTML.
showCounterStyle string '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.
cardBorderRadius string '8px' Border radius of the themed card and its banner image top corners. Sets --mo-card-border-radius on the container. Any valid CSS length is accepted (e.g. '0', '16px', '1rem').
avatarSize string '50px' Width and height of the circular avatar image. Sets --mo-avatar-size on the container. Any valid CSS length is accepted (e.g. '40px', '4rem').
cardMargin string '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.
cardMarginInverted string '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.
cardMarginFullWidth string '0.5rem' Margin of full-width themed cards. Sets --mo-card-margin-fullwidth on the container.
randomFullWidth number | boolean 0 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>.
animate string | boolean false Animate items as they scroll into view using IntersectionObserver. 'fade' — items fade in. 'slide' — left-column items slide in from the left, right-column items from the right. true = 'fade'. Disable for individual items by adding mo-visible manually. Control speed via --mo-animate-duration.
renderCard function | null null (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 the <li>, column placement, spine, badge, arrow, addItems(), and scroll pagination.
adSlots object | null null Inject ad slot placeholders into the timeline and observe them. See Ad slots below.

Data attributes

Attribute Element Description
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. Falls back to a built-in SVG icon if absent. Also set automatically by addItems() when an icon field is provided.

CSS classes reference

Class Applied to Description
mo-timeline container <ul> Core layout class. Added automatically on init; safe to add in HTML before init.
mo-twocol container Present when two-column mode is active. Triggers the center vertical line and badge/arrow positioning.
mo-theme container Activates the built-in card theme. 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 badge — 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 badge Image inside the badge when showCounterStyle: 'image'.
mo-arrow <span> Triangle arrow pointing from the card toward the center line.
mo-card <div> Card wrapper. 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.
mo-banner <img> Full-width banner image at the top of a themed card.
mo-avatar <img> Circular avatar overlapping the bottom of the banner. Always positioned on the right side of the card.
mo-card-body <div> Text content area. 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 in your own JS queries to avoid coupling to styling class names.

API

const tl = new MoTimeline(elementOrSelector, options);

tl.refresh();               // re-layout all items (called automatically on resize)
tl.initNewItems();          // pick up manually appended <li> elements
tl.addItems(items);         // create and append <li> from an array of item objects (or JSON string)
tl.insertItem(item, index); // insert a single item at a specific index (or random if omitted)
tl.clear();                 // remove all items and ad slots, reset counters — instance stays alive
tl.destroy();               // remove listeners and reset DOM classes

insertItem

// Insert at a specific 0-based index:
tl.insertItem({ title: 'Breaking news', meta: 'Now', text: '...' }, 2);

// Insert at a random position (omit the index):
tl.insertItem({ title: 'Surprise!', meta: 'Now', text: '...' });

// Insert as a full-width item:
tl.insertItem({ title: 'Featured', meta: 'Now', text: '...', fullWidth: true }, 0);

Badge numbers are automatically re-sequenced after insertion. Returns the inserted <li> element.

addItems — item schema

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'
  },
]);

// A JSON string is also accepted:
tl.addItems('[{"title":"From JSON","meta":"Today","text":"Parsed automatically."}]');

React

moTimeline manipulates the DOM directly, so use a useRef + useEffect wrapper to bridge it with React's rendering. Save the snippet below as Timeline.jsx:

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>
  );
}

Usage

import { useState } from 'react';
import Timeline from './Timeline';

export default function App() {
  const [items, setItems] = useState([
    { id: '1', title: 'Project kickoff', meta: 'Jan 2024', text: 'Team aligned on goals.' },
    { id: '2', title: 'Design system',   meta: 'Feb 2024', text: 'Component library shipped.',
      banner: 'banner.jpg', avatar: 'avatar.jpg' },
  ]);

  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>
    </>
  );
}

How it works: React renders the <li> elements. moTimeline initialises once on mount and reads the DOM. When the items array grows, initNewItems() picks up the new <li> nodes React just appended. When items are removed or reordered React re-renders the list and the instance is fully reinitialised.


Ad slots

Inject ad placeholder <li> elements at configurable positions, observe them with IntersectionObserver, and fire a callback exactly once when each slot reaches 50% visibility. The library owns the slot element — you own what goes inside it.

const tl = new MoTimeline('#my-timeline', {
  adSlots: {
    mode:     'every_n', // 'every_n' | 'random'
    interval: 10,        // every_n: inject after every N real items
                         // random:  inject once at a random position per N-item page
    style:    'card',    // 'card' | 'fullwidth'
    onEnterViewport: (slotEl, position) => {
      // slotEl   = the <li class="mo-ad-slot"> element
      // position = its 0-based index in the container at injection time
      const ins = document.createElement('ins');
      ins.className        = 'adsbygoogle';
      ins.style.display    = 'block';
      ins.dataset.adClient = 'ca-pub-XXXXXXXXXXXXXXXX';
      ins.dataset.adSlot   = '1234567890';
      ins.dataset.adFormat = 'auto';
      slotEl.appendChild(ins);
      (window.adsbygoogle = window.adsbygoogle || []).push({});
    },
  },
});

adSlots option shape

Property Type Description
mode 'every_n' | 'random' 'every_n' — inject after every interval real items. 'random' — inject one slot at a random position within each interval-item page.
interval number Cadence for slot injection (see mode).
style 'card' | 'fullwidth' 'card' — slot sits in the normal left/right column flow. 'fullwidth' — slot spans both columns (adds mo-fullwidth).
onEnterViewport (slotEl: HTMLElement, position: number) => void Called once per slot when ≥ 50% of it enters the viewport. position is the 0-based child index of the slot in the container at injection time.

What the library provides:

  • A <li class="mo-ad-slot"> element with min-height: 100px (so the observer can detect it before content loads)
  • fullwidth layout via the existing mo-fullwidth mechanism when style: 'fullwidth'
  • Exactly-once IntersectionObserver (threshold 0.5) per slot
  • Automatic slot cleanup on tl.destroy()

What you provide: everything inside the slot — the ad creative, network scripts, markup.

Slots are injected after each addItems() call, so they work seamlessly with infinite scroll.

Infinite scroll recipe

moTimeline handles the layout — you own the data fetching. Wire an IntersectionObserver to a sentinel element below the list and call addItems() when it comes into view.

<!-- 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 }, …]
}

IntersectionObserver is supported in all modern browsers with no polyfill needed. The loading flag prevents duplicate requests if the sentinel stays visible while a fetch is in flight. Set exhausted = true and disconnect when your API returns an empty page.


CSS custom properties

#my-timeline {
  --mo-line-color:      #dde1e7;
  --mo-badge-bg:        #4f46e5;
  --mo-badge-color:     #fff;
  --mo-badge-size:      26px;
  --mo-badge-font-size: 12px;
  --mo-arrow-color:     #dde1e7;
  --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;
}

Bootstrap integration

No framework option needed. Wrap the <ul> inside a Bootstrap .container:

<div class="container">
  <ul id="my-timeline"></ul>
</div>

Examples

Folder Description
example/ Main example — run with npm run dev
example/mattopen/ Bootstrap 5 integration
example/livestamp/ Livestamp.js + Moment.js relative timestamps

Changelog

v2.12.0

  • New method clear() — removes all .mo-item and .mo-ad-slot elements from the container and resets internal counters (lastItemIdx, _adRealCount) without destroying the instance. Active IntersectionObservers are disconnected but kept alive so they re-observe items added by the next addItems() call. Use this in React wrappers to reinitialize timeline content when props change without recreating the instance.

v2.11.0

  • New option adSlots — inject ad slot <li> placeholders at configurable positions (every_n or random mode) and receive an onEnterViewport(slotEl, position) callback exactly once per slot when ≥ 50% of it is visible. Works with addItems() and infinite scroll. Slots are removed on tl.destroy(). See Ad slots section.

v2.10.0

  • New option renderCard(item, cardEl) — custom card renderer. When provided, the library skips its built-in card HTML and calls this function instead, passing the item data object and the .mo-card div already inserted into the DOM. The library continues to own column placement, spine, badge, arrow, addItems(), and scroll pagination. Enables full React component injection via createRoot(cardEl).render(...).

v2.9.0

  • New option animate — scroll-triggered animations via IntersectionObserver. 'fade' fades items in as they enter the viewport; 'slide' slides left-column items from the left and right-column items from the right. true defaults to 'fade'. Speed controlled via --mo-animate-duration (default 0.5s). Works for initial load, addItems(), and insertItem().

v2.8.1

  • New method insertItem(item, index) — inserts a single item at a specific 0-based index, or at a random position when index is omitted. Badge numbers are re-sequenced automatically.

v2.8.0

  • New: full-width items — add mo-fullwidth class to any <li> to make it span both columns (two-column mode only). Badge and arrow are hidden automatically; card margin collapses to equal sides via --mo-card-margin-fullwidth
  • New option randomFullWidth (number 0–1 or boolean) — randomly promotes items to full-width during init (true = 33% probability)
  • New option cardMarginFullWidth (string, default '0.5rem') — controls the themed card margin for full-width items

v2.7.5

  • New options cardMargin (default '0.5rem 1.25rem 0.5rem 0.5rem') and cardMarginInverted (default '0.5rem 0.5rem 0.5rem 1.25rem') — control themed card margins via --mo-card-margin and --mo-card-margin-inverted

v2.7.4

  • Fix: wrong column placement when adjacent left and right items share the same bottom y-coordinate (#3) — adds a 1 px tolerance to the column algorithm to absorb offsetHeight/offsetTop rounding mismatches

v2.7.3

  • Fix: cards misaligned on first load when items contain images (#2) — layout now re-runs automatically after each unloaded image fires its load event, so column placement is correct once images are rendered

v2.7.2

  • Fix: cards misaligned on first load (#2) — reverted to sequential offsetTop-based column algorithm; the batch offsetHeight fill-shorter approach produced non-alternating columns

v2.7.1

  • Fix: resize listener not attached when container is empty at init time (#1) — addItems() on an empty timeline now correctly responds to window resize

v2.7.0

  • New option cardBorderRadius (string, default '8px') — controls card and banner border radius via --mo-card-border-radius
  • New option avatarSize (string, default '50px') — controls avatar width/height via --mo-avatar-size

v2.6.0

  • Breaking: badgeShow renamed to showBadge; arrowShow renamed to showArrow — consistent show* naming alongside showCounterStyle

v2.5.0

  • Breaking: showCounter (boolean) removed — replaced by showCounterStyle: 'none', which preserves center-line spacing with an invisible badge
  • showCounterStyle now accepts three values: 'counter' · 'image' · 'none'

v2.4.0

  • Added addItems(items) — creates and appends <li> elements from an array of item objects or a JSON string, then initializes them in one batch
  • Badges and arrows now hidden in single-column mode (center-line elements have no meaning without a center line)

v2.3.0

  • Added showCounterStyle ('counter' | 'image') and showCounter opacity toggle (consolidated into showCounterStyle: 'none' in v2.5.0)
  • data-mo-icon attribute on <li> sets a custom icon in image mode; built-in flat SVG used as fallback

v2.2.0

  • All library-managed classes renamed to consistent mo- prefix (mo-item, mo-badge, mo-arrow, mo-twocol, mo-offset)
  • Added parallel js-mo-* classes for JS-only selectors alongside mo-* styling classes

v2.1.0

  • Opt-in card theme (theme: true) with mo-card, mo-banner, mo-avatar
  • Badges repositioned to the center line with directional arrows
  • CSS custom properties for easy color/size overrides
  • Badge offset algorithm: later DOM item always gets the offset on collision

v2.0.0

  • Complete rewrite — removed jQuery, zero dependencies
  • Class-based API: new MoTimeline(element, options)
  • Vite build pipeline: ESM, CJS, UMD outputs
  • Debounced resize listener, WeakMap instance data storage

License

MIT © MattOpen

About

Responsive two-column timeline layout library — plain JavaScript, zero dependencies, MIT licensed.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors