Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[css-grid-2] Masonry layout #4650

Open
MatsPalmgren opened this issue Jan 6, 2020 · 8 comments
Open

[css-grid-2] Masonry layout #4650

MatsPalmgren opened this issue Jan 6, 2020 · 8 comments

Comments

@MatsPalmgren
Copy link

@MatsPalmgren MatsPalmgren commented Jan 6, 2020

Overview

This is a proposal to extend CSS Grid to support masonry layout in one of the axes while doing normal grid layout in the other. I'll use grid-template-rows/columns: masonryto specify the masonry-axis in the examples (and I'll call the other axis the grid-axis). Here's a simple example:

<style>
.grid {
  display: inline-grid;
  grid: masonry / 50px 100px auto;
  grid-auto-columns: 200px;
  grid-gap: 10px;
  border: 1px solid;
}
item { background: silver; }
</style>

<div class="grid">
  <item style="border:10px solid">1</item>
  <item>2</item>
  <item>3</item>
  <item style="height:50px">4</item>
  <item>5</item>
  <item>6</item>
</div>

Result:
image

The grid-axis behaves pretty much the same as in a normal grid container. Line names and track sizes can be specified and items can be placed and span multiple tracks using grid-column/row. CSS Box Alignment works normally etc. In the masonry-axis, items are placed into the grid-axis track(s) with the most space (typically) after the margin-box of the last item in those tracks.

Line name resolution and grid item placement

Grid items are formed and blockified the same as in a normal grid container. Line name resolution works the same as if masonry were replaced with none, i.e. names resolve in both axes. Grid item placement is done normally as well, although most of this result is discarded. Any items that were placed in the first hypothetical "track" in the masonry-axis keep their placement. Other items that have a definite position in the grid-axis keep that. Other placement results are ignored. These items will instead be placed according to the Masonry layout algorithm. (This implies that items can only be placed into the same grid area in this first hypothetical "track"). The flow axis specified by grid-auto-flow is ignored - items are always placed by filling the grid-axis. direction:rtl works as usual (reverses the grid) if the grid-axis is the inline-axis.

Containing block

The containing block for a grid item is formed by the grid area in the grid-axis and the grid container's content-box in the masonry-axis. Self-alignment works normally in the grid-axis, but is ignored in the masonry-axis. Margins do not collapse in either axis.

Track sizing

The Track Sizing Algorithm works as usual in the grid-axis, except only the subset of items with a definite placement in the grid-axis contribute to the intrinsic sizing. This makes the first (implicit grid) "track" in the masonry-axis special since those items always contribute to the intrinsic sizing. auto-placed items which don't end up in the first track don't contribute (since which track they end up in depends on layout results). The min/max-content size of a grid container in the masonry-axis is the largest distance between the item margin-edges in each of the tracks in the grid-axis, when sized under a min/max-content constraint.

Grid container alignment and spacing

