Skip to main content
This document specifies how a merchandised Depict collection page is laid out: how content blocks, products, product duplicates and image settings combine into a grid. It is written so that it can be handed to a developer, or to an AI coding agent, to produce a conforming implementation in any UI framework (SwiftUI, Jetpack Compose, React Native, Flutter, a web framework, or anything else). The integration guide specifies how to fetch the input data; this document specifies what to do with it. The algorithms below are extracted from the renderer that Depict ships to web storefronts and produce identical layouts.

1. Conformance

The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT, MAY and OPTIONAL in this document are to be interpreted as described in RFC 2119. Algorithms are written as sequences of numbered steps. Conforming implementations MAY use any equivalent formulation, as long as the observable result is the same. All arithmetic in this document is exact integer arithmetic unless a step says otherwise; “x mod y” is the remainder of floor division and is always non-negative for the non-negative operands used here; “floor(x / y)” is floor division.

2. Terminology

column count (C): The number of grid columns being rendered. An integer, at least 1. authored column count (Ca): The merchant’s configured desktop column count (the first entry of the shop’s column_settings metafield, default 4). Block indexes are authored against a grid of Ca columns. Ca is a required input even when rendering fewer columns. slot: One cell of the conceptual grid. Slots are numbered 0, 1, 2, … in reading order: left to right within a row, then the next row. The slot with index i is in row floor(i / C), column (i mod C). viewport class: Either "desktop" or "mobile". The web renderer uses "mobile" when the viewport is at most 990 CSS pixels wide, "desktop" otherwise. A phone app SHOULD use "mobile"; a tablet layout MAY use either, but MUST use the matching column count. product list (P): The ordered list of products to display, after market resolution (see the integration guide). Each entry is a product. block: A content block: placement fields index (integer, at least 0), span_columns (integer, at least 1), span_rows (integer, at least 1), aspect_ratio (decimal, width divided by height), visibility ("desktop", "mobile", or unset), plus content fields (media, text, link). The block list (B) preserves the order of the metafield’s reference list. duplicate: A product duplicate entry: product (a product reference), index (integer or null), created_at (integer, Unix milliseconds, or null), and its own default_image and optional hover_image. The duplicate list (D) preserves the order of the metafield’s reference list. item: A member of the composed sequence: a product, a duplicate, or a block. Products and duplicates occupy exactly 1 slot. A block occupies a rectangle of span_columns by span_rows slots. set (of a field): A field is set when it is present with a non-empty, non-null value. Unset fields read as empty strings (the Depict app writes empty strings to clear previous values) or as null or absent on blocks created before a field was added to the definition. Implementations MUST treat empty string, null and absent identically. merchandised view: The collection displayed in its published default order, without user-applied sorting or filtering. An in-stock availability filter does not disqualify a view from being merchandised. Inputs that violate the stated ranges MUST be repaired before running the algorithms: clamp index below 0 up to 0, and clamp span_columns or span_rows below 1 up to 1.

3. Inputs

A renderer needs, for one collection and one market:
  1. The product list P (market-resolved order).
  2. The block list B (market-resolved).
  3. The duplicate list D (market-resolved).
  4. The image setting list (market-resolved).
  5. The column count C for the current viewport class. C MUST come from the shop’s column_settings metafield when present ([desktop, mobile]), with defaults 4 for desktop and 2 for mobile.
  6. The authored column count Ca (the desktop entry of the same metafield), needed by the remapping algorithm whenever C differs from Ca.
  7. The current viewport class.
  8. Whether the current view is a merchandised view.

4. Rendering pipeline

To render the grid, run these algorithms in order:
  1. Applicability determines whether blocks and duplicates apply at all.
  2. Remap authored indexes re-anchors block indexes when C differs from Ca.
  3. Prepare the block list clamps spans and filters by visibility.
  4. Resolve block placement assigns every block a final, collision-free slot.
  5. Insert the duplicates merges duplicates into the product list, producing the item list L.
  6. Compose the sequence interleaves blocks with L into one ordered sequence with slot assignments.
  7. Lay out and size maps the sequence onto rows and determines heights.
  8. Present each block and product card.

5. Applicability

