-
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.
-
Responsive by container width: Breakpoints are resolved from the row's own measured width, not the full screen—a
LayrzRowinside a 400px sidebar behaves asxseven 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:
LayrzConstrainedViewprovides the Bootstrap.containerpattern—a fixed-width, horizontally-centred column for page-level layout constraints.
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.
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)(private): Given a width in logical pixels, returns the resolved span by checking each breakpoint's constant.
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,
})| 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. |
-
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.basefrom 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.base. 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.
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.
// 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.
// 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.
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.
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. -
useScreenWidthparameter 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). PassuseScreenWidth: truefor CSS-like behaviour. -
spacingis theme-aware by default: Null spacing readscontext.tokens.spacing.basefrom the theme. Explicit0gives flush layouts. -
LayrzConstrainedViewis new: Provides page-level constraint and centring (Bootstrap.container).
-
M1 Theme System (
LayrzTheme,LayrzThemeData,LayrzTokens) — for theme-aware spacing defaults -
Constants (
kExtraSmallGrid,kSmallGrid,kMediumGrid,kLargeGrid) — exported frompackage:layrz_ui/constants.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