Alignment etc works normally in the grid-axis. Gutters are supported in both axes. In the masonry-axis the spacing is applied between each item. Content alignment (align/justify-content) in the masonry-axis is applied "to the content as a whole". More specifically, the alignment subject is the "masonry box", which has the extent from the content box edge of the container to the margin-box end edge of the item that is the furthest away, as indicated by the dashed border here:
image
(Item "1" has a 5px bottom margin here.)
Note that there is only ever one alignment subject for these properties in the masonry axis, so the unique alignments boils down to start, center, end and stretch. (normal behaves as stretch as usual for grid containers). The above image shows the alignment subject with align-content:start. By default the masonry box is the same as the content box due to being stretched. This doesn't affect the items alignment within the masonry box in any way though (which is what I meant by "to the content as a whole"). So I've added two properties to allow authors to align the items within the masonry box: align/justify-tracks which have the same values as the corresponding -content property. Here's a screenshot showing a few alignment possibilities:
image
(Here's the testcase for that.)
There's one difference for these new properties though: normal behaves as start. So if all these properties have their initial values, the rendering is the expected "packed" masonry layout as shown in the top left corner above.

align/justify-tracks:stretch

align/justify-tracks:stretch can be used to fill the tracks in the masonry axis by stretching items individually. Items can opt out from stretching process by setting align/justify-self to something other than normal/stretch in the relevant axis. Items with a definite size and without an auto-margin are also excluded. An item only grows up to its max-size. auto margins can be used to align the item inside its new larger space instead of changing its size. I made a testcase and a video to illustrate. Only the purple items have height:auto, so they are the ones that may grow by default. A few items worth noting: item 4 has max-height:40px so it only grows up to that size and then the other items in its track picks up the remaining size. Item 16 opts out from resizing by setting align-self:end. Item 18 has margin-top/bottom:auto so it's centered in its allotted space instead of growing. Item 20 has margin-top:auto so it's aligned to the end. (Here's the corresponding testcase with a masonry inline-axis instead, with video.) It should be noted that this is an alignment step only - all items keep their pre-alignment track placement.

Masonry layout algorithm

Items are placed in order-modifed document order but items with a definite placement are placed before items with an indefinite position (as for a normal grid). For each of the tracks in the grid-axis, keep a running position initialized to zero. For each item that has an indefinite placement:

  1. starting at line 1...
  2. find the largest running position of the tracks that the item spans at this position, call it min_pos
  3. increment the line and repeat step 2 until the item would no longer fit inside the grid
  4. pick the line number that resulted in the smallest min_posas the definite placement

Calculate the size of the containing block and flow the item. Then calculate its resulting margin-box size in the masonry-axis. Set the running position of the tracks the item spans to min_pos + margin-box + grid-gap.

There are a few variations of this algorithm that might be useful to authors. First, the "definite items first" might be useful to skip in some cases, so that a plain order-modifed document order is used instead. Also, opting out from the packing behavior described above and instead placing each item in order (a couple of existing masonry JS packages provides this option). So, perhaps something like this: masonry-auto-flow: [ definite-first | ordered ] || [ pack | next ]. Example:

<style>
.grid {
  display: inline-grid;
  grid: 50px 100px auto / masonry;
  border: 1px solid;
  masonry-auto-flow: next;
}

.grid > * {
  margin: 5px;
  background: silver;
}
.grid > :nth-child(2n) {
  background: pink;
  width: 70px;
}
</style>

<div class="grid">
  <item>1</item>
  <item style="height: 50px">2</item>
  <item>3</item>
  <item style="order: -1">4</item>
  <item>5</item>
  <item>6</item>
  <item>7</item>
</div>

Result:
image

(Without masonry-auto-flow: next, 1,3,5,6 are placed in the middle row.)

Fragmentation

Fragmentation in the masonry-axis is supported. Each grid-axis track is fragmented independently. If an item is fragmented, or has a forced break before/after it, then the running position for the tracks that it spans in the grid-axis are set to the size of the fragmentainer so that no further items will be placed in those tracks. Placement continues until all items are placed or pushed.

Subgrid

Masonry layout is supported also in subgrids (e.g. grid: subgrid / masonry). However, only a parent grid-axis can be subgridded. A subgrid axis with a parent masonry-axis will behave as masonry, unless the subgrid's other axis is also masonry in which case it behaves as none. (A grid container can only have one masonry-axis). auto-placed subgrids don't inherit any line names from their parent grid, but are aligned to the parent's tracks as usual. Here's a subgrid example:

<style>
.grid {
  display: inline-grid;
  grid: auto auto 100px / masonry;
  align-content: center;
  height: 300px;
  border: 1px solid;
}

.grid > * {
  margin: 5px;
  background: silver;
}
.grid > :nth-child(2n) {
  background: pink;
}

.grid subgrid {
  display: grid;
  grid: subgrid / subgrid;
  grid-row: 2 / span 2;
  grid-gap: 30px;
}
.grid subgrid > * { background: cyan; }
</style>

<div class="grid">
  <item>1</item>
  <item>2</item>
  <item>3</item>
  <subgrid>
    <item style="height:100px">subgrid.1</item>
    <item>sub.2</item>
    <item>s.3</item>
  </subgrid>
  <item>4</item>
  <item>5</item>
  <item style="width: 80px">6</item>
  <item>7</item>
</div>

Result:
image

Note how the subgrid's first item ("subgrid.1") contributes to the intrinsic size of the 2nd row in the parent grid. This is possible since the subgrid specified a definite placement so we know which tracks it will occupy. Note also that it's subgridding the masonry-axis of the parent which falls back to masonry layout in the inline-axis for the subgrid.

Absolute Positioning

Grid-aligned absolute positioned children are supported. The containing block is the grid-area in the grid-axis and the grid container padding edges in the masonry-axis, except for line 1 in the masonry-axis which corresponds to the start of the "grid" (the content-box start edge usually). It might be useful to define a static position in the masonry-axis though, given that we only have a one line in that axis to align to. Maybe it could defined as the max (or min?) current running position of the grid-axis tracks at that point?

repeat(auto-fit)

I don't see a way to support repeat(auto-fit) since auto-placed items depend on the layout size of its siblings. Removing empty tracks after layout wouldn't be possible in most cases since it might affect any intrinsic track sizes. Even if all track sizes are definite the containing block size could change for grid-aligned abs.pos. descendants. So repeat(auto-fit) behaves as repeat(auto-fill) when the other axis is a masonry-axis.

Performance notes

In general, masonry layout should have significantly better performance than the equivalent (2-axis) grid layout, particularly when the masonry-axis is the block-axis since the intrinsic sizing of grid rows is typically quite expensive. Any intrinsic track sizing in the grid-axis should be cheaper too, because, typically, only a subset of items contribute to the intrinsic sizing in a masonry layout, contrary to a 2-axis grid where all items spanning the intrinsic track contributes. That said, justify/align-tracks:stretch specifically adds a cost proportionate to the number of items that are resized. (Note that stretch isn't the default value for these properties though.) Stretched items do a second layout (size reflow) with the new size (when it actually changed) so this can be costly if there are a huge amount of stretched items that each contains a lot of content. Especially nested stretched masonry layouts should be avoided unless they are small/trivial. This can be ameliorated by the author by opting out from the stretching on most items though, e.g. specifying justify/align-items:start and then opting in for just a few items with justify/align-self:stretch to let those items fill the masonry axis. Other justify/align-tracks values such as center, endand <content-distribution> (other than stretch) shouldn't be a problem though since they just reposition the items which is fast. (This performance analysis is from a Gecko perspective, but I suspect there's some truth to it for other engines as well.)

Graceful degradation

A Masonry design should degrade quite nicely in an UA that supports Grid layout but not Masonry layout if the grid/grid-template shorthands are avoided and the longhands are used instead. e.g.

  grid-template-rows: masonry; /* ignored by UAs that don't support it */
  grid-template-columns: 150px 100px 50px;

Here's a testcase to demonstrate. It gives you a three-column grid layout, but with "more gaps" than if the UA supported masonry. (A video of the masonry layout for comparison.)

@tabatkins

This comment has been minimized.

Copy link
Member

@tabatkins tabatkins commented Jan 7, 2020

I'm liking this quite a bit!

Just to be clear, the masonry axis has no explicit tracks, right? Everything's effectively placed into implicit tracks?

I'm not sure I understand from this description how auto-placed items interact with definite-placement items. In your first example, what would happen if item 4 said "grid-column: 2;"? What about item 2 or 6? Do they just get placed after all the masonry-placed items, which are presumably all in the first implicit column?

For repeat(auto-fit), is there really a case that would differ here? An empty track would be at minimum run, right, so the only way it could possibly be empty is if there just aren't enough elements in the grid to reach that track; there's no dependence on the layout size of the elements. Am I missing an interaction here?

@MatsPalmgren

This comment has been minimized.

Copy link
Author

@MatsPalmgren MatsPalmgren commented Jan 8, 2020

Just to be clear, the masonry axis has no explicit tracks, right? Everything's effectively placed into implicit tracks?

Right, the line name resolution + "hypothetical grid-placement" steps are done as if masonry were none. Note that this grid is an illusion though, there are no tracks in the masonry axis. The purpose of the grid-placement step is to resolve (some) auto-positioned items into definite tracks in the grid-axis for the intrinsic track sizing step. Only items placed into the hypothetical first implicit track keep their resolved auto-position, other auto-placed items don't. The subset of items with a definite position in the grid-axis goes into the track sizing step and contributes to the intrinsic sizing. So for example:

<style>
.grid {
  display: inline-grid;
  grid: masonry / auto;
}
x { background: silver; }
y { background: lime; width:100px; grid-row: 1; }
</style>
<div class="grid">
  <x>x</x>
  <y>y</y>
</div>

This makes the column size 100px. Removing grid-row: 1 makes is the size of "x" and the y element overflows.

Currently, I'm also handling the items that are were placed at first implicit line specially in the masonry layout step. They are sorted before other items and they all start at position zero. This has two effects, a) you can make these items in this first hypothetical track intentionally overlap. E.g., using grid-area: 1/1 above and adding more <y> items would make them all overlap. This may not seem very useful at first glance, but it's actually quite useful in creating a stacked tab-panel type of layout where you want the size to be the maximum of the children. (<x> would then follow after the largest <y>). And b) it moves the <y> element to the start, which seems like the least surprising layout.

I'm not sure I understand from this description how auto-placed items interact with definite-placement items.

Items with an auto-placement in the grid-axis gain a definite placement only if they end up in the first hypothetical track in the masonry axis. Otherwise, they are still considered auto-placed and will not contribute to track sizing. There's a sorting step before masonry layout starts. The masonry-auto-flow: [ definite-first | ordered ] controls if items with a non-auto grid-axis placement should be placed first or not (the actual line number isn't considered just if it's auto or non-auto, the set is already sorted in order-modifed document order to begin with and this is a stable sort).

In your first example, what would happen if item 4 said "grid-column: 2;"? What about item 2 or 6?

No change. No change. With grid-column: 2 on item 6:

image

In the above: items 1,2,3 are attached the first implicit line in the masonry axis, so they are placed first. Then item 6, because it has a definite placement in the grid-axis. Then 4 and 5 since the are auto-placed in the grid-axis. Items 1,2,3 and 6 have a definite grid-axis placement when masonry layout starts so we simply position them in the requested column at the minimum position possible for its span extent.
That's the behavior with masonry-auto-flow: definite-first which is the default. With masonry-auto-flow: ordered you get:
image

Do they just get placed after all the masonry-placed items, which are presumably all in the first implicit column?

Yes, all items with definite placement first, then auto-placed. The auto-placed items don't really have a column yet. It's resolved by the masonry layout step while honoring the masonry-auto-flow: [ pack | next ] preference for the positioning.

So, perhaps it's confusing to use the term track at all in the masonry axis since there really aren't any tracks there. It's a continuous layout. I think it's still useful to have a "first implicit line" there though. It's convenient for intrinsic sizing purposes with auto-placed items, and it allows overlapping items at this line.

It's also useful to have a line at the start/end of the items in the masonry axis for abs.pos. items to align to, although I'll punt on the exact details of that for a bit. (Currently, I'm resolving grid-row:auto/1 from the padding edge to the start of the first implicit line, and N > 1 to the last implicit line.)
Example:

<style>
.grid {
  display: inline-grid;
  grid: masonry / 100px;
  padding: 40px;
  position: relative;
  border: 1px solid;
}
a {
  position:absolute;
  inset: 10px;
  border: 3px dashed red;
}
y { background: lightgrey; }
</style>
<div class="grid">
  <y>y</y><y>y</y><y>y</y>
  <a></a>
  <a style="grid-row-end: 1; border-color: blue"></a>
  <a style="grid-row-start: 2; border-color: black"></a>
</div>

Result:
image

For repeat(auto-fit), is there really a case that would differ here? An empty track would be at minimum run, right

Correct.

so the only way it could possibly be empty is if there just aren't enough elements in the grid to reach that track; there's no dependence on the layout size of the elements.

Consider:

<style>
.grid {
  display: inline-grid;
  grid: masonry / repeat(auto-fit, 100px);
  width: 300px;
  border: 1px solid;
}
x { background: silver; }
</style>
<div class="grid">
  <x>1</x>
  <x>2</x>
  <x style="grid-column:span 2">3</x>
</div>

Item 1 and 2 have equal height so masonry layout places item 3 in column 1 (it doesn't fit in column 3 since it has span 2 and the grid only has 3 columns). With <x style="height: 4em">1</x> though, the desired result is that item 3 is placed in column 2. We don't know until after we have flowed and placed all items preceding item 3 whether it's the former or latter case.

@MatsPalmgren

This comment has been minimized.

Copy link
Author

@MatsPalmgren MatsPalmgren commented Jan 9, 2020

I've made a demo of a couple of fragmentation tests.
A grid with masonry layout in the inline-axis, fragments like this.
A grid with masonry layout in the block-axis, fragments like this.
I made the items that are fragmented keep its fragments in the same grid-axis track (so they should line up in paged media), whereas items that are pushed (break before due to break-inside:avoid) are masonry-placed in the next container fragment. Does this make sense? Or should we perhaps freeze the first pushed item (for each track) to the track it came from and just place the rest?

@MatsPalmgren

This comment has been minimized.

Copy link
Author

@MatsPalmgren MatsPalmgren commented Jan 9, 2020

Robert Utasi suggested that alignment in the masonry-axis per grid-axis track could be useful too.
I said above that "Alignment in the masonry-axis is applied to the content as a whole (same as for a block container in the block-axis).", but perhaps doing it per track is actually a better default. I can see both being useful though.

@MatsPalmgren

This comment has been minimized.

Copy link
Author

@MatsPalmgren MatsPalmgren commented Jan 9, 2020

A problem with doing the alignment per axis is that if there are items that span 2 or more grid-axis tracks then it's very likely that we'll make items overlap each other. It should work fine when all items are non-spanning though, which I guess is quite common in practice. Still, I think the align-per-axis feature should probably be opt-in to avoid that footgun. So we need an extension to css-align like justify/align-content: ... || per-track or something. A few examples to illustrate (grid container's content area is grey, the masonry axis is the block axis):
image

image

image

Alternatively, a new property to control the track- and content-area alignment independently instead of the more limited per-tracks keyword:
image

@heycam heycam added the Agenda+ F2F label Jan 9, 2020
@MatsPalmgren

This comment has been minimized.

Copy link
Author

@MatsPalmgren MatsPalmgren commented Jan 10, 2020

I think having separate properties for alignment within the tracks in the masonry axis is the way to go. It gives authors very good control over the final layout. I've updated the original description above with the details.

@MatsPalmgren

This comment has been minimized.

Copy link
Author

@MatsPalmgren MatsPalmgren commented Jan 14, 2020

I've updated the proposal above with align/justify-tracks:stretch that can be used to fill tracks in the masonry axis by stretching items.

@MatsPalmgren

This comment has been minimized.

Copy link
Author

@MatsPalmgren MatsPalmgren commented Jan 14, 2020

I've updated the proposal above with some notes on performance and graceful degradation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
4 participants
You can’t perform that action at this time.