Responsive two-column timeline layout library — plain JavaScript, zero dependencies, MIT licensed.
Live demo & docs → mattopen.github.io/moTimeline
- 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 viainitNewItems(),addItems(), orinsertItem() - 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:
adSlotsinjects viewport-triggered<li>placeholders at configurable cadences (every_norrandom), firesonEnterViewportexactly once per slot at ≥ 50% visibility, works seamlessly with infinite scroll, and cleans up ondestroy(). 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
npm install motimelineimport MoTimeline from 'motimeline';
import 'motimeline/dist/moTimeline.css';<link rel="stylesheet" href="moTimeline.css">
<script src="moTimeline.umd.js"></script><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><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>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 theirmo-*counterparts — use them in your own scripts to avoid coupling to styling class names.
| 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. |
| 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. |
| 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. |
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// 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.
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."}]');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>
);
}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 theitemsarray 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.
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({});
},
},
});| 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 withmin-height: 100px(so the observer can detect it before content loads) fullwidthlayout via the existingmo-fullwidthmechanism whenstyle: '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.
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 }, …]
}
IntersectionObserveris supported in all modern browsers with no polyfill needed. Theloadingflag prevents duplicate requests if the sentinel stays visible while a fetch is in flight. Setexhausted = trueand disconnect when your API returns an empty page.
#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;
}No framework option needed. Wrap the <ul> inside a Bootstrap .container:
<div class="container">
<ul id="my-timeline">…</ul>
</div>| Folder | Description |
|---|---|
example/ |
Main example — run with npm run dev |
example/mattopen/ |
Bootstrap 5 integration |
example/livestamp/ |
Livestamp.js + Moment.js relative timestamps |
- New method
clear()— removes all.mo-itemand.mo-ad-slotelements from the container and resets internal counters (lastItemIdx,_adRealCount) without destroying the instance. ActiveIntersectionObservers are disconnected but kept alive so they re-observe items added by the nextaddItems()call. Use this in React wrappers to reinitialize timeline content when props change without recreating the instance.
- New option
adSlots— inject ad slot<li>placeholders at configurable positions (every_norrandommode) and receive anonEnterViewport(slotEl, position)callback exactly once per slot when ≥ 50% of it is visible. Works withaddItems()and infinite scroll. Slots are removed ontl.destroy(). See Ad slots section.
- 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-carddiv already inserted into the DOM. The library continues to own column placement, spine, badge, arrow,addItems(), and scroll pagination. Enables full React component injection viacreateRoot(cardEl).render(...).
- New option
animate— scroll-triggered animations viaIntersectionObserver.'fade'fades items in as they enter the viewport;'slide'slides left-column items from the left and right-column items from the right.truedefaults to'fade'. Speed controlled via--mo-animate-duration(default0.5s). Works for initial load,addItems(), andinsertItem().
- 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.
- New: full-width items — add
mo-fullwidthclass 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
- New options
cardMargin(default'0.5rem 1.25rem 0.5rem 0.5rem') andcardMarginInverted(default'0.5rem 0.5rem 0.5rem 1.25rem') — control themed card margins via--mo-card-marginand--mo-card-margin-inverted
- 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/offsetToprounding mismatches
- Fix: cards misaligned on first load when items contain images (#2) — layout now re-runs automatically after each unloaded image fires its
loadevent, so column placement is correct once images are rendered
- Fix: cards misaligned on first load (#2) — reverted to sequential
offsetTop-based column algorithm; the batchoffsetHeightfill-shorter approach produced non-alternating columns
- Fix: resize listener not attached when container is empty at init time (#1) —
addItems()on an empty timeline now correctly responds to window resize
- 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
- Breaking:
badgeShowrenamed toshowBadge;arrowShowrenamed toshowArrow— consistentshow*naming alongsideshowCounterStyle
- Breaking:
showCounter(boolean) removed — replaced byshowCounterStyle: 'none', which preserves center-line spacing with an invisible badge showCounterStylenow accepts three values:'counter'·'image'·'none'
- 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)
- Added
showCounterStyle('counter'|'image') andshowCounteropacity toggle (consolidated intoshowCounterStyle: 'none'in v2.5.0) data-mo-iconattribute on<li>sets a custom icon in image mode; built-in flat SVG used as fallback
- 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 alongsidemo-*styling classes
- Opt-in card theme (
theme: true) withmo-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
- Complete rewrite — removed jQuery, zero dependencies
- Class-based API:
new MoTimeline(element, options) - Vite build pipeline: ESM, CJS, UMD outputs
- Debounced resize listener,
WeakMapinstance data storage
MIT © MattOpen