Great news, I found a way around this and it works really well, so I’m posting it here for anyone who runs into the same problem.
The idea involves display: grid on the parent, display: contents on the block wrapper, and grid column and row placement on the elements inside.
Let’s take the tabs I mentioned earlier. They can be solved by having each block render its own button and its own panel, instead of the parent building the nav out of each block’s title:
{% comment %} blocks/tab.liquid {% endcomment %}
<div class="tab-pair" {{ block.shopify_attributes }}>
<button class="tab-button" aria-controls="panel-{{ block.id }}">
{{ block.settings.title | escape }}
</button>
<div class="tab-panel" id="panel-{{ block.id }}">
{% content_for 'blocks' %}
</div>
</div>
The parent still reads nothing, only renders the blocks:
<div class="tabs">
{% content_for 'blocks' %}
</div>
This outputs button, panel, button, panel, which is not the layout we want. display: contents on the wrapper removes its box from the page, so every button and every panel becomes a direct child of .tabs. Grid placement on the parent can then put them wherever we want, regardless of the order they were written in:
.tabs {
display: grid;
grid-template-columns: auto 1fr; /* nav column, then content */
}
.tab-pair { display: contents; }
.tab-button { grid-column: 1; } /* buttons go down the left */
.tab-panel { grid-column: 2; grid-row: 1; } /* panels all share one cell */
Every button lands in the left column, every panel in the same cell on the right, and no setting has to travel up to the parent.
The panels sharing one cell also means it is as tall as the tallest panel, so switching tabs doesn’t shift the page. Hide the inactive ones with visibility: hidden rather than display: none, or they stop taking up space and you lose that.
@Allan_Emerson @jakeshw @haroldao Hope that’s clear. Happy to explain any part of it in more detail if anyone needs help.