Skip to content
Kenny Mochizuki Escalona edited this page Aug 20, 2026 · 5 revisions

Grid

A Material-free responsive layout system with three composable widgets: LayrzRow (12-column grid container), LayrzCol (column definition with breakpoint-specific widths), and LayrzConstrainedView (width-constrained centred column).

Metadata
Mirrors: ResponsiveRow / ResponsiveCol (layrz_theme)
Phase: M2 (Core primitives)
Domain: Layout
Primitive: Hand-rolled (Row + Column)
Status: Confirmed scope.


Overview

The grid module provides responsive layout primitives for building adaptive, multi-column interfaces. It is inspired by 12-column CSS grids (Bootstrap, Tailwind) but adapted for Flutter's box model and widget tree.

Design Principles

  • 12-column foundation: Every row is divided into 12 logical columns. Children (LayrzCol instances) specify their span (1–12) at each breakpoint.
  • Cascade by default: A column's span cascades downward through breakpoints—if md is not specified, it inherits from sm, then xs. Only xs is required.
  • Greedy line wrapping: Columns are grouped greedily into visual rows. When adding the next column would exceed 12 spans, a new visual row starts automatically.
  • Viewport-driven breakpoints: Breakpoints are resolved from the viewport width (screen width), not the row's own measured width. This is the standard CSS Grid / Bootstrap behaviour. The consequence: a LayrzRow inside a 400px sidebar on a 1920px screen selects the xl band (because the screen is wide) and divides its 400px width by those wide-screen spans, resulting in narrower columns.
  • Container-relative pixel widths: The pixel width for sizing columns always comes from the row's own measured box, preventing overflow in narrow containers. When the parent is horizontally unbounded, the row falls back to the viewport width.
  • No geometry changes on interaction: Spacing applies between columns in a visual row and between wrapped rows. Changing breakpoints never triggers reflow or flicker—only the width and the visual row boundary change.
  • Constrained view for page layouts: LayrzConstrainedView provides the Bootstrap .container pattern—a fixed-width, horizontally-centred column for page-level layout constraints.

Breakpoints

The grid uses five breakpoints resolved from viewport width (screen width), not the row's measured width. Breakpoints are themeable via LayrzBreakpointTokens and can be customized by passing a custom theme to your app.

Band Default Width Threshold (u) Selection Logic
xs < 600 Mobile: phones, small tablets (landscape)
sm 600–959 Tablets (portrait)
md 960–1263 Tablets (landscape), small desktops
lg 1264–1903 Large desktops
xl ≥ 1904 Extra-large displays

Important: Each field in LayrzBreakpointTokens represents the upper bound (exclusive) of the band below it:

  • xs = 600 means the xs band is < 600; sm band starts at 600
  • sm = 960 means the sm band is 600–959; md band starts at 960
  • And so on

Breakpoints are resolved from the viewport width (from MediaQuery.sizeOf(context).width), following standard CSS Grid and Bootstrap semantics. This is viewport-driven, not container-driven — a grid inside a 400px sidebar on a 1920px screen selects the xl band (because the viewport is wide) and divides its 400px width by those wide-screen spans, resulting in narrower columns.

Cascade Example

LayrzCol(
  xs: 12,    // Mobile: full width
  sm: 6,     // Tablet (portrait): half width
  md: 4,     // Tablet (landscape): one third
  lg: 3,     // Desktop: one quarter
  // xl inherits lg's value (3) because not specified
)

API Structure

LayrzCol

Column definition within a LayrzRow. Specifies the column's span at each breakpoint, cascading downward if any are omitted.

const LayrzCol({
  Key? key,
  int xs = 12,          // Span at xs band (required base; defaults to full width)
  int? sm,              // Span at sm band (optional; cascades from xs)
  int? md,              // Span at md band (optional; cascades from sm/xs)
  int? lg,              // Span at lg band (optional; cascades from md/sm/xs)
  int? xl,              // Span at xl band (optional; cascades from lg/md/sm/xs)
  required Widget child, // The widget to display in this column
})