To determine applicability:
  1. If the current view is a merchandised view, blocks and duplicates apply.
  2. Otherwise (the user changed the sort order, or applied any filter other than an in-stock availability filter), blocks and duplicates do NOT apply: the renderer MUST proceed with B and D treated as empty.
  3. Image settings apply in every view, including sorted and filtered views, with one exception: while the collection runs a Depict A/B test, web sessions assigned to the control variant hide blocks, duplicates AND image settings. This document assumes no active test; the integration guide describes the test metafield and the tradeoffs of ignoring tests in a native app.
This matches the web storefront, where position-based features are hidden as soon as the underlying list is no longer the published order.

6. Remapping authored indexes

Block indexes are slot numbers on the authored (desktop) grid. A wide block occupies a different number of slots on a narrower grid, which would shift every later block relative to the products around it. To keep blocks anchored to the same products on every device, the renderer replays the authored layout to find how many products precede each block, then re-places the blocks after the same number of products on the shown grid. If C equals Ca, this algorithm MUST be skipped: the block indexes are used as authored. Otherwise, run the two sub-algorithms below on the entire block list B, before the visibility filter. This is intentional and matches production: a block hidden on the current viewport class still shifts the anchors of the blocks after it.

6.1 Counting products before each block

To count products before each block, given B (in list order) and Ca:
  1. Group the blocks by max(index, 0), preserving list order within each group.
  2. Let covered be an empty set of slots, products be 0, and anchors be an empty map from blocks to integers.
  3. For slot = 0, 1, 2, … until every block has an anchor:
    1. If one or more blocks are grouped at this slot: let start be slot; for each such block b in group order: let span be min(b.span_columns, Ca); add every cell start + r*Ca + c (for 0 <= r < b.span_rows, 0 <= c < span) to covered; set anchors[b] to products; set start to start + span.
    2. Otherwise, if slot is not in covered, increment products.
The cursor advances one slot per iteration, so blocks authored inside another block’s span are still visited. Covered slots never count as products. This phase replays the layout exactly as authored: it applies no row-boundary or collision correction.

6.2 Re-placing the blocks

To re-place the blocks, given the anchors and C:
  1. Stable-sort the blocks ascending by (anchors[b], b.index).
  2. Let covered be an empty set of slots, slot be 0, and products be 0.
  3. For each block b in sorted order:
    1. Let span be min(b.span_columns, C).
    2. Loop: if slot is in covered, increment slot; otherwise, if products < anchors[b], or (slot mod C) + span > C, increment products and increment slot (these slots will hold products); otherwise exit the loop.
    3. Add every cell slot + r*C + c (for 1 <= r < b.span_rows, 0 <= c < span) to covered. (Only the continuation rows; the first row is passed over by advancing slot in the next step.)
    4. Set b.index to slot, then set slot to slot + span.
  4. Return the blocks in their original list order with the updated indexes.
The re-placed indexes can still collide or overlap in edge cases; the placement algorithm resolves that, as it does on the authored grid.

7. Preparing the block list

To prepare the block list, given the (possibly remapped) block list, viewport class V, and column count C:
  1. Let prepared be an empty list.
  2. For each block b, in list order:
    1. Set b.span_columns to min(b.span_columns, C).
    2. If b.visibility is set and not equal to V, skip b. (A value that is not exactly "desktop" or "mobile" therefore hides the block on every viewport class.)
    3. Append b to prepared.
  3. Return prepared.

8. Resolving block placement

Blocks request a slot through index, but requests can collide or fall where the block does not fit. This algorithm assigns each block a final slot such that no two blocks overlap and no block crosses a row boundary. To resolve block placement, given the prepared block list and C:
  1. Let occupied be an empty set of slot indices.
  2. For each block b, in list order:
    1. Let s be b.index.
    2. Loop:
      1. Let columnStart be s mod C.
      2. If columnStart + b.span_columns is greater than C, set s to s + 1 and continue this loop. (The block would cross a row boundary.)
      3. If any slot of b’s candidate rectangle, that is any s + r*C + c for 0 <= r < b.span_rows and 0 <= c < b.span_columns, is in occupied, set s to s + 1 and continue this loop.
      4. Exit the loop.
    3. Add every slot of the rectangle (s + r*C + c for 0 <= r < b.span_rows, 0 <= c < b.span_columns) to occupied.
    4. Set b’s final index to s.
