Skip to content
Kenny Mochizuki Escalona edited this page Aug 16, 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.
  • Responsive by container width: Breakpoints are resolved from the row's own measured width, not the full screen—a LayrzRow inside a 400px sidebar behaves as xs even on a 4K display. This makes the grid composable: a row can be placed anywhere and adapt to its container, not just the viewport.
  • 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 determined by the row's measured width (not screen width by default):

Band Width Range (px) Constant Selection Logic
xs < 600 kExtraSmallGrid = 600 Mobile: phones, small tablets (landscape)
sm 600–959 kSmallGrid = 960 Tablets (portrait)
md 960–1263 kMediumGrid = 1264 Tablets (landscape), small desktops
lg 1264–1903 kLargeGrid = 1904 Large desktops
xl ≥ 1904 Extra-large displays

Constants are exported from package:layrz_ui/constants.dart and defined in lib/src/constants/grid.dart.

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) (private): Given a width in logical pixels, returns the resolved span by checking each breakpoint's constant.

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,
  bool useScreenWidth = false,
})

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.base (usually 8.0 dp). Pass 0 explicitly for flush layouts.
useScreenWidth bool false When false (default), breakpoints resolve from the row's own measured width (container-relative). When true, resolves from MediaQuery.sizeOf(context).width (viewport-relative). See Container-Relative vs. Viewport-Relative below.

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.base 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.base. 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.

Container-Relative vs. Viewport-Relative: useScreenWidth

By default, LayrzRow resolves its breakpoint from its own measured width, not the screen width. This makes the grid composable: it adapts to its container.

Container-Relative (Default: useScreenWidth: false)

// Sidebar = 400px wide. Row measures 400px.
// At 400px, breakpoint = xs (< 600).
// A column with xs: 12 spans full width (of the 400px row), not the screen.
SizedBox(
  width: 400,
  child: LayrzRow(
    useScreenWidth: false, // default
    children: [
      LayrzCol(xs: 12, md: 6, child: ...), // Renders full-width in sidebar, ignoring md
    ],
  ),
)

// On a 4K display (width 4000px):
// - Row width = 400px
// - Breakpoint = xs (not xl, even though viewport is huge)
// - Column spans 12 (full 400px width)

Use case: Composable components (sidebars, cards, modals) that should adapt to their container, not the viewport.

Viewport-Relative: useScreenWidth: true

// Same 400px sidebar, but useScreenWidth: true.
// Row still measures 400px, but breakpoint resolves from viewport width (4000px on 4K).
// At 4000px, breakpoint = xl.
// A column with md: 6, xl: 3 would use xl's value (3).
LayrzRow(
  useScreenWidth: true,  // Use screen width, not row width
  children: [
    LayrzCol(xs: 12, md: 6, xl: 3, child: ...), // Uses xl: 3 on 4K, renders 3/12 width (25%)
  ],
)

// Visual result: 400px available, 3/12 span = ~100px column width, +300px empty space.

Use case: Rare. CSS/Bootstrap semantics where breakpoints are always viewport-relative, regardless of container size.

Critical Note on Pixel Widths

useScreenWidth changes span selection only. Pixel widths always come from the row's own box.

Whether useScreenWidth is true or false, the available width for sizing columns is always the row's measured width, not the screen width. This prevents a column in a 400px row from trying to render at 25% of a 4K screen (100px of 4000px), which would be invisible and broken.


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.
  • useScreenWidth parameter added (default false): Controls whether breakpoints resolve from row width or screen width. layrz_theme defaulted to screen width (CSS semantics); layrz_ui defaults to container width (Flutter composability). Pass useScreenWidth: true for CSS-like behaviour.
  • spacing is theme-aware by default: Null spacing reads context.tokens.spacing.base from the theme. Explicit 0 gives flush layouts.
  • LayrzConstrainedView is new: Provides page-level constraint and centring (Bootstrap .container).

Dependencies

  • M1 Theme System (LayrzTheme, LayrzThemeData, LayrzTokens) — for theme-aware spacing defaults
  • Constants (kExtraSmallGrid, kSmallGrid, kMediumGrid, kLargeGrid) — exported from package:layrz_ui/constants.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