Lightweight, dependency-free JavaScript library for responsive two-column timeline layouts — with the most publisher-friendly ad slot support on npm.
via Blog
The new v2 API is live. Breaking changes are listed in the migration guide; all v1 endpoints remain available until Q3.
via Twitter
P95 latency dropped from 340 ms to 88 ms after rolling out the new caching layer to all edge nodes.
via LinkedIn
Welcome to the team! Six engineers joined this month across frontend, backend, and infrastructure.
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.
via GitHub
Dark mode, keyboard navigation, and a new plugin API landed in this release. Full changelog on GitHub.
via Email
300 early adopters onboarded this week. Structured feedback sessions start Monday — sign up in the dashboard.
via YouTube
Live-streamed the first public walkthrough to 1 400 viewers. Recording available on the YouTube channel.
via Slack
Repository created, architecture decisions recorded, first sprint planned. Let's build something great.
npm install motimeline
import MoTimeline from 'motimeline';
import 'motimeline/dist/moTimeline.css';
const tl = new MoTimeline('#my-timeline', {
showBadge: true,
showArrow: true,
theme: true,
});
<link rel="stylesheet" href="moTimeline.css">
<script src="moTimeline.umd.js"></script>
<script>
const tl = new MoTimeline('#my-timeline', { theme: true });
</script>
<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>
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>
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>
);
}
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 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>
// 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
// 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();
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 }, …]
}
#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;
}
| 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 (no center line to attach to). |
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 the mo-theme class 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 the --mo-card-border-radius CSS custom property 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 the --mo-avatar-size CSS custom property 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' — fade in. 'slide' — slide in from left/right column direction. true = 'fade'. 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 column placement, spine, badge, arrow, addItems(), and scroll pagination. |
adSlots | object | null | null |
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().
|
| 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. If omitted, the built-in flat SVG icon is used as fallback. Also set automatically by addItems() when an icon field is provided. |
| 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 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 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. 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. |