Custom Card Layouts
Create completely custom article card layouts alongside the built-in portrait and landscape layouts, giving you full control when the standard styling options aren’t enough to achieve the design you need.
Built-in card presets New
The two base layouts, portrait and landscape, arrange cards vertically and horizontally. Six presets are registered automatically: five extend portrait, while pill extends landscape. Each inherits its parent's drawing, connector, star, and cache hooks. image-only supplies image-based measurements; the other five are style-only extensions.
title-only: 220 pixels wide, up to six title lines, 20-pixel line spacing and 12-pixel vertical padding; image and subtitle hidden.title-subtitle: 240 pixels wide, up to three bold title lines and four subtitle lines on a pale background; image hidden. Use the article'ssubtitlefield for supporting text or a description.label: automatic width capped at 280 pixels, one title line, 8-pixel text margins and 5-pixel vertical padding; image and subtitle hidden.image-text: 220 pixels wide, with a single-line title and optional subtitle above a natural-aspect image capped at 200 pixels high. Supports photographs, artwork, diagrams, and other image content.image-only: 220 pixels wide with both text blocks disabled. Missing, loading, or failed images leave a 220 × 200 placeholder with a light greybackgroundColor: '#f0f0f0'by default. See image-only sizing for fixed and automatic dimensions.pill: a 70-pixel-high landscape card with rounded ends (borderRadius: 36), a 62-pixel circular thumbnail, a single-line title and an optional single-line subtitle. Itswidth: 'auto'fits the text and visible thumbnail up tomaxWidth: 360; longer text is ellipsized.
The five portrait presets default to height: 'auto'; pill defaults to 70 and also accepts 'auto'. Its connector sits beneath the thumbnail, with offsetX: 35, offsetY: -27, and arrow.width: 14.
const timeline = new Histropedia.Timeline(container, {
article: { defaultCardLayout: 'title-subtitle', cardLayoutBreakpoints: [] }
});
timeline.load([
{ id: 'note', title: 'Field notes', subtitle: 'A longer description wraps here.', from: { year: 2020 } },
{ id: 'label', title: 'Base camp', from: { year: 2021 }, cardLayout: 'label', style: { maxWidth: 200 } }
]);Select presets through timeline or lane defaults, breakpoints, or individual article layouts. Disable breakpoints to keep the default at every height. Style overrides use each preset's own name, such as layoutStyles['title-subtitle'] or layoutStyles.pill; overrides under the parent layout's name do not carry over. Text-only presets can show images with image.visible: true. Try the preset and sizing example.
Pill spacing and overrides
Pill's header and subheader each use padding: { left: 14, right: 14 } and text.margin: 0. Its image margins are { top: 4, right: 0, bottom: 4, left: 4 }, so block padding supplies the 14-pixel gap after the thumbnail and the right-hand gutter. Missing, loading, failed or hidden images remove the image column; the same padding keeps text 14 pixels from both ends.
Change these ordinary style properties through article.layoutStyles.pill.style or the other style overrides. Setting padding: 0 on both text blocks removes their gutters. An explicit text.margin adds to the padding on both sides; the image's right margin adds to the gap before the text.
For a fixed-width, two-line pill, set width: 220 and header.text.numberOfLines: 2. Numeric widths ignore maxWidth.
Extend an existing layout New
Use extends to reuse a registered layout's hooks and independently deep-merge its normal, hover, and active defaults. Register the parent first and both layouts before creating timelines.
Histropedia.Timeline.registerCardLayout({
name: 'compact-landscape',
extends: 'landscape', // New
defaultStyle: { height: 44, image: { visible: false } },
defaultHoverStyle: { border: { color: '#64748b' } }
});
All hooks are inherited, including getConnectorEnd(). A supplied hook replaces the parent function rather than wrapping it. Extensions capture a snapshot of defaults and hook references at registration; replacing the parent later does not update the child. Parents may be base layouts, presets, custom layouts, extensions, or one-hop aliases to concrete layouts.
Use the child's name for timeline and lane layout-specific styles. Parent-name overrides do not carry over. Normal overrides do not erase inherited hover or active defaults. Use Histropedia.UNSET to remove inherited declarations. A layout cannot extend itself or combine extends and aliasOf.
Register before creating timelines
Timeline.registerCardLayout(definition) registers a layout globally for the current JavaScript
environment. Register during application startup, before constructing a timeline, so its style defaults are
copied into each timeline's defaults.
import { Timeline } from 'histropediajs';
// With the UMD script build, use the global Histropedia.Timeline instead.
Timeline.registerCardLayout({
name: 'compact',
draw(ctx) {
const style = this.getCurrentStyle();
const { left, top } = this.position;
const width = this.getWidth();
const height = this.getHeight();
ctx.save();
ctx.fillStyle = style.backgroundColor;
ctx.fillRect(left, top, width, height);
ctx.fillStyle = style.header.text.color;
ctx.font = style.header.text.font;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(this.title, left + 10, top + height / 2, width - 20);
ctx.restore();
},
defaultStyle: {
width: 220,
height: 48,
backgroundColor: '#fff',
header: { text: { color: '#0f172a', font: '600 14px sans-serif' } }
},
defaultHoverStyle: { backgroundColor: '#f8fafc' },
defaultActiveStyle: { backgroundColor: '#e0f2fe' }
});
const timeline = new Timeline(container, {
article: {
defaultCardLayout: 'compact',
// Disable the built-in responsive override for this minimal example.
cardLayoutBreakpoints: []
}
});
The instance wrapper performs the same global operation:
timeline.registerCardLayout(definition);
const layout = timeline.getCardLayout('compact');
// Equivalent registry access: Timeline.getCardLayout('compact')
Definition and drawing contract
Timeline.registerCardLayout(definition) New values
Global registrationRegister or replace a case-sensitive layout name. There is no public unregister operation.
Definition fields
name– required non-empty registry key.draw(ctx)– renderer required for a new layout (inherited when usingextends), called with the currentArticleasthis.getWidth()andgetHeight()– optional numeric CSS-pixel measurements. Width falls back to style width or standard text measurement for'auto'; height falls back to style height.getIconBox()– optional rectangular star hover/click area; defaults tofalse.defaultStyle,defaultHoverStyle, anddefaultActiveStyle– optional registered style defaults.extendsNew – reuse a registered layout with optional hook and style overrides.getConnectorEnd()– optional card attachment point as{ left, top }; otherwise derived from card origin, height, and connector offsets.aliasOf– alternative to drawing hooks when this name should resolve to an existing concrete layout.
Timeline.getCardLayout(name)
Global lookupReturn the registered concrete layout, follow one alias, or warn and return null when the name is unknown.
if (!Timeline.getCardLayout('compact')) {
throw new Error('Register the compact layout before constructing timelines');
}
The canvas context is shared with the axis, connectors, lanes, charts, time bands, and other cards. Isolate every
canvas property your hook changes. Wrapping the drawing code in ctx.save()/ctx.restore()
is the simplest reliable pattern; otherwise, restore each changed property yourself. Never clear the shared
canvas or leave a transform, clipping region, shadow, or compositing mode active.
Useful public article state includes id, title, subtitle, data,
position.left/top, style, hoverStyle, activeStyle,
activeHoverStyle, getCurrentStyle(), isActive, isMouseover, and opacity. Use normal
functions for hooks so this remains the current article.
Read this.data for the article's latest source data, including custom properties supplied when the
article was loaded or added through later data updates.
Call this.getCurrentStyle() inside a layout hook to retrieve the fully merged style for the
article's current normal, hover, active, or active-hover state. The individual style fields remain available
when you need to inspect a specific state.
getWidth(), getHeight(), and the hit area
Draw from this.position.left/top. Together, getWidth() and getHeight()
define the rectangular pointer hit area used for clicking and dragging. getWidth() also controls
horizontal overlap detection and row assignment, while getHeight() supplies the default connector
endpoint. There is no custom card-shape hit-test hook.
Measured height does not determine the vertical distance between stacked rows. That separation comes from
article.autoStacking.rowSpacing, so increase rowSpacing when a custom card is taller than
the configured spacing. Card visibility is determined by dates, filtering, and lane/group state, not by the
measured card dimensions. Keep measurement and drawing geometry identical and return finite, non-negative
CSS-pixel values.
With width: 'auto', the fallback width hook measures standard title/subtitle fonts, padding, margins, and border; custom hooks can supply their own content width. The shared cache applies maxWidth afterwards. Numeric widths ignore the cap. Use this.getWidth() in drawing code for the resolved number. The fallback height hook returns style height; a custom layout using height: 'auto' must implement a numeric height measurement.
Measurement hooks receive no canvas argument; use this.owner.canvasContext when text metrics are needed.
getWidth() {
const style = this.getCurrentStyle();
const key = `width:${this.title}:${style.header.text.font}`;
return this.memo(key, () => {
const ctx = this.owner.canvasContext;
ctx.save();
ctx.font = style.header.text.font;
const width = ctx.measureText(this.title).width + 24;
ctx.restore();
return Math.min(320, Math.max(120, width));
});
},
getHeight() {
const style = this.getCurrentStyle();
const imageHeight = this.imageLoaded && this.image ? 56 : 0;
const textHeight = typeof style.height === 'number' ? style.height : 48;
return textHeight + imageHeight;
},
draw(ctx) {
const width = this.getWidth();
const height = this.getHeight();
const { left, top } = this.position;
ctx.save();
ctx.fillStyle = this.getCurrentStyle().backgroundColor;
ctx.fillRect(left, top, width, height);
if (this.imageLoaded && this.image) {
ctx.drawImage(this.image, left + 8, top + height - 48, 48, 48);
}
ctx.restore();
}
Only draw an image after checking this.imageLoaded && this.image. Images still use the normal
sanitizer, loader, cache, CORS, and decode settings. Include every value that affects a memoized measurement in
its key. Calling this.getWidth() or this.getHeight() from draw uses the engine's
cached result, keeping drawing, horizontal overlap detection, connector placement, and pointer interaction
aligned.
Star interaction with getIconBox()
getIconBox() defines the rectangular hover/click area for the star interaction. HistropediaJS uses
only the returned left, top, width, and height properties for
pointer interaction. Drawing the icon remains the custom renderer's responsibility; calculate any centre points
or radii needed for drawing from that rectangle.
Return false when stars are disabled or the layout does not provide a star interaction. Omitting the
hook supplies an implementation that returns false. Because the hook may be called during pointer
handling, keep it fast, deterministic, and free of drawing side effects. Keep the returned icon box inside the
card rectangle from getWidth()/getHeight(): HistropediaJS identifies the card first, then
checks its icon box.
interface CardLayoutIconBox {
left: number;
top: number;
width: number;
height: number;
}
getIconBox() {
if (this.owner.options.article.star.visible === false) return false;
const width = 18;
const height = 18;
const left = this.position.left + this.getWidth() - width - 8;
const top = this.position.top + 8;
return { left, top, width, height };
},
draw(ctx) {
const icon = this.getIconBox();
if (icon && (this.isActive || this.isStarred)) {
const centreX = icon.left + icon.width / 2;
const centreY = icon.top + icon.height / 2;
ctx.save();
ctx.fillStyle = this.isStarred ? '#f59e0b' : '#2563eb';
ctx.font = '18px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(this.isStarred ? '★' : '☆', centreX, centreY);
ctx.restore();
}
}
Returning the rectangle does not draw anything. The layout must draw a matching icon, normally when the article
is active or starred. A geometric star renderer can derive outerRadius as
Math.min(icon.width, icon.height) / 2 and innerRadius as
outerRadius / 2. A click inside the box toggles this.isStarred.
How custom layout defaults enter the style cascade
This is the general Article Style cascade used by built-in and custom card layouts. Registering a custom layout does not create a separate precedence system; its registered styles become the card layout defaults in the second layer:
Histropedia defaults → card layout defaults → timeline → lane → article
Each scope is deep-merged over the previous one, so it can change a single nested property without repeating
the rest. Within the timeline and lane scopes, the general style is applied first and the matching
layout-specific layoutStyles[name] values are applied second. The full order for a normal style is:
- Histropedia's base article style;
- the selected card layout's registered
defaultStyle; - timeline
article.defaultStyle; - timeline
article.layoutStyles[name].style; - lane
article.defaultStyle, when the article is in a configured lane; - lane
article.layoutStyles[name].style; - the individual article's
style.
Hover and active overrides follow the same cascade through defaultHoverStyle/hoverStyle
and defaultActiveStyle/activeStyle, then merge over the resolved normal style. When an
article is both active and hovered, hover is applied first and active is applied afterwards, so active wins only
where both states set the same property.
To give a custom layout portrait's dark active subtitle background, include defaultActiveStyle: { subheader: { color: '#333' } } in its registration. This sets the colour for layouts that draw the subtitle background; extending portrait also inherits its rendering hooks.
const timeline = new Timeline(container, {
article: {
layoutStyles: {
compact: {
style: { width: 260, backgroundColor: '#fff7ed' },
hoverStyle: { backgroundColor: '#ffedd5' },
activeStyle: { border: { color: '#ea580c', width: 2 } }
}
}
}
});
A lane override uses the same shape under that lane's article options:
const timeline = new Timeline(container, {
article: { cardLayoutBreakpoints: [] },
lane: {
data: [{
id: 'people',
article: {
defaultCardLayout: 'compact',
layoutStyles: {
compact: {
style: { backgroundColor: '#f5f3ff' },
activeStyle: { border: { color: '#7c3aed' } }
}
}
}
}]
}
});
A late registration can draw in an existing timeline, but that timeline does not automatically receive the
newly registered style defaults. Register first, reconstruct the timeline, or explicitly add matching
article.layoutStyles before selecting the late layout.
Custom style properties
A custom layout can define and read style properties that are not part of HistropediaJS's built-in
ArticleStyle model. Namespace them under an application- or layout-specific object to reduce the
chance of colliding with future library properties:
Timeline.registerCardLayout({
name: 'compact',
draw(ctx) {
const style = this.getCurrentStyle();
const compact = style.custom.compact;
ctx.save();
ctx.fillStyle = compact.accentColor;
ctx.fillRect(
this.position.left,
this.position.top,
compact.accentWidth,
this.getHeight()
);
ctx.restore();
},
defaultStyle: {
width: 220,
height: 48,
custom: {
compact: {
accentColor: '#2563eb',
accentWidth: 4
}
}
}
});
const timeline = new Timeline(container, {
article: {
layoutStyles: {
compact: {
style: {
custom: {
compact: {
accentColor: '#ea580c'
}
}
}
}
}
}
});
Custom properties participate in the same deep merging and state-style precedence as standard properties.
Supply them through registered defaults, timeline or lane article.layoutStyles, and per-article
styles. They have no built-in meaning and are read only by your renderer. Include every custom property that
affects getWidth() or getHeight() in the relevant memoization key.
TypeScript: this is an advanced JavaScript capability. The exported ArticleStyle
type declares only library-supported properties, so arbitrary custom keys require an application-defined
extended style type or a narrow type assertion.
Responsive and per-article selection
Selection precedence is: explicit article cardLayout; first matching timeline
article.cardLayoutBreakpoints rule; lane article.defaultCardLayout; timeline
article.defaultCardLayout; then the built-in portrait fallback when the resolved default is empty.
const timeline = new Timeline(container, {
article: {
defaultCardLayout: 'portrait',
cardLayoutBreakpoints: [
{ maxHeight: 180, layout: 'compact' },
{ maxHeight: 300, layout: 'landscape' }
]
}
});
timeline.load([{ id: 'ada', title: 'Ada Lovelace', from: { year: 1815 }, cardLayout: 'compact' }]);
timeline.getArticleById('ada')?.setCardLayout('compact');
Put narrower breakpoint thresholds first. Register every selected name before it can render. An unknown layout
name warns during lookup and cannot be selected with article.setCardLayout(name); use
Timeline.getCardLayout(name) for an early assertion.
Aliases and built-in names
An alias gives an existing concrete layout another selectable name. Alias definitions contain no drawing hooks or style defaults, and aliases resolve one hop only.
Timeline.registerCardLayout({
name: 'editorial-card',
aliasOf: 'compact'
});
const timeline = new Timeline(container, {
article: {
defaultCardLayout: 'editorial-card',
layoutStyles: {
'editorial-card': {
style: { backgroundColor: '#fdfcf8' }
}
}
}
});
The target must be a directly registered concrete layout. Alias-specific styles belong under the selected alias
name. Changed An alias without registered defaults uses the target's defaults, including landscape dimensions. Unlike an extension, it follows later replacement of the target. HistropediaJS also includes the six presets, plus
default as an alias of portrait.
Caching and runtime updates
article.memo(key, factory) stores derived values in a cache bucket dedicated to the selected layout.
Include every input that changes the result in the key:
const lines = this.memo(
`lines:${this.title}:${font}:${maxWidth}`,
() => calculateWrappedLines(this.title, font, maxWidth)
);
Prefer public setters over direct runtime-field mutation. setStyle(), setOption(), and
setCardLayout() invalidate layout caches. setHoverStyle() and
setActiveStyle() request a redraw; removal with UNSET explicitly invalidates those caches. If either setter changes a
value used by getWidth(), getHeight(), or a memoized calculation, invalidate that article
explicitly. Do the same after changing any external state used by a measurement:
article.setHoverStyle({ width: 240 });
article.invalidateCaches();
timeline.requestRedraw();
Changing a memo key does not by itself invalidate the engine's cached card size. Call
invalidateCaches() before requesting a redraw whenever a measurement input changes outside a setter
that already performs invalidation. Keeping card dimensions the same across normal, hover, and active states also
avoids hit-area and stacking changes during pointer interaction.
TypeScript definition
The package exports CardLayoutDefinition, CardLayoutExtensionDefinition New, RegisteredCardLayout,
CardLayoutIconBox, CardLayoutName, and Article.
import {
Timeline,
type Article,
type CardLayoutDefinition,
type CardLayoutIconBox
} from 'histropediajs';
const compactLayout: CardLayoutDefinition = {
name: 'compact',
getWidth(this: Article): number {
const width = this.getCurrentStyle().width;
return typeof width === 'number' ? width : 220;
},
getHeight(this: Article): number {
const height = this.getCurrentStyle().height;
return typeof height === 'number' ? height : 48;
},
getIconBox(this: Article): CardLayoutIconBox | false {
return false;
},
draw(this: Article, ctx: CanvasRenderingContext2D): void {
const style = this.getCurrentStyle();
ctx.save();
ctx.fillStyle = style.backgroundColor ?? '#fff';
ctx.fillRect(this.position.left, this.position.top, this.getWidth(), this.getHeight());
ctx.restore();
},
defaultStyle: { width: 220, height: 48, backgroundColor: '#fff' }
};
Timeline.registerCardLayout(compactLayout);
CardLayoutName New values preserves autocomplete for the built-ins while accepting custom strings.
Custom layout compatibility
When upgrading to v1.7.0, custom layouts keep their own drawing and sizing functions. Check these details if you rely on inherited styling or use the new automatic sizing options:
- Active subtitle background: the dark default now belongs to portrait. Custom layouts that relied on it must set it explicitly or extend portrait.
- Automatic height: layouts using
height: 'auto'must return a numeric height fromgetHeight(). - Maximum width: with
width: 'auto',maxWidthalso limits widths returned by custom sizing functions. Numeric widths ignore the cap.
See custom layout sizing for details and an example.
Troubleshooting and implementation checklist
- Layout not found: Register the layout before selecting it and check the exact case-sensitive name with
Timeline.getCardLayout(name). Unknown names are not automatically changed to a built-in layout. - Registered style defaults missing: A layout registered after a timeline was created is available to its renderer registry, but its
defaultStyle,defaultHoverStyle, anddefaultActiveStylewere not copied into that existing timeline. Create the timeline after registration, create a new timeline instance, or provide equivalent values throughtimeline.setOption({ article: { layoutStyles: … } }). - Visible and clickable areas differ: Draw from
this.position.left/topand make the rendered card use the same dimensions returned bygetWidth()andgetHeight(). Remember that a canvas stroke is centred on its boundary and may extend beyond the measured rectangle unless it is inset. - Cards overlap: Return finite, non-negative dimensions.
getWidth()controls horizontal overlap and row assignment, while vertical separation is controlled byarticle.autoStacking.rowSpacing. IncreaserowSpacingwhen cards are taller than the configured spacing, and invalidate caches whenever an external sizing input changes. - Hover or active styles are ignored: Read the current merged state with
this.getCurrentStyle()instead of always readingthis.style. Confirm that hover and active overrides are defined under the selected layout name. - Canvas state affects other drawing: The canvas context is shared between renderers. Set every canvas property your renderer relies on and restore any transforms, clipping, shadows, compositing, line-dash settings, or other state it changes. Wrapping the renderer in
ctx.save()andctx.restore()is the simplest way to guarantee isolation. - Star is invisible or unclickable: Drawing and interaction are separate responsibilities. Draw the icon and return its matching rectangular
getIconBox()area, or returnfalsewhen the layout does not support or display a star.
Test normal, hovered, active, active-hovered, dragged, starred, image-loading, responsive, and lane-specific states.
setStyle(...), setOption(...), and
setCardLayout(...) invalidate layout caches. After a measurement-affecting
setHoverStyle(...) or setActiveStyle(...) update, explicitly call
article.invalidateCaches() and request a redraw; do the same when external state affects measurements.
Need More Help?
If you can't find what you're looking for, browse the examples or contact us.