Skip to content

[css-conditional] Layout State Container Queries: column-position feature for flex/grid/multi-column layouts #13729

Description

@devi-prsd

Problem Statement

There is no CSS-only way to style elements based on their position within a layout column (first, last, only, nth) in multi-column flex, grid, or columns layouts. This is a common need for:

  • Decorative fill patterns in masonry-style layouts (filling remaining column space)
  • Bottom-border removal on the last card per column to avoid double borders
  • Spacing adjustments (removing margin-bottom from the column-terminal item)
  • Column-start decorations (drop-cap-like effects, top-border treatment)

Authors are forced to use significant JavaScript involving ResizeObserver, IntersectionObserver, and getBoundingClientRect() to compute column membership and toggle classes — a substantial amount of code for what is fundamentally a presentational concern.

Why Not a Pseudo-Class Selector?

The CSSWG FAQ on layout-dependent selectors explains that selectors cannot depend on layout because they create circular dependencies:

:last-in-column { display: none; } — now a different element is last-in-column, the selector unapplies, the original reappears, infinite loop.

Restricting applicable properties is rejected because developers can chain rules across multiple selectors to recreate circularity indirectly.

However, this class of problem has already been solved by Scroll State Container Queries (CSS Conditional 5, shipped Chrome 133). The pattern: the container holds the layout-resolved state, only descendants can query it, and the querying element cannot be the container itself — breaking circularity by construction.

This proposal extends that proven pattern to column-position states.

Proposed Solution

New Container Type: layout-state

A new container-type value that makes an element a layout-state query container. The UA resolves the element's column position during layout and exposes it to descendant queries.

.masonry-item {
  container-type: layout-state;
}

layout-state can be combined with existing container types:

.masonry-item {
  container-type: inline-size layout-state;
}

New Container Feature: column-position

A discrete container feature queryable via @container, with the following values:

Value Matches When
first Element is the first item laid out in its column
last Element is the last item laid out in its column
only Element is the sole item in its column
interior Element is neither first nor last (at least 3 items in column)

Syntax

@container layout-state(column-position: last) {
  /* styles for descendants of the last item in each column */
}

Real-World Example

Consider a horizontally-scrolling editorial board with a flexbox column-wrap masonry layout where decorative fill patterns (diagonal hatching, dot matrices, etc.) should appear after the last card in each column:

/* Container: the masonry wrapper provides the column context */
.masonry-wrapper {
  display: flex;
  flex-direction: column;
  flex-wrap: wrap;
  height: 100%;
  gap: 2rem;
}

/* Each item is a layout-state container */
.masonry-item {
  container-type: layout-state;
  width: 320px;
}

/* Descendants of the last item in each column get styled */
.masonry-item > .entry-card {
  @container layout-state(column-position: last) {
    /* Grow to fill remaining column space */
    flex-grow: 1;

    &::after {
      content: "";
      flex-grow: 1;
      min-height: 48px;
      background: repeating-linear-gradient(
        -45deg, currentColor, currentColor 1px,
        transparent 1px, transparent 8px
      );
      opacity: 0.15;
    }
  }
}

/* First item in each column gets a top accent */
.masonry-item > .entry-card {
  @container layout-state(column-position: first) {
    border-top: 3px solid AccentColor;
  }
}

Today this requires ~80 lines of JavaScript:

// Simplified version of what production apps actually ship:
const entryState = new Map();
let resizeObserver, intersectionObserver;

// 1. IntersectionObserver to track visibility
intersectionObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    entryState.set(entry.target.dataset.id, {
      visible: entry.isIntersecting,
      height: entry.boundingClientRect.height
    });
  });
  scheduleUpdate();
}, { root: scrollContainer, rootMargin: '0px 2560px' });

// 2. ResizeObserver to watch container changes
resizeObserver = new ResizeObserver(() => scheduleUpdate());
resizeObserver.observe(wrapper);