Parameters

Parameter Type Default Description
key Key? null Widget key for state preservation
xs int 12 Column span at extra-small breakpoint (1–12). Mobile-first base; defaults to full row width.
sm int? null Column span at small breakpoint. If null, cascades from xs.
md int? null Column span at medium breakpoint. If null, cascades from sm, then xs.
lg int? null Column span at large breakpoint. If null, cascades from md, sm, then xs.
xl int? null Column span at extra-large breakpoint. If null, cascades from lg, md, sm, then xs.
child Widget Required. The widget to render in this column.

Key Behaviors

  • Span validation (debug only): Spans are asserted to be integers between 1 and 12. Out-of-range values trigger an assertion error during development.
  • Width calculation: Within a visual row of n columns, each column's pixel width = (rowWidth - spacing * (n - 1)) * span / 12.
  • spanAt(double width, LayrzBreakpointTokens breakpoints): Given a width in logical pixels and the active breakpoint tokens, returns the resolved span by checking each breakpoint's upper bound.

LayrzRow

A 12-column responsive grid container. Wraps Row internally and handles breakpoint resolution, greedy line wrapping, and spacing.

const LayrzRow({
  Key? key,
  required List<LayrzCol> children,
  MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
  CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.start,
  double? spacing,
})

Parameters

Parameter Type Default Description
key Key? null Widget key.
children List Required. List of LayrzCol instances defining the grid columns.
mainAxisAlignment MainAxisAlignment .start Horizontal alignment of columns within the row. Ignored when crossAxisAlignment is .stretch.
crossAxisAlignment CrossAxisAlignment .start Vertical alignment of columns. When .stretch, children expand to fill the parent's height; requires a bounded height from the parent (asserts in unbounded contexts).
spacing double? null Gap between columns in a visual row and between wrapped visual rows (logical pixels). Defaults to context.tokens.spacing.sp2 (usually 8.0 dp). Pass 0 explicitly for flush layouts.

Key Behaviors

  • Greedy wrapping: Columns are grouped by span into visual rows. When adding the next column would exceed 12 total spans, a new visual row begins. Example: columns [7, 7] produce two rows (7 + 7 = 14 > 12); columns [4, 4, 4] stay in one row (4 + 4 + 4 = 12).
  • Spacing applies both ways: spacing controls the horizontal gap between columns in a visual row and the vertical gap between wrapped rows.
  • Theme-aware spacing default: If spacing is null, the layout reads context.tokens.spacing.sp2 from the theme. This allows design system control over default spacing without hardcoding.

LayrzConstrainedView

Constrains the maximum width of its children, centres them horizontally, and lays them out in a vertical Column internally. The Bootstrap .container pattern for page-level layout.

const LayrzConstrainedView({
  Key? key,
  required double maxWidth,
  double? spacing,
  required List<Widget> children,
})

Parameters

Parameter Type Default Description
key Key? null Widget key.
maxWidth double Required. The maximum width constraint in logical pixels. Must be > 0 (asserted in debug builds). Example: 960 for a traditional 960-grid page layout.
spacing double? null Gap between child widgets (logical pixels). Defaults to context.tokens.spacing.sp2. Pass 0 explicitly for flush layouts.
children List Required. Widgets to layout vertically inside the centred, constrained column.

Key Behaviors

  • Fixed alignment (by design): The internal Column is fixed to mainAxisAlignment: MainAxisAlignment.start and crossAxisAlignment: CrossAxisAlignment.stretch. These are deliberately not exposed as parameters.
  • Rationale for fixed alignment: The component is designed to centre and constrain, not to expose the full range of Column alignment options. Callers needing different alignments can wrap their own Column and pass it as a child.
  • No clipping: The component constrains and centres; it does not clip overflow. Content wider than maxWidth renders at its natural width.