Consequences worth noting:
  • Blocks are processed in list order, so when two blocks request the same slot, the earlier block in the list wins it and the later block is pushed rightward and possibly to following rows.
  • Final indices are unique, and every block’s rectangle is disjoint from every other block’s rectangle, by construction.
  • A block’s final index can be far beyond the end of the product list; such blocks are handled by the trailing rule in Composing the sequence.

9. Duplicates

Duplicates are inserted into the product list before blocks are interleaved. The result is the item list L in which each duplicate is a 1-slot item, tagged as a duplicate and remembering its source entry.

9.1 Ordering the duplicates

To order the duplicates, given D and the product list P:
  1. For each duplicate d in D, compute its sort key:
    1. If d.index is not null, the key is d.index. (An index of 0 is a valid key.)
    2. Otherwise, if d.product is in P, the key is the 0-based position of d.product in P.
    3. Otherwise the key is positive infinity.
  2. Stable-sort D ascending by key. Call the result sortedD.

9.2 Inserting the duplicates

To insert the duplicates, given sortedD and P:
  1. Let L be a copy of P.
  2. For each duplicate d in sortedD, in order:
    1. If d.index is not null, insert d into L at position min(d.index, length of L). (List splice semantics: existing entries at that position and after shift right by one.)
    2. Otherwise:
      1. Let p be the position in L of the first entry that is d’s original product (not a duplicate). If there is no such entry, skip d.
      2. Let related be the sublist of sortedD whose entries reference the same product as d.
      3. Stable-sort related ascending by created_at, treating null as positive infinity.
      4. Let rank be the 0-based position of d in the sorted related list.
      5. Insert d into L at position p + 1 + rank.
  3. Return L.
Note that insertions are sequential: each insertion shifts the positions seen by later insertions. The duplicate test vector exercises this.
The web renderer keeps a slot reserved for an explicit-index duplicate even when its original product is absent from the list, but cannot render a card for it. Implementations of this specification MUST instead skip any duplicate whose original product is not in P, regardless of whether its index is set. Merchandisers do not create such duplicates intentionally; they can only arise when a product leaves a collection after publishing.

10. Composing the sequence

