Shopify Builds>Nudestix>Homepage exclusive-offers rail with caption heights equalized in idle time

Homepage Exclusive-Offers Rail With Caption Heights Equalized in Idle Time

A merchandiser-built row of promotional tiles for the Nudestix homepage — grid or swipe rail, chosen separately for desktop and mobile — whose uneven caption blocks are measured in idle time and equalized through a single --content-height custom property.

Dawn 6.0.2 has no promo-tile row, and a row of tiles with three lines of free-length copy each has captions of different heights. We built a Nudestix homepage section whose tiles are merchandiser blocks and whose caption heights are measured in idle time, five tiles at a time, then equalized by writing one --content-height custom property that the stylesheet already consumes. Reads and writes never interleave.

The Problem

Dawn 6.0.2 ships a featured-collection carousel and an image-with-text section. It ships nothing for a row of campaign tiles where each tile is an image, three lines of copy and a link to wherever the offer lives — and nothing where the merchandising team picks a tidy grid for desktop and a peeking swipe rail for mobile, then changes its mind next campaign.

The copy is the hard part. Each caption line is a theme-editor field with no length limit, so one tile says "Free gift" and its neighbour says two sentences about a bundle. Their caption blocks end up different heights, the images above them stop lining up, and a grid that was meant to look like a shelf looks like a spill. A caption's height is decided by its own text; nothing in the stylesheet makes one tile's caption adopt the height of the tallest caption in the row without something first finding out what that height is.

Finding out means measuring in the browser, on a homepage, while the hero above it is still settling. That is the situation a measure-then-write-then-measure loop handles worst.

The Constraint

The theme is Dawn 6.0.2 with a Tailwind utility layer over it, so the section had to be written in that vocabulary: tw- prefixed classes, the theme's settings.page_width, Dawn's component-slider.css for the rail chrome. The section is written against a slider global the layout owns rather than importing its own, so it could not bring its own copy and could not assume a module import.

Everything the merchandising team touches had to be a setting or a block: heading, subheading, a view-all link, section colours, the two layout choices, and per tile an image, three lines of copy and a URL. The tiles had to be in the initial HTML — this row sits inside the first viewport on the homepage and a client-rendered row would arrive after paint.

And the equalization had to be written as a batched pass rather than an interleaved one. Read a height, write a height, read the next is the shape that forces layout on every iteration. The measurement had to yield, batch its reads, and make exactly one write.

What We Built

exclusive-offers.liquid, with its own exclusive-offers.css and Dawn's component-slider.css loaded at the top of the section.

The Liquid side chooses the grid's column classes from section.blocks.size — one, two, three, four, or five and more, each a different set of tw-grid-cols utilities — rather than from a merchandiser setting, so the grid always fits the number of tiles in it. Each tile is an offer_tile block: an image with an explicit srcset ladder from 165 to 1066 pixels (each rung emitted only when the source is at least that wide) and a sizes string computed from settings.page_width, then the three caption lines, each blank-guarded and carrying its full text in a title attribute because the heading lines are clamped to a single line in the grid. The link's aria-label is derived from the last segment of its URL, and a tile with no image gets Shopify's placeholder SVG on a white ground. Desktop and mobile layout are two independent selects, eo_desktop_layout and eo_mobile_layout, and every rule the section emits is namespaced to section.id, so two rails on one page do not share a decision.

An IIFE at the foot of the section owns the runtime. It constructs a Swiper only on narrow viewports — slidesPerView stepping from 1.1 to 3.2 across six breakpoints, so the next tile always peeks — and calls destroy(true, true) when the viewport widens past the desktop cut, so a desktop grid never carries a live slider instance. Pagination (a progress bar) and arrows are each optional, and their Swiper options are emitted into the JavaScript object literal by Liquid only when the setting is on.