Breakpoint Resolution and Pixel Sizing

LayrzRow uses two independent width concepts to ensure composability and correctness:

  1. Breakpoint width — always the viewport width from MediaQuery.sizeOf(context).width, resolved using context.tokens.breakpoints.bandAt(width). This follows the standard CSS Grid / Bootstrap pattern where breakpoint band selection depends on the overall screen width, regardless of the row's container. Breakpoints can be customized by creating a custom LayrzBreakpointTokens and passing it via your theme to LayrzApp.

  2. Layout width — the row's own measured box width, used for pixel arithmetic when sizing columns. When the parent is unbounded horizontally, the row falls back to the viewport width to prevent infinity from propagating.

Accessing the Current Breakpoint

Use the context.breakpoint getter to determine the active breakpoint band anywhere in your widget tree:

if (context.breakpoint == LayrzBreakpoint.xs) {
  // Mobile layout
} else if (context.breakpoint == LayrzBreakpoint.md) {
  // Tablet or desktop layout
}

This getter reads from the viewport width and the current theme's breakpoint tokens.

Worked Example

A row inside a 400px sidebar on a 1920px display:

SizedBox(
  width: 400,
  child: LayrzRow(
    spacing: 0,    // Flush layout for clear arithmetic
    children: [
      LayrzCol(xs: 12, md: 6, lg: 4, child: ...), // Column definition
      LayrzCol(xs: 12, md: 6, lg: 8, child: ...), // Another column
    ],
  ),
)

Breakpoint resolution:

  • Viewport width = 1920px → xl band selected (≥ 1904)
  • Neither column sets xl, so each cascades xl ?? lg and lands on its lg value

Pixel sizing:

  • Row's measured width = 400px (the sidebar width)
  • Column 1 resolves to span 4; pixel width = 400 × 4/12 ≈ 133px
  • Column 2 resolves to span 8; pixel width = 400 × 8/12 ≈ 267px

Result: The row selects the xl band (extra-large, ≥ 1904px) because the viewport is 1920px wide, but divides its 400px width by those wide-screen spans, producing narrower columns that fit the narrow sidebar. This is standard CSS Grid behaviour and prevents layout breakage in constrained containers.


Usage Examples

Responsive Two-Column Layout

LayrzRow(
  children: [
    LayrzCol(
      xs: 12,    // Mobile: stack vertically
      md: 6,     // Desktop: side-by-side
      child: Text('Left column'),
    ),
    LayrzCol(
      xs: 12,
      md: 6,
      child: Text('Right column'),
    ),
  ],
)

Three-Column Grid

LayrzRow(
  spacing: 16,   // Custom spacing (default is 8dp from tokens)
  children: [
    LayrzCol(xs: 12, sm: 6, md: 4, child: Card(child: Text('Card 1'))),
    LayrzCol(xs: 12, sm: 6, md: 4, child: Card(child: Text('Card 2'))),
    LayrzCol(xs: 12, sm: 6, md: 4, child: Card(child: Text('Card 3'))),
  ],
)

// Mobile (< 600px): All three cards stack vertically, full width each.
// Tablet (600–959px): Two per row, third wraps below.
// Desktop (960px+): Three per row.

Asymmetric Layout (8-4 Split)

LayrzRow(
  children: [
    LayrzCol(
      xs: 12,  // Mobile: sidebar becomes full-width stacked above
      md: 8,   // Desktop: main content, two-thirds width
      child: MainContent(),
    ),
    LayrzCol(
      xs: 12,
      md: 4,   // Desktop: sidebar, one-third width
      child: Sidebar(),
    ),
  ],
)

Constrained Page Layout

// Page constrained to 960px, centred horizontally
LayrzConstrainedView(
  maxWidth: 960,
  spacing: 24,
  children: [
    Header(),
    LayrzRow(
      children: [
        LayrzCol(xs: 12, md: 8, child: MainContent()),
        LayrzCol(xs: 12, md: 4, child: Sidebar()),
      ],
    ),
    Footer(),
  ],
)