This algorithm interleaves the placed blocks with the item list L, producing the final ordered sequence and a slot assignment for every item. To compose the sequence, given C, the placed blocks (each with a final index), and L:
  1. Let sequence be an empty list.
  2. Let slotOf be an empty map from items to slot indices.
  3. Let blockAt be a map from final index to block, for every placed block.
  4. Let pending be an empty map from slot index to integer. (It records where the continuation rows of multi-row blocks resume.)
  5. Let queue be L as a first-in-first-out queue.
  6. Let s be 0.
  7. While queue is not empty:
    1. If pending contains s: let k be pending[s], remove s from pending, set s to s + k, and return to step 7. (These k slots are covered by an earlier block’s continuation row.)
    2. Otherwise, if blockAt contains s: let b be blockAt[s], remove s from blockAt, append b to sequence, set slotOf[b] to s, then for each integer r from 1 up to and including b.span_rows - 1 set pending[s + r * C] to b.span_columns, then set s to s + b.span_columns, and return to step 7.
    3. Otherwise: dequeue an item i from queue, append i to sequence, set slotOf[i] to s, and set s to s + 1.
  8. (Trailing blocks.) For each block b remaining in blockAt, in block list order (the order of the prepared block list; b’s final index is disregarded from here on):
    1. Advance s to the smallest value s’ ≥ s such that (s’ mod C) + b.span_columns ≤ C and no slot of the rectangle (s' + r*C + c for 0 <= r < b.span_rows, 0 <= c < b.span_columns) is covered by an already placed block.
    2. Append b to sequence, set slotOf[b] to s’, mark its rectangle covered, and set s to s’ + b.span_columns.
  9. Return sequence and slotOf.
The pending map is essential. Without it, a configuration such as a 2 by 2 block followed by items would skip slots that belong to the row below the block and misplace everything after it; see test vector 2. Step 8 makes blocks that were never reached while items remained appear after all products, in the order the merchandiser’s block list gives them. This is how a banner is put at the very end of a collection, and it also cleanly handles small collections whose product count is lower than a block’s index.
The web renderer implements step 8 through its page machinery and CSS dense grid packing, which in degenerate configurations (for example trailing blocks combined with multi-row blocks near the end of the list) can order or pack trailing blocks differently. Implementations MUST follow step 8 as written; it matches the web renderer in every configuration merchandisers produce in practice.

11. Layout and sizing

To lay out the sequence:
  1. The grid MUST have exactly C columns of equal width, with the merchant’s configured column gap and row gap (see grid_settings in the integration guide) or the implementation’s own spacing.
  2. Each item MUST occupy the slots assigned by Composing the sequence: products and duplicates one slot each, block b the rectangle of b.span_columns by b.span_rows slots whose top left is slotOf[b]. Blocks always occupy their configured span_rows rows.
  3. A row that contains products, duplicates, or parts of tile-sized blocks MUST derive its height from the product cards, and all such rows SHOULD share a uniform minimum height so that multi-row blocks align with the product rows they span. (The web renderer uses the measured height of the first product card as this minimum.)
  4. Native-ratio blocks. A block is rendered at its media’s native aspect ratio if and only if it is full-width (b.span_columns ≥ C) AND b.aspect_ratio ≥ 1 AND b.span_rows equals 1. For such a block:
    1. Its rendered height MUST be its rendered width divided by b.aspect_ratio.
    2. The row it occupies MUST size itself to that height instead of the product-derived minimum.
  5. Tile-sized blocks. Every other block, including full-width blocks with aspect_ratio below 1 (portrait) or span_rows above 1, MUST fill its entire spanned rectangle; its media is cropped to cover the area (center crop). aspect_ratio is not used for sizing these blocks.
  6. Spacer blocks. If a block’s space content is present with a non-null height H, the block’s rendered height MUST be H pixels (overriding rules 4 and 5 for height); with a null height it follows the normal sizing rules. Spacers render no media.
When the web renderer cannot measure any product card (for example a collection with no products), it cannot size tiles, so every full-width block falls back to its native aspect ratio rather than becoming invisible. A native implementation whose product cards have intrinsic sizes does not need this fallback, but MAY reproduce it.

12. Block presentation

  1. Media selection. If the block has a video, render the video and ignore image. Otherwise, if it has an image, render the image. Otherwise, if it has space content, render an empty spacer.
  2. Video MUST be rendered muted, looping, inline (no fullscreen takeover, no controls), using the block’s preview image as poster, and SHOULD play only while visible. Prefer the HLS (m3u8) source on mobile devices and the largest-width MP4 source on desktop.
  3. Hover image. When hover_image is set: on pointer devices, show it in place of the main image while hovered. On touch devices, the web renderer presents the two images as horizontally swipeable frames with the main image first; native apps SHOULD do the same or an equivalent two-frame affordance, and MAY show only the main image.
  4. Text overlay. When text is present, render its header and body (each only when its text value is non-empty) stacked vertically over the media, in that order:
    1. Horizontal and vertical alignment come from horizontal_alignment and vertical_alignment; both default to center. The overlay area has a 16 pixel inset from the block edges and the two text parts are separated by gap (default 0).
    2. Each text part uses color_hex (default white) and its bold, italic and underline flags. html_tag conveys the semantic level (h1, h2, h3, p); native implementations SHOULD map it to an equivalent type scale.
    3. When background_overlay is set, render it between the media and the text: as a gradient of that color fading to transparent when overlay_style is exactly "gradient", and covering the whole block otherwise, including when overlay_style is absent. The gradient anchors to the text’s vertical alignment: from the top edge when vertical alignment is start, from the bottom edge when end, and as a centered horizontal band when center.
    4. When text_shadow is true, render a soft dark drop shadow behind the text.
  5. Instagram attribution. When instagram_post_metadata is present, render a small attribution element in the bottom right corner of the block showing the creator name, linking to post_url.
  6. Link. At most one link field is set. If, due to stale data, more than one is set, use the first in this order: link_to_collection, link_to_page, link_to_product, blog article (blog_handle with blog_article_id), external_url. The whole block is the tap target. Blog article links resolve by finding the article whose ID matches blog_article_id within the blog named by blog_handle.
  7. Alt text. Use alt_text as the image’s accessibility label when present.

13. Product presentation

  1. Image settings. For every product in the grid that has an image setting in this collection, the card’s primary image MUST be the setting’s default_image, and, if the card design has a hover or second image and the setting has a hover_image, that slot MUST use it. All other card content (title, price, link) is unchanged. Image settings apply only within the collection whose metafield references them.
  2. Duplicates. A duplicate renders the same card as its original product, except that its imagery MUST come from the duplicate’s own default_image and optional hover_image.
  3. Interaction. Image settings MUST NOT be applied to duplicates. If a product has both an image setting and a duplicate, the original card shows the image setting’s imagery and the duplicate card shows the duplicate’s imagery.

14. Pagination

Native apps and headless storefronts SHOULD render a collection as a single sequence (page size of infinity), which is what the algorithms above produce, and paginate by loading products incrementally. An implementation that reproduces the web storefront’s numbered pages MUST split the composed sequence as follows: walk the sequence in order, counting only original products (not duplicates, not blocks); a new page starts each time the count reaches a multiple of the page size; every item, duplicate and block belongs to the page that was current when it was placed; blocks placed by the trailing rule belong to the last page.

15. Edge case summary

16. Test vectors

Each vector gives the exact inputs and the required outputs: the composed sequence, each item’s slot, and the visible grid as a matrix (rows of C cells; . is an empty cell; a block’s ID fills every cell it covers). A conforming implementation MUST reproduce these outputs. All vectors assume a merchandised view. Vectors 1 through 3 render at the authored column count (C = Ca = 4), so the remapping algorithm is an identity for them; vectors 4 and 5 also exercise C = 2 with Ca = 4.

Vector 1: basic interleaving

Input:
Required output:
  • Final index of B1: 2.
  • Sequence: p1, p2, B1, p3, p4, p5, p6, p7, p8
  • Slots: p1=0, p2=1, B1=2 (covers 2 and 3), p3=4, p4=5, p5=6, p6=7, p7=8, p8=9.

Vector 2: a 2 by 2 block and a pushed block

Two blocks, where the second requests a slot inside the first block’s area and is pushed, and where the first block’s second row must not swallow the slots of the items beside it. Input:
Required output:
  • Final indices: B1 = 0 (covers 0, 1, 4, 5), B2 = 2 (slot 1 is occupied by B1, so B2 is pushed to 2).
  • Sequence: B1, B2, p1, p2, p3, p4, p5, p6
  • Slots: B1=0, B2=2, p1=3, p2=6, p3=7, p4=8, p5=9, p6=10. (After p1 at slot 3, the composer reaches slot 4, finds B1’s continuation row, and skips to slot 6.)
B1 spans two rows and is not full-width, so it is tile-sized (its 0.8 aspect ratio is not used for sizing).

Vector 3: duplicates

No blocks; three duplicates of two products, exercising explicit index, index-null ordering by created_at, and sequential insertion shifts. Input:
Required output:
  • Sort keys: d1 = 1 (B’s position), d2 = 1, d3 = 0. Sorted order (stable): d3, d1, d2.
  • Insertions, in order:
    1. d3 (index 0): [d3, A, B, C, D]
    2. d1 (null): B is at position 2; related duplicates of B sorted by created_at are [d2, d1], d1’s rank is 1; insert at 2 + 1 + 1 = 4: [d3, A, B, C, d1, D]
    3. d2 (null): B is at position 2; d2’s rank is 0; insert at 2 + 1 + 0 = 3: [d3, A, B, d2, C, d1, D]
  • Sequence: d3, A, B, d2, C, d1, D with slots 0 through 6.
d3 renders product D’s card with d3’s imagery; d1 and d2 render product B’s card with their own imagery.

Vector 4: visibility, clamping and trailing blocks

The same input rendered at both viewport classes. B1 is desktop-only and wider than the mobile grid; both blocks land beyond the products. Input:
Required output, viewport class "desktop" (C = Ca = 4, remap is an identity):
  • Both blocks are visible. Placement in list order: B1 requests 5; columnStart 1 with span 4 does not fit, nor do 6 or 7; slot 8 has columnStart 0 and is free; B1’s final index is 8 (covers 8 through 11). B2 requests 3; columnStart 3 with span 2 does not fit; slot 4 is free; final index 4 (covers 4 and 5).
  • Products fill slots 0 through 3, then the queue is empty, so both blocks are trailing. In block list order, B1 is appended first: the cursor is at slot 4, which fits a span of 4, so B1 lands at slots 4 through 7. B2 follows at slots 8 and 9. Their final indexes (8 and 4) are disregarded by the trailing rule.
  • Sequence: p1, p2, p3, p4, B1, B2. B1 is full-width with aspect_ratio 2.0 and one row, so it renders as a natural-ratio band (height = width / 2.0). B2 spans 2 of 4 columns and is tile-sized.
Required output, viewport class "mobile" (C = 2, Ca = 4):
  • Remapping, counting phase on the authored grid (4 columns): slots 0 through 2 hold products (count 3); B2 is authored at slot 3 (its authored rectangle covers 3 and 4), so anchors[B2] = 3; slot 4 is covered; B1 is authored at slot 5, so anchors[B1] = 3.
  • Remapping, re-placing phase on 2 columns, sorted by (anchor, index) = B2 then B1: for B2 (span 2), slots 0 through 2 are counted as products 1 through 3, slot 3 starts at column 1 where a span of 2 does not fit (counted as product 4), slot 4 fits: B2.index becomes 4. For B1 (span clamped to 2), the cursor is then at slot 6, which fits: B1.index becomes 6.
  • The visibility filter then drops B1 (desktop-only). B2’s span is 2. Placement: slot 4 has columnStart 0 and is free; final index 4.
  • The four products fill slots 0 through 3; B2’s slot is never reached while items remain, so B2 is a trailing block, appended at slot 4.
  • Sequence: p1, p2, p3, p4, B2. B2 is full-width on this grid (span equals C) with aspect_ratio 1.5 and one row, so it renders as a natural-ratio band.

Vector 5: remapping to a narrower grid

A full-width desktop hero above the products, with a second block anchored right after it. On mobile the hero is hidden, yet it still shapes the second block’s anchor. This vector fails if remapping is skipped or run after the visibility filter. Input:
Required output, viewport class "desktop" (C = Ca = 4, remap is an identity):
  • Placement: B1’s final index is 0 (covers 0 through 3); B2’s final index is 4 (covers 4 and 5).
  • Sequence: B1, B2, p1, p2, p3, p4, p5, p6. B1 renders as a natural-ratio band (full-width, aspect_ratio 2.0, one row); B2 is tile-sized.
Required output, viewport class "mobile" (C = 2, Ca = 4):
  • Remapping, counting phase (4 columns): B1 is authored at slot 0 covering 0 through 3, B2 at slot 4 covering 4 and 5. No product slots precede either block: anchors[B1] = 0 and anchors[B2] = 0.
  • Remapping, re-placing phase (2 columns), sorted by (anchor, index) = B1 then B2: B1 (span clamped to 2) is placed at slot 0, the cursor moves to 2; B2 (span 2) is placed at slot 2. B1.index becomes 0, B2.index becomes 2.
  • The visibility filter drops B1. B2 is placed at final index 2.
  • Composition: p1 and p2 take slots 0 and 1, B2 takes slots 2 and 3, p3 through p6 take slots 4 through 7.
  • Sequence: p1, p2, B2, p3, p4, p5, p6. B2 is full-width on this grid and renders as a natural-ratio band.
Note the anchoring effect: without remapping, B2’s authored index 4 would put it after four products on mobile; remapping keeps it directly after the hidden hero’s position, two products in, exactly where the merchandiser placed it relative to the products on desktop.

17. Self-verification checklist

An implementation is conforming when:
  1. All five test vectors reproduce exactly (sequence, slots, matrix) at every stated column count.
  2. Re-running vector 1 with "index": 50 for B1 appends B1 after p8 (trailing rule).
  3. Re-running vector 2 with both blocks at "index": 0 places B1 at 0 and pushes B2 to slot 2 (list order priority).
  4. Re-running vector 5 on mobile with B1’s visibility set to "mobile" places B1 at slots 0 and 1 and B2 at slots 2 and 3, with all six products after them; both render as natural-ratio bands.
  5. Re-running any vector with a sorted or filtered view renders only the products, in the sorted or filtered order, with image settings still applied.