Caption equalization is the piece worth the reader's time. updateTileUrlHeights() runs behind a latch and a 50 ms debounce, then hands the work to requestIdleCallback with a 100 ms timeout. Inside the idle slot it walks the tiles in the layout that is in play, five at a time, taking one getBoundingClientRect() per caption and tracking the maximum. If the idle budget is spent before the row is done, it yields and asks for another slot. Only when every tile has been read does applyHeights() run: one loop, one style.setProperty('--content-height', …) per caption, no measurement in between. The stylesheet does the rest — .exclusive-offers--content { height: var(--content-height, auto) } — so the un-equalized state is simply auto, and the equalized state is the same rule with a value.

READ PASS · requestIdleCallbackWRITE PASS · once, after the last read Triggers DOM ready · Swiper init · resize moved > 50px · 500 ms hedge Measure visible tiles, five per pass getBoundingClientRect() → max budget spent? yield, ask for another idle slot Collected caption elements one max height, N elements, nothing written yet Stylesheet height: var(--content-height, auto) applyHeights() setProperty('--content-height', max) × N one write per caption, no read between latch + 50 ms all tiles read consumed by hands off no write during the read pass no inline height: a custom property, consumed by the stylesheet
As we read the code: every caption is measured before any caption is written, the measuring happens in idle slots five tiles at a time, and the only write is a custom property the stylesheet was already listening for.

Around that: a resize handler that does almost nothing. It holds an isResizing latch, waits 150 ms, then compares the new width against the last one it acted on and ignores any move of 50 px or less — which is what stops a mobile browser's address bar collapsing and expanding from re-running everything. Only on a real width change does it clear its element cache, re-decide the Swiper and re-measure. A single delayed re-measure fires 500 ms after load, and beforeunload destroys the Swiper and clears every pending timer.

Why This Way

A custom property rather than an inline height means the JavaScript writes one value and the stylesheet decides what to do with it. The un-equalized state is a valid state — auto — so the row has a layout before the write lands. The write is idempotent, and re-running it after a resize writes the same property to the same elements rather than juggling a second set of inline styles.

Chunked idle-time measurement is what the homepage argued for. Reading five captions per slot and asking for another slot when the budget is spent keeps the reading in units the browser hands out, rather than in one sweep the section takes for itself.

Three things come with that. The equalized height arrives after first paint, not with it, so a caption block can adjust once the write lands; the design accepts that late adjustment as the price of a batched pass. The extra measurement at 500 ms is a hedge against text that reflows after the first pass, and a hedge is an admission that one pass is not always enough. And equalizing to the tallest caption gives every shorter caption empty space beneath its text — a row that looks like a shelf costs some whitespace in the tiles with less to say.

Why Not an App

Promo-tile and banner apps render their tiles client-side from the app's own API, so the tiles arrive after the document does and the row's look is decided in someone else's admin. Here the tiles are in the initial HTML, the images carry a real srcset, and the merchandising team controls layout, colours, copy and links from the theme editor they already use. The one thing an app would have handled for us — equalizing the captions — is the part of this section we most wanted to own, because it is the part that runs in the browser.

Implementation Notes

  • Grid column classes are chosen in Liquid from section.blocks.size — one, two, three, four, and five or more — not from a merchandiser setting, so a four-tile row and a five-tile row lay out correctly without anyone adjusting a select.
  • The two layout selects are independent, and every emitted rule is keyed to section.id, so a second instance of the section on the same page cannot inherit the first one's choice.
  • The Swiper is constructed only on narrow viewports and destroy(true, true)'d when the viewport widens, with slidesPerView values from 1.1 to 3.2 across six breakpoints so a following tile always peeks.
  • Caption equalization writes a --content-height custom property to each caption container; exclusive-offers.css consumes it with height: var(--content-height, auto).
  • Measurement runs through requestIdleCallback with a 100 ms timeout, and falls back to requestAnimationFrame where the API is absent.
  • Resize is triple-guarded: an isResizing latch, a 150 ms timeout, and a 50 px width-delta threshold, and the element cache is cleared only when the width really moved.
  • Pagination and arrows are individually optional; their Swiper options are emitted inside the JavaScript object literal by Liquid, so an unused control is absent from the config rather than disabled in it.
  • The tiles use tw- prefixed Tailwind utilities, which places this section after the Tailwind layer was added over Dawn in this theme.

