Custom CardLayouts

Take control of how article cards are measured and drawn while retaining timeline layout, interaction, state styling, and responsive selection.

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's subtitle field 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 grey backgroundColor: '#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. Its width: 'auto' fits the text and visible thumbnail up to maxWidth: 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.

JavaScript
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.

JavaScript
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 registration

Register 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 using extends), called with the current Article as this.
  • getWidth() and getHeight() – 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 to false.
  • defaultStyle, defaultHoverStyle, and defaultActiveStyle – optional registered style defaults.
  • extends New – 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 lookup

Return 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:

  1. Histropedia's base article style;
  2. the selected card layout's registered defaultStyle;
  3. timeline article.defaultStyle;
  4. timeline article.layoutStyles[name].style;
  5. lane article.defaultStyle, when the article is in a configured lane;
  6. lane article.layoutStyles[name].style;
  7. 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 from getHeight().
  • Maximum width: with width: 'auto', maxWidth also 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, and defaultActiveStyle were not copied into that existing timeline. Create the timeline after registration, create a new timeline instance, or provide equivalent values through timeline.setOption({ article: { layoutStyles: … } }).
  • Visible and clickable areas differ: Draw from this.position.left/top and make the rendered card use the same dimensions returned by getWidth() and getHeight(). 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 by article.autoStacking.rowSpacing. Increase rowSpacing when 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 reading this.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() and ctx.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 return false when the layout does not support or display a star.

Test normal, hovered, active, active-hovered, dragged, starred, image-loading, responsive, and lane-specific states.

Runtime updates. Prefer public setters over direct field mutation. 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.