// 3. Manual column detection via getBoundingClientRect()
function updateLastInColumn() {
  const items = scrollContainer.querySelectorAll('.masonry-item');
  const visible = [];
  for (const el of items) {
    if (entryState.get(el.dataset.id)?.visible) {
      visible.push({ id: el.dataset.id, left: el.getBoundingClientRect().left });
    }
  }
  // Detect column breaks by checking inline-axis offset jumps
  const lastIds = [];
  for (let i = 0; i < visible.length; i++) {
    const next = visible[i + 1];
    if (!next || next.left > visible[i].left + 100) {
      lastIds.push(visible[i].id);
    }
  }
  // Toggle class on matching elements
  for (const el of items) {
    el.classList.toggle('is-last-in-column', lastIds.includes(el.dataset.id));
  }
}

// 4. Debounced re-computation
let timeout;
function scheduleUpdate() {
  clearTimeout(timeout);
  timeout = setTimeout(updateLastInColumn, 50);
}

// 5. Event listener wiring
window.addEventListener('resize', scheduleUpdate);
// 6. Cleanup on unmount...

Circular Dependency Avoidance

This proposal inherits the same circularity-breaking mechanism as Scroll State Container Queries:

  1. The container holds the state. The .masonry-item element's column position is resolved during layout and recorded as container state.

  2. Only descendants can query. The @container layout-state(...) rule can only style elements inside the container, not the container itself.

  3. The querying element cannot affect column assignment. Since the container's own width, height, display, position, flex-grow, and other layout properties cannot be modified by the query (they belong to the container, not a descendant), column assignment remains stable.

This is structurally identical to how @container scroll-state(stuck: top) works — the sticky element is the container, and only its children respond to the state change.

Note: The example above shows flex-grow: 1 on .entry-card (a descendant), not on .masonry-item (the container) — so the container's column membership is unaffected.

Extended Feature: column-index() (Future)

A functional form for targeting specific column ordinals or An+B patterns:

/* Style items in the first column */
@container layout-state(column-index(1)) {
  /* ... */
}

/* Style items in even columns */
@container layout-state(column-index(2n)) {
  /* ... */
}

This is deferred to a future level to keep the initial proposal focused.

Which Layout Modes Apply

Layout Mode Column Determination
flex-direction: column; flex-wrap: wrap Items sharing the same column line in the wrapping axis
columns (multi-column layout) Items in each column fragment
grid with explicit/implicit columns Items sharing the same grid-column track
flex-direction: row N/A — could define a symmetric row-position feature
Block flow Always only (single column)

Relationship to Existing Specifications

CSS Conditional 5 (Scroll State Container Queries)

This proposal is a direct extension of the container query mechanism in CSS Conditional 5. Where scroll-state queries expose scroll/sticky/snap states, layout-state queries expose column-position states. The circularity-avoidance mechanism is identical.

CSS Containment 3

Layout-state containers would require the UA to track column assignment metadata, similar to how size containers track inline/block dimensions. This is a bookkeeping addition — the UA already computes column assignment during layout; it simply needs to expose it.

CSS Grid Level 3 (Masonry)

The masonry layout proposal will create additional demand for per-column selection. column-position would work natively with masonry tracks.

Selectors Level 4

This proposal deliberately avoids adding pseudo-class selectors, respecting the CSSWG's position that selectors must not depend on layout. Container queries are the sanctioned mechanism for layout-dependent styling.

Alternatives Considered

Alternative Why It Falls Short
Pseudo-class selectors (:last-in-column) Rejected by CSSWG — circular dependency
JavaScript (IntersectionObserver + classList) Performance cost, fragile, not declarative, SSR-hostile
:nth-child(An+B) with known column count Column count changes with viewport; requires JS or @media ladder for every breakpoint
break-after: column Only works in multi-column layout, not flex or grid
Container Size Queries Can query dimensions but not positional layout role
CSS masonry layout (Grid L3) Only addresses one layout mode; flex column-wrap masonry exists today

