Masonry
stimeo--masonry
Packs variable-height cards into the shortest column while keeping DOM order.
The stimeo--masonry controller has no dedicated APG pattern; it is a layout-only helper. It derives a responsive column count from the container width and min-column-width and assigns each item to the currently shortest column, exposing the count on --stimeo-masonry-columns and each item's column index on data-column. A ResizeObserver and a MutationObserver re-run the layout on resize and on item add/remove. Crucially, the DOM order is never changed, so reading order and focus order stay the source order (WCAG 1.3.2) — use it only for independent cards whose visual order carries no meaning. Here the demo feeds --stimeo-masonry-columns into a CSS multi-column layout. Behavior only — the look is yours.
Quick note
A short card.
Changelog
A taller card whose extra paragraphs make the column heights uneven, so the shortest-column packing is easy to see.
A taller card whose extra paragraphs make the column heights uneven, so the shortest-column packing is easy to see.
Release notes
A medium card with a couple of lines of supporting copy to vary the height.
Quick note
A short card.
Changelog
A taller card whose extra paragraphs make the column heights uneven, so the shortest-column packing is easy to see.
A medium card with a couple of lines of supporting copy to vary the height.
Release notes
A medium card with a couple of lines of supporting copy to vary the height.
<%# Markup for the masonry (tiled grid) demo.
The library computes the column count from the container width and min-column-width,
and updates --stimeo-masonry-columns and each item's data-column. The look (CSS
multi-column) is the consumer's CSS; reading/focus order stays in DOM order. %>
<div
class="masonry-demo"
data-controller="stimeo--masonry"
data-stimeo--masonry-min-column-width-value="180"
data-stimeo--masonry-gap-value="12">
<div class="masonry-demo__item" data-stimeo--masonry-target="item">
<h3 class="masonry-demo__title"><%= t("components.masonry.demo.cards.short.title") %></h3>
<p><%= t("components.masonry.demo.cards.short.body") %></p>
</div>
<div class="masonry-demo__item" data-stimeo--masonry-target="item">
<h3 class="masonry-demo__title"><%= t("components.masonry.demo.cards.tall.title") %></h3>
<p><%= t("components.masonry.demo.cards.tall.body") %></p>
<p><%= t("components.masonry.demo.cards.tall.body") %></p>
</div>
<div class="masonry-demo__item" data-stimeo--masonry-target="item">
<h3 class="masonry-demo__title"><%= t("components.masonry.demo.cards.medium.title") %></h3>
<p><%= t("components.masonry.demo.cards.medium.body") %></p>
</div>
<div class="masonry-demo__item" data-stimeo--masonry-target="item">
<h3 class="masonry-demo__title"><%= t("components.masonry.demo.cards.short.title") %></h3>
<p><%= t("components.masonry.demo.cards.short.body") %></p>
</div>
<div class="masonry-demo__item" data-stimeo--masonry-target="item">
<h3 class="masonry-demo__title"><%= t("components.masonry.demo.cards.tall.title") %></h3>
<p><%= t("components.masonry.demo.cards.tall.body") %></p>
<p><%= t("components.masonry.demo.cards.medium.body") %></p>
</div>
<div class="masonry-demo__item" data-stimeo--masonry-target="item">
<h3 class="masonry-demo__title"><%= t("components.masonry.demo.cards.medium.title") %></h3>
<p><%= t("components.masonry.demo.cards.medium.body") %></p>
</div>
</div>
<p
class="masonry-demo__status"
data-masonry-status
data-template="<%= t("components.masonry.demo.status_template") %>"
aria-live="polite"></p>
/*
* Presentation-only styles for the masonry demo.
* Feed the library's --stimeo-masonry-columns into CSS multi-column (columns) to pack
* cards of varying heights with no gaps. Column splitting is CSS; reading order stays
* in DOM order.
*/
.masonry-demo {
columns: var(--stimeo-masonry-columns, 1);
column-gap: 12px;
}
.masonry-demo__item {
/* Keep cards from breaking across columns in the multi-column layout. */
break-inside: avoid;
margin: 0 0 12px;
padding: 0.85rem 1rem;
border: 1px solid var(--border-strong);
border-radius: 0.5rem;
background: var(--surface, var(--surface-card));
}
.masonry-demo__title {
margin: 0 0 0.4rem;
font-size: 0.95rem;
font-weight: 600;
color: var(--fg, var(--color-text));
}
.masonry-demo__item p {
margin: 0 0 0.4rem;
font-size: 0.85rem;
line-height: 1.5;
color: var(--color-text-muted);
}
.masonry-demo__status {
margin: 0.75rem 0 0;
font-size: 0.8rem;
color: var(--color-text-muted);
}
// Demo that consumes masonry's state hooks (consumer-side JS).
//
// The core controller (stimeo--masonry) computes the column count, updates
// --stimeo-masonry-columns and each item's data-column, and fires
// stimeo--masonry:layout on every relayout. Here we only subscribe to that event and
// show the current column count in a live region. The layout itself is CSS (columns).
//
// For the bilingual catalog the copy isn't hardcoded: JS substitutes the number into
// the localized template the ERB passes (the "{count}" token in data-template).
document.querySelectorAll('[data-controller~="stimeo--masonry"]').forEach((masonry) => {
const status = masonry.parentElement?.querySelector('[data-masonry-status]');
if (!status) return;
const template = status.dataset.template || '{count}';
const render = (columns) => {
status.textContent = template.replace('{count}', String(columns));
};
masonry.addEventListener('stimeo--masonry:layout', (event) => {
render(event.detail.columns);
});
// Reflect the initial state right after connect (read the current value from the CSS variable).
const initial = getComputedStyle(masonry).getPropertyValue('--stimeo-masonry-columns').trim();
if (initial) render(Number(initial));
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--masonry"
Targets
| Name | Description | Attribute |
|---|---|---|
item
required
|
A card placed into the shortest column via a data-column index (DOM order is never changed). |
data-stimeo--masonry-target="item" |
Values
| Name | Description | Attribute |
|---|---|---|
minColumnWidth
|
The minimum column width in px used to derive the column count (default 240). | data-stimeo--masonry-min-column-width-value |
gap
|
The gap in px between items, factored into column count and height packing (default 16). | data-stimeo--masonry-gap-value |
Events
| Name | Description | Event |
|---|---|---|
layout
|
Dispatched when the column count changes; detail carries the column count. | stimeo--masonry:layout |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
--stimeo-masonry-columns |
Container | The current column count, consumed by the demo's CSS columns. |
data-column |
Item | The column index the item was assigned to. |