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.
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 }
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.
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'
}
| 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).
|
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.
|
// 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' }