Edge Cases

  • mobile_hide_shop_all emits a media query that hides the view-all link below 767 px, so a mobile header with no room for the link simply does without it.
  • A tile with no image renders Shopify's placeholder SVG on a white background rather than an empty box.
  • Each of the three caption lines is blank-guarded and carries its full text in a title attribute, so a clamped line can still be read on hover.
  • The view-all link's aria-label is derived from the last path segment of its URL, so a link to /collections/best-sellers announces "Best sellers" even if the visible label is only an arrow.
  • If no tiles are visible when the measurement runs — the layout has no tiles yet, or the selector matched nothing — the function releases its latch and exits without writing anything.
  • A measured maximum of zero writes nothing, so a row whose captions have not yet laid out keeps auto rather than collapsing to a zero height.
  • beforeunload tears down the Swiper instance and clears every pending timer, so nothing fires against a document that is going away.

Platform Primitives Used

  • Liquid section schema and settings — the section's whole merchandising surface: heading, subheading, view-all link, colours, two layout selects, slider controls and the offer_tile block with its image, three copy lines and URL.
  • CSS custom properties--content-height is the one channel between the measurement script and the stylesheet; the script writes it, the stylesheet decides what it means.
  • Responsive images — each tile image carries a six-rung srcset ladder and a sizes string derived from the theme's page width, so the browser picks a source before any script runs.

Integrations in Play

  • Swiper — the slider runtime the section constructs on narrow viewports and destroys on wide ones. The section imports nothing: it is written against a global the theme layout owns, and reads its breakpoint config from an object literal Liquid helps assemble.
  • Tailwind — the tw- prefixed utility layer the theme carries over Dawn; the grid's column classes and most of the tile's layout are Tailwind utilities.

Where It Runs

The homepage, through one instance on index.json — the only template in the theme that mounts the section, and the only place exclusive-offers.css and the section's script are requested. The section is preset-enabled, so a second instance is a theme-editor action rather than a code change, and its rules would be scoped to its own section.id.

What This Demonstrates

How We Know

One section file of roughly 800 lines — schema, markup and the inline script — plus the stylesheet it loads and the homepage template that mounts it, all read from the theme. The template tells us how the section is configured today; the code tells us what it does. Documented from the code; no client-side record of the decision survives. The account of the measurement pass above is our reading of the script, and the figure inherits that grade.

Related Builds

The Buy-vs-Build Question

A row of promo tiles is a standard line item in merchandising apps, and the tile itself is the cheap part of that line item. What an app cannot do is put the tiles in the first HTML response, or hand you the measurement pass to write the way you want it written. Where we draw that line is set out in the merchandising rules decision.

Provenance & Evidence

  • Client: Nudestix — nudestix.com
  • Surface: Home
  • Templates served: 1 — index.json
  • Complexity: High — roughly 800 lines across schema, markup and script, plus a stylesheet
  • Attribution: Deploi-authored. The section, its stylesheet and its script are ours. They sit in a theme built on Dawn 6.0.2 with a Tailwind utility layer, and the slider runtime is Swiper, a global the theme layout owns.
  • Status: Live, verified 2026-09-07
  • Evidence: One section file, one stylesheet and the homepage template, read from the theme; no client working record
  • Confidence: Moderate — documented from the code; no client-side record of the decision survives
  • Primary capability: Collection merchandising: rails, tiles and in-grid promotion

Ready for a Promo Row Your Merchandisers Own?

If your homepage tiles are rendered by an app after the page arrives, or equalized by a script that measures as it writes, there is another way to do both. Contact us today and we'll show you what a merchandiser-owned tile row looks like when the reads and the writes are kept apart.

More builds