Flush Layout (No Spacing)

LayrzRow(
  spacing: 0,    // Explicit zero for flush, no gaps
  children: [
    LayrzCol(xs: 6, child: Text('Left')),
    LayrzCol(xs: 6, child: Text('Right')),
  ],
)

Line Wrapping Behavior

Columns are grouped greedily into visual rows based on their resolved span at the current breakpoint.

Example: Wrapping at sm Breakpoint

Three columns, each specified as xs: 12, sm: 5:

  • At xs (< 600px): Each column spans 12 → only one per row → three visual rows
  • At sm (600–959px): Each column spans 5 → 5 + 5 + 5 = 15 > 12 → first two columns in row 1, third in row 2
LayrzRow(
  children: [
    LayrzCol(xs: 12, sm: 5, child: Box1()),
    LayrzCol(xs: 12, sm: 5, child: Box2()),
    LayrzCol(xs: 12, sm: 5, child: Box3()),
  ],
)

// xs: three stacked rows
// sm: two rows (5+5, then 5)

Migration from layrz_theme

layrz_theme layrz_ui Note
ResponsiveRow LayrzRow Renamed; same semantics
ResponsiveCol LayrzCol Renamed; same semantics
Sizes.col6 6 Sizes enum removed; use plain int spans (1–12) per decision D9
Sizes.full 12 Full-width column
WrapAlignment MainAxisAlignment Native Flutter enum; no custom type
WrapCrossAlignment CrossAxisAlignment Native Flutter enum; no custom type
ResponsiveRow.builder(itemCount:, itemBuilder:) LayrzRow(children: List.generate(...)) Factory deliberately not ported; use List.generate inline
LayrzConstrainedView New; no predecessor in layrz_theme

Key API Changes

  • Spans are plain int (1–12), not enum values. This is simpler and follows Flutter conventions.
  • ResponsiveRow.builder removed: Use List.generate to build the children list inline.
  • Viewport-driven breakpoints: Breakpoint selection is always based on viewport width (standard CSS/Bootstrap semantics), not container width. The useScreenWidth escape hatch was removed; breakpoints are viewport-only. Pixel widths come from the row's own measured box, preventing overflow in narrow containers.
  • Breakpoints moved to tokens: The constants kExtraSmallGrid, kSmallGrid, kMediumGrid, kLargeGrid have been deleted. Breakpoint thresholds are now stored in LayrzBreakpointTokens (default thresholds: 600, 960, 1264, 1904) and are themeable. Apps can customize them by passing a custom theme to LayrzApp.
  • spacing is theme-aware by default: Null spacing reads context.tokens.spacing.sp2 from the theme. Explicit 0 gives flush layouts.
  • LayrzConstrainedView is new: Provides page-level constraint and centring (Bootstrap .container).
  • context.breakpoint getter is new: Quickly determine the current breakpoint band from viewport width without manual calculation.

Dependencies

  • M1 Theme System (LayrzTheme, LayrzThemeData, LayrzTokens) — for theme-aware spacing defaults and breakpoint tokens
  • Breakpoint Tokens (LayrzBreakpointTokens, LayrzBreakpoint) — from package:layrz_ui/layrz_ui.dart
  • Flutter primitives (Row, Column, LayoutBuilder) — from package:flutter/widgets.dart

Material-Free Construction

The grid is built entirely from Flutter primitives without Material or Cupertino imports:

  1. Row — horizontal layout of columns
  2. Column — internal structure of LayrzConstrainedView
  3. LayoutBuilder — measure row width for breakpoint resolution
  4. Semantics — accessibility (optional)

No custom painting or complex gesture handling—the grid is pure composition.


Last updated: 2026-08-16
Related documents: Milestone 2, Architecture, Design Tokens, Roadmap, Decisions (D9)

Clone this wiki locally