-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
- 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
mdis not specified, it inherits fromsm, thenxs. Onlyxsis 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
LayrzRowinside 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:
LayrzConstrainedViewprovides the Bootstrap.containerpattern—a fixed-width, horizontally-centred column for page-level layout constraints.
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 = 600means the xs band is< 600; sm band starts at 600 -
sm = 960means the sm band is600–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.
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
)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
})| 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. |
- 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.
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,
})| 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. |
-
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:
spacingcontrols the horizontal gap between columns in a visual row and the vertical gap between wrapped rows. -
Theme-aware spacing default: If
spacingis null, the layout readscontext.tokens.spacing.sp2from the theme. This allows design system control over default spacing without hardcoding.
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,
})| 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. |
-
Fixed alignment (by design): The internal
Columnis fixed tomainAxisAlignment: MainAxisAlignment.startandcrossAxisAlignment: 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
maxWidthrenders at its natural width.
LayrzRow uses two independent width concepts to ensure composability and correctness:
-
Breakpoint width — always the viewport width from
MediaQuery.sizeOf(context).width, resolved usingcontext.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 customLayrzBreakpointTokensand passing it via your theme toLayrzApp. -
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.
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.
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 cascadesxl ?? lgand lands on itslgvalue
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.
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'),
),
],
)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.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(),
),
],
)// 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(),
],
)LayrzRow(
spacing: 0, // Explicit zero for flush, no gaps
children: [
LayrzCol(xs: 6, child: Text('Left')),
LayrzCol(xs: 6, child: Text('Right')),
],
)Columns are grouped greedily into visual rows based on their resolved span at the current 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)| 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 |
-
Spans are plain
int(1–12), not enum values. This is simpler and follows Flutter conventions. -
ResponsiveRow.builderremoved: UseList.generateto build the children list inline. -
Viewport-driven breakpoints: Breakpoint selection is always based on viewport width (standard CSS/Bootstrap semantics), not container width. The
useScreenWidthescape 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,kLargeGridhave been deleted. Breakpoint thresholds are now stored inLayrzBreakpointTokens(default thresholds: 600, 960, 1264, 1904) and are themeable. Apps can customize them by passing a custom theme toLayrzApp. -
spacingis theme-aware by default: Null spacing readscontext.tokens.spacing.sp2from the theme. Explicit0gives flush layouts. -
LayrzConstrainedViewis new: Provides page-level constraint and centring (Bootstrap.container). -
context.breakpointgetter is new: Quickly determine the current breakpoint band from viewport width without manual calculation.
-
M1 Theme System (
LayrzTheme,LayrzThemeData,LayrzTokens) — for theme-aware spacing defaults and breakpoint tokens -
Breakpoint Tokens (
LayrzBreakpointTokens,LayrzBreakpoint) — frompackage:layrz_ui/layrz_ui.dart -
Flutter primitives (
Row,Column,LayoutBuilder) — frompackage:flutter/widgets.dart
The grid is built entirely from Flutter primitives without Material or Cupertino imports:
- Row — horizontal layout of columns
-
Column — internal structure of
LayrzConstrainedView - LayoutBuilder — measure row width for breakpoint resolution
- 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)
Made with ❤️ by Golden M, Inc.
- LayrzTextInput
- LayrzSelectableAction
- LayrzSelectionToolbar
- LayrzTextSelectionControls
- LayrzSelectionMagnifier
- LayrzSelectionHandlePainter
- LayrzTextAreaInput
- LayrzComboBoxInput
- LayrzNumberInput
- LayrzPasswordInput
- LayrzCheckboxInput
- LayrzRadioInput
- LayrzSelectInput
- LayrzMultiSelectInput
- LayrzSearchInput
- LayrzDualListInput
- LayrzDurationInput