Implementation Considerations

  • No new layout computation. The UA already determines column assignment during flex/grid/multi-column layout. This proposal asks the UA to record and expose that information, not compute anything new.
  • Containment model. layout-state containers would not require layout containment — column position is determined by the parent's layout algorithm, not the element's own subtree.
  • Composability. layout-state composes with scroll-state and size containment: container-type: inline-size scroll-state layout-state.
  • Polyfill path. A polyfill using ResizeObserver + getBoundingClientRect() + [data-column-position] attributes is straightforward — production applications already ship this exact pattern.

Open Questions

  1. Should layout-state be a separate container type or folded into scroll-state? Both are post-layout states. A unified state container type could encompass both, but separating them allows independent adoption.

  2. Should row-position be included symmetrically? For flex-direction: row; flex-wrap: wrap, the equivalent need is "last item in each row." The same mechanism applies with s/column/row/.

  3. Should column-position: last on a container also set flex-grow: 1 on the container itself? This would be convenient but violates the principle that queries don't affect the queried container. A separate flex-grow: last-in-column value or masonry-fill property might be more appropriate.


Suggested Labels: css-conditional-5, css-flexbox, css-grid, css-multicol

Related Issues/Specs:

Original Proposal (Psuedo selectors)

Previous Psuedo Selector Proposal ## Problem Statement

There is currently no CSS-only way to select the last item in each column of a multi-column flex or grid layout. This is a common need for:

  • Decorative fill patterns in masonry-style layouts (filling remaining column space with hatching, dot patterns, accent borders, etc.)
  • Bottom-border removal on the last card per column to avoid double borders
  • Spacing adjustments (removing margin-bottom from last items per column)
  • Visual indicators like column-end dividers, fade-outs, or ornamental elements

Authors are forced to use significant JavaScript to achieve what is fundamentally a presentational concern.

Real-World Use Case

Consider a horizontally-scrolling editorial/zine-style board with a flexbox column-wrap masonry layout:

.masonry-wrapper {
  display: flex;
  flex-direction: column;
  flex-wrap: wrap;
  height: 100%;
  column-gap: 2rem;
}

.masonry-item {
  width: 320px;
  margin-bottom: 2rem;
}

The design calls for decorative fill patterns (diagonal hatching, dot matrices, crosshatching, etc.) applied via ::after pseudo-elements on whichever item happens to land last in each column. These patterns fill the remaining vertical space, creating a polished editorial aesthetic.

Today's workaround requires ~80 lines of JavaScript:

// 1. IntersectionObserver to track which items are visible
// 2. ResizeObserver to watch for container size changes
// 3. getBoundingClientRect() to determine column positions
// 4. Manual computation of which items are last per column
// 5. Toggling an `.is-last-in-column` class on matching elements
// 6. Debounced re-computation on scroll, resize, and content changes

This approach is:

  • Fragile — breaks on scroll, resize, dynamic content, and container queries
  • Expensive — forces layout recalculation and DOM reads on every reflow
  • Non-composable — cannot be combined with other selectors or used in @media/@container contexts without additional JS wiring
  • Inaccessible to CSS-only contexts — server-rendered pages, CSS-only prototypes, and declarative design systems cannot use it at all

Proposed Solution

New Pseudo-Classes

:last-in-column

Matches every element that is the last sibling laid out in its respective column within a multi-column flex container (flex-direction: column; flex-wrap: wrap), a CSS columns layout, or a grid container with implicit column tracks.

/* Decorative column-end fill */
.masonry-item:last-in-column {
  flex-grow: 1;
}

.masonry-item:last-in-column::after {
  content: "";
  flex-grow: 1;
  background: repeating-linear-gradient(
    -45deg, currentColor, currentColor 1px,
    transparent 1px, transparent 8px
  );
  opacity: 0.15;
}

:first-in-column

Matches every element that is the first sibling laid out in its respective column. Useful for column-start decorations, drop-cap-like effects, or top-border treatment.

.masonry-item:first-in-column {
  border-top: 3px solid accent-color;
}

:nth-in-column() / :nth-last-in-column()

Functional pseudo-classes accepting An+B notation, scoped to the column the element is placed in.

/* Style every 2nd item within each column */
.masonry-item:nth-in-column(2n) {
  background: var(--zebra-stripe);
}

/* Style the second-to-last item in each column */
.masonry-item:nth-last-in-column(2) {
  margin-bottom: 0;
}

Which Layouts Apply

Layout Mode Applies? Column Determination
flex-direction: column; flex-wrap: wrap Yes Items in the same column share the same inline-axis offset
columns (multi-column layout) Yes Items in each column fragment
grid with explicit/implicit columns Yes Items sharing the same grid-column track
flex-direction: row No N/A (items flow in rows, not columns)
Block flow No N/A (single column by definition)

Definition of "Column"

A column is defined as a set of sibling elements that share the same column track (grid) or column fragment (multi-column, column-wrap flex). Two flex items are in the same column if they occupy the same column line in the wrapping axis.

The UA already computes this information during layout; the selector simply exposes it.

Interaction with Existing Specifications

Selectors Level 4/5

These pseudo-classes extend the structural pseudo-class family (:first-child, :last-child, :nth-child()). The key difference is that structural pseudo-classes operate on DOM order, while :*-in-column pseudo-classes operate on layout position — making them layout-dependent selectors.

Precedent: Layout-Dependent Selection

CSS already has layout-aware pseudo-classes and selectors:

  • :empty — depends on content (though not layout)
  • ::column (proposed in CSS Pseudo-Elements 4) — selects column boxes in multi-column layout
  • The proposed masonry layout (CSS Grid Level 3) will need similar per-column selection

Circular Dependency Avoidance

To prevent circular dependencies (where selecting :last-in-column changes sizing, which changes which item is last), column assignment must be resolved before these selectors are evaluated — similar to how :first-line resolves after line breaking. The selector matches based on the layout computed in the previous layout pass; if matching causes a layout change, the UA performs at most one additional pass (analogous to fit-content resolution).

Alternatively, these selectors could be restricted to properties that do not affect column assignment (e.g., color, opacity, background, border, outline, box-shadow, transform, filter) — similar to how ::first-line restricts applicable properties.

Alternatives Considered

Alternative Why It Falls Short
JavaScript (IntersectionObserver + classList) Performance cost, fragile, not declarative, SSR-hostile
:nth-child(An+B) with known column count Column count changes with viewport; requires JS or @media ladder for every breakpoint
break-after: column Only works in multi-column layout, not flex or grid
Container Queries Can query dimensions but cannot select based on positional layout role
CSS masonry layout (Grid L3) Only addresses one layout mode; flex column-wrap masonry exists today and is widely used

Implementation Considerations

  • The UA already knows column assignment after layout; exposing it to selectors is a matter of bookkeeping, not new computation.
  • Restricting applicable properties (as with ::first-line) avoids circular dependency without additional layout passes.
  • These selectors compose naturally with existing pseudo-classes: .card:last-in-column:not(:first-in-column) targets columns with more than one item.

Syntax Summary

:first-in-column        /* first item laid out in its column */
:last-in-column         /* last item laid out in its column */
:only-in-column         /* sole item in its column */
:nth-in-column(An+B)    /* nth item in its column */
:nth-last-in-column(An+B) /* nth from end in its column */

Polyfill Path

A polyfill can be implemented today using ResizeObserver + getBoundingClientRect() to compute column membership and toggle data attributes ([data-last-in-column]). This is exactly what production applications are already doing — see the real-world example above.

Related Issues / Specs


Labels: selectors-5, css-flexbox, css-grid, css-multicol

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status
    Thursday Afternoon

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions