Shoji

Layout plugin

Turns the inline gallery container into a fully managed, config-driven grid — no extra markup, no external masonry library. The plugin renders its own tiles from each item's aspect ratio and clears whatever was in the container beforehand. Three types:

new Shoji('#gallery', {
  plugins: [Shoji.Layout],
  layout: { type: 'justified' }, // default type if `layout` is omitted entirely too
});

Every tile's <img> renders from item.thumb, not item.src — falling back to item.poster (video items), then item.src only as a last resort. If you serve separate thumbnail and full-resolution files (see the Getting started guide's field reference), the grid only ever downloads the thumbnails — the full-resolution src is fetched later, just for whichever slide the lightbox actually opens.

Grid

Plain CSS grid — every cell shares one uniform aspect-ratio, cropped via object-fit: cover. Cheapest option, no JS positioning math, no ResizeObserver. Deliberately not each photo's own aspect ratio: native CSS grid sizes a row's height to its tallest cell, so per-item ratios would leave a blank gap under shorter tiles sharing a row with a taller one.

layout: { type: 'grid', columnWidth: 220, aspectRatio: 1 }

Masonry

Preserves each photo's true aspect ratio via JS packing. Two orientations, both grow the page only vertically — neither ever needs horizontal scroll.

orientation: 'vertical' (default)

Fixed-width columns, tiles stack top-to-bottom, ragged bottom edge — the classic Pinterest layout. The only layout mode with true incremental append: appending items continues packing from each column's saved height instead of relaying out everything.

layout: {
  type: 'masonry',
  columns: 'auto',       // or a fixed number
  columnWidth: 240,      // target width used when columns: 'auto'
  minColumnWidth: 160,   // clamps how far 'auto' can drift at odd container widths
  maxColumnWidth: 480,
  fill: 'shortest',      // or 'ordered' — round-robin, preserves reading order at slight tightness cost
}

orientation: 'horizontal'

The masonry-family transpose: fixed-height rows, tiles pack left-to-right at their own true aspect ratio. The moment the next tile would push a row past the container's width, that tile starts a new row instead of the finished row being stretched to close the gap — ragged right edge. Grows the page downward exactly like vertical masonry grows it taller; it measures the container's width and computes its height, same shape as every other layout type, and never scrolls sideways.

layout: {
  type: 'masonry',
  orientation: 'horizontal',
  rowHeight: 220, // exact, fixed row height — never scaled or clamped
}
fill, minColumnWidth/maxColumnWidth, and incremental append don't apply to horizontal masonry — placement is sequential, not bucket-assignment, and a row isn't "committed" until a tile overflows it (an appended tile can extend what was the previous render's trailing row), so it always does a full relayout on any items change, same as justified below.

Justified

The Google Photos / Flickr row-packed layout — the default if type is omitted. Greedy row filler: tiles accumulate until the next one would exceed the container width at ~rowHeight, then the whole row is rescaled so its total width lands exactly on the container width. Row height is the free, solved-for variable (hovers near but rarely exactly at rowHeight); width is the forced constraint — every row's right edge flush with the container, the opposite tradeoff from horizontal masonry above.

layout: {
  type: 'justified',
  rowHeight: 220,       // target — the actual solved height lands close to, not exactly at, this
  minRowHeight: 120,    // clamps how far a row's solved height can drift
  maxRowHeight: 360,
  lastRow: 'justify',   // 'justify' (stretch like any row) | 'left' (natural size, no stretch) | 'hide'
}

Shared options

Option Default Applies to
gutter 8 (or { x, y }) all types
animate true all types — FLIP-animated relayout, off under prefers-reduced-motion
autoMeasure true masonry, justified — self-corrects a missing item.width/height once the tile's own <img> loads, using a 4:3 placeholder in the meantime so the first pass is never blocked on an image load
breakpoints — Record<maxWidth, Partial<LayoutOptions>> — the narrowest key still ≥ the container's current width wins, re-evaluated on every relayout. Cannot override type or orientation (a bigger structural change than a sizing tweak — use a media query + two Shoji instances for that).

Sections (groupBy)

Derives a section key per item (e.g. a formatted date) — a heading is inserted whenever the key changes between consecutive items, real "Today / Yesterday / March 2024" sectioning.

layout: {
  type: 'justified',
  groupBy: (item) => item.data.section,
  renderHeading: (key) => ({ title: key, subtitle: 'Vancouver, BC' }),
  headingOverflow: 'fit', // drops the subtitle, then breaks onto a fresh row, if a label doesn't fit
}

grid and masonry treat a heading as a full-width blocking element — each section gets its own fresh packing pass, no two sections share visual space. justified is the deliberate exception, matching real Google Photos behavior: sections never force a row break — a section too small to reach a full row's width shares that row with whatever comes next, instead of being stretched into its own near-empty row. Not supported with masonry's orientation: 'horizontal' (warns, ignored).

headingOverflow (justified + the {title, subtitle} form only)

Controls what happens when a compact label doesn't fit its available width — the gap until the next section's label sharing its row, or the container edge.

Value Behavior
'show' (default) Always renders the full title + subtitle, one line, however wide — a label can visually run past the next label or the container edge. Simple and predictable; nothing is ever dropped or reflowed.
'fit' Never wraps a label's text. Drops the subtitle first if title+subtitle together don't fit; if the title alone still doesn't fit, that section is pushed to start a fresh row instead — row composition responds to label width, not just content. A single label wider than the entire container is ellipsis-truncated as a last resort.
'wrap' The in-between, matching real Google Photos date headers: same continuous row-packing as 'show' (no section is ever pushed to a fresh row), but a label that doesn't fit wraps its own text onto extra lines instead. Capped at maxRowHeight (default 360) — a label tall enough to hit that cap ellipsis-truncates its last visible line rather than growing the row further.

Examples

// Tight masonry — flush tiles, no ragged gaps
layout: { type: 'masonry', gutter: 0 }

// Grid with ~180px square crops (native CSS grid auto-fill — no fixed column count option)
layout: { type: 'grid', columnWidth: 180, aspectRatio: 1 }

// Justified with a trailing partial row left un-stretched
layout: { type: 'justified', lastRow: 'left' }