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
- Category filtering — tag items with
categoriesand callfilterByCategory()to show/hide by category; active filter persists automatically through lazy-loaded batches - 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. |
data-categories |
<li> |
Space-separated list of category tokens this item belongs to (e.g. "development architecture"). Used by filterByCategory(). Set automatically by addItems() / insertItem() when a categories field is provided. Can also be set manually in HTML. |
| 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. |
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 when the filter is cleared or the item's category is selected. |
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.filterByCategory(category); // show only items matching category; null / 'all' shows everything
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'
categories: ["development", "architecture"] // used by filterByCategory() — array or space-separated string
},
]);
// 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.
Tag items with categories (array or space-separated string) and wire filterByCategory() to your own filter buttons. The active filter is stored on the instance — items added later via addItems() are filtered automatically, making it fully compatible with infinite scroll.
const tl = new MoTimeline('#my-timeline', { theme: true, showBadge: true });
// Load items with categories
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); // pass null or 'all' to show everything
});
});
// Active filter persists through lazy-loaded batches
tl.addItems(moreItemsFromServer); // auto-filtered to match current selectionOr set data-categories directly in HTML and use initNewItems():
<li data-categories="development architecture">...</li>
<li data-categories="management">...</li>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: category filtering — tag items with
categories(array or space-separated string) and callfilterByCategory(category)to show only matching items. Passnullor'all'to clear the filter. The active filter is stored on the instance and applied automatically to items added viaaddItems()orinitNewItems(), making it fully compatible with infinite scroll / server-side pagination. Column placement recalculates on every filter change so the two-column layout stays correct. New classmo-filtered-outis used internally (display: none) and new attributedata-categoriesis set on each<li>.
- Fix: badge width — use
widthinstead ofmin-widthto prevent oversized badges (#8)
- 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