2 columns, from the Building blocks group of the nertia component library.
npx shadcn@latest add https://ner.sh/r/layout-container.jsonThe recipe carries this block's layout and variant; the install brings the component.
{
"registries": {
"@nertia": "https://ner.sh/r/{name}.json"
}
}npx shadcn@latest add @nertia/layout-containernpm create nersh@latest my-site -- --site https://nertia.ai/components/building-blocks/element-layout-container-grid-2/site.json scaffolds a project from it.{
"slug": "element-layout-container-grid-2",
"mode": "builder",
"status": "draft",
"skinId": "zinc",
"composition": {
"id": "element-layout-container-grid-2",
"sections": [
{
"id": "layout-container",
"instanceId": "stage-layout-container",
"layout": "grid-2"
}
]
},
"copy": {
"stage-layout-container.align": "",
"stage-layout-container.phone": ""
}
}
import type { Section } from '../types';
import { Component } from './Component';
import { schema } from './schema';
import { meta } from './meta';
import { writeCopy } from './writeCopy';
export const layoutContainer: Section = { meta, schema, Component, writeCopy };
import { Children, Fragment, isValidElement, type ReactNode } from 'react';
import type { SectionProps } from '../types';
import { LayoutDropHint } from '@/components/site/LayoutDropHint';
import {
layoutBlockColClasses,
resolveLayoutBlockMode,
CONTAINER_GAP_CLASS,
CONTAINER_ALIGN_CLASS,
CONTAINER_JUSTIFY_CLASS,
CONTAINER_PHONE_ROW_CLASS,
CONTAINER_PHONE_ROW_GAP,
parseContainerPhone,
layoutBlockClasses,
type LayoutBlockMode,
} from '@/lib/patterns/blocks';
import { cn } from '@/lib/utils';
/**
* Structural template only (no gap) — the node's own `gap` field is the single source of
* the gap class (`CONTAINER_GAP_CLASS`), so this never reaches for `layoutBlockClasses`'s
* built-in per-mode gap.
*/
/** A cluster inside a cover or centered band centres its row, as the band centres everything else. */
const CLUSTER_BAND_JUSTIFY =
'[.site-compose-cover-card_&]:justify-center [.site-compose-cover-plain_&]:justify-center [.site-compose-centered_&]:justify-center';
/** A grid cell lets its column or card fill the row height, so cells share one height. */
const GRID_CELL_FILL = '[&>.site-nested-block]:flex [&>.site-nested-block]:flex-col';
/**
* Zigzag alternation: a row (a `.site-nested-block` direct child, typically a nested
* `split` layout-container) already lays its own two columns side by side from
* `@cq-md/site:` up. Every even row keeps that same left-to-right DOM order but swaps
* which side each column renders on, via `order` on the row's own two children —
* the same order-first/order-last vocabulary `about/Component.tsx` uses for its
* copy/media flip, just reached through the row wrapper instead of set on the leaf.
*/
const ZIGZAG_ROW_FLIP = cn(
'[&>.site-nested-block:nth-child(even)>.site-layout-container>.site-nested-block:first-child]:@cq-md/site:order-last',
'[&>.site-nested-block:nth-child(even)>.site-layout-container>.site-nested-block:last-child]:@cq-md/site:order-first',
);
function containerStructureClass(mode: LayoutBlockMode): string {
if (mode === 'stack' || mode === 'zigzag') return 'flex flex-col';
// A cluster is an inline wrapped row (chips, pills): it hugs content and
// wraps at any width instead of collapsing to a one-column grid.
if (mode === 'cluster') return 'flex flex-wrap';
return cn('grid', layoutBlockColClasses(mode));
}
/** Count direct slot peers; unwraps renderer Fragments (Children.toArray does not). */
function slotChildCount(slotContent: ReactNode): number {
if (slotContent == null || typeof slotContent === 'boolean') return 0;
if (Array.isArray(slotContent)) {
return slotContent.reduce<number>((n, child) => n + slotChildCount(child), 0);
}
if (isValidElement(slotContent) && slotContent.type === Fragment) {
return slotChildCount(
(slotContent.props as { children?: ReactNode }).children,
);
}
return Children.count(slotContent);
}
export function Component({
slotContent,
layout,
childSpans,
gap,
align,
justify,
copy,
builderInsert,
}: SectionProps) {
const childCount = slotChildCount(slotContent);
const mode = resolveLayoutBlockMode(layout, childCount);
// Explicit spans are the truth: the children carry their own col-span-N, so the
// container drops the preset template for the raw 12-column grid.
const spanned = Boolean(childSpans && childSpans.length > 0);
// A node without a gap keeps the shipped defaults (the raw grid's gutter, the
// preset template's per-mode gap), so sites built before the field render as before.
const gapClass = gap ? CONTAINER_GAP_CLASS[gap] : undefined;
// A cluster (chips, pills, a CTA pair) centres its items on the cross axis and takes its
// justification from the band, so a cover or centered band centres the row.
const effectiveAlign = align ?? (mode === 'cluster' ? 'center' : 'stretch');
const alignClass = CONTAINER_ALIGN_CLASS[effectiveAlign];
const justifyClass = justify
? CONTAINER_JUSTIFY_CLASS[justify]
: mode === 'cluster'
? CLUSTER_BAND_JUSTIFY
: undefined;
// Closed copy slot, distinct from the `align` node field above (that one is cross-axis
// items-*; this one is a content-centring toggle authors set per instance). A stack
// centres on the cross axis same as items-center would; a cluster/grid centres its
// tracks on the main axis instead. Either way the container's own text centres and
// hands `--heading-align` to a nested block-heading, matching the band-level rule in
// layout-section/Component.tsx (`text-start [--heading-align:start]` on a stack band).
const contentCentered = (copy?.align ?? '').trim().toLowerCase() === 'center';
const isStackLike = mode === 'stack' || mode === 'zigzag';
const centerClass = contentCentered
? cn('text-center', '[--heading-align:center]', isStackLike ? 'items-center' : 'justify-center')
: undefined;
// A kept row (a header bar) is one spread row at every width, whatever the mode stacks to.
const keepRow = parseContainerPhone(copy?.phone) === 'row';
if (!slotContent && !builderInsert) return null;
const showChrome = Boolean(builderInsert);
return (
<div
data-section="layout-container"
data-layout={mode}
data-gap={gap ?? undefined}
data-align={effectiveAlign}
data-phone={keepRow ? 'row' : undefined}
className={cn(
'site-layout-container mx-auto w-full max-w-[var(--site-container-max,64rem)]',
showChrome && 'min-h-[4rem] rounded-lg border border-dashed border-border/60 p-4',
slotContent && keepRow
? cn(CONTAINER_PHONE_ROW_CLASS, gapClass ?? CONTAINER_PHONE_ROW_GAP, centerClass)
: slotContent
? cn(
spanned
// One real track below md: a 12-track grid at phone width owes
// eleven inter-column gaps, and a gutter-sized gap alone can
// exceed the container, collapsing every track to 0 and
// pushing a full-row item past the viewport (the clipped Cart).
? cn('grid grid-cols-1 @cq-md/site:grid-cols-12', gapClass ?? 'gap-gutter')
: gapClass
? cn(containerStructureClass(mode), gapClass)
: layoutBlockClasses(mode),
// A rail's cells stay blocks, so a sticky column keeps its own height and can pin.
!['stack', 'zigzag', 'cluster', 'rail'].includes(mode) && GRID_CELL_FILL,
mode === 'zigzag' && ZIGZAG_ROW_FLIP,
alignClass,
justifyClass,
centerClass,
)
: undefined,
)}
>
{slotContent ? (
slotContent
) : (
<LayoutDropHint label="Add column or component" />
)}
</div>
);
}
import type { SectionCopySchemaField } from '../types';
export const schema: SectionCopySchemaField[] = [
{
key: 'align',
label: 'Content',
type: 'text',
placeholder: '',
maxLength: 8,
advanced: true,
group: 'Structure',
helpText:
'Center centers everything inside this container, including any headings inside it. Default aligns content to the start.',
options: [
{ value: '', label: 'Default' },
{ value: 'center', label: 'Center' },
],
},
{
key: 'phone',
label: 'On a phone',
type: 'text',
placeholder: '',
maxLength: 8,
advanced: true,
group: 'Structure',
helpText:
'Keep one row holds the items side by side on every screen, first item at its own size and the last aligned to the end. Default stacks them on a phone.',
options: [
{ value: '', label: 'Default' },
{ value: 'row', label: 'Keep one row' },
],
},
];
import type { SectionMeta } from '../types';
export const meta: SectionMeta = {
id: 'layout-container',
displayName: 'Container',
family: 'structure',
tags: ['structure', 'layout', 'container'],
layouts: [
{ id: 'stack', label: 'Stack' },
{ id: 'split', label: 'Split' },
{ id: 'split-40-60', label: 'Split 40/60' },
{ id: 'split-60-40', label: 'Split 60/40' },
{ id: 'grid-2', label: '2 columns' },
{ id: 'grid-3', label: '3 columns' },
{ id: 'grid-4', label: '4 columns' },
{ id: 'zigzag', label: 'Zigzag' },
{ id: 'cluster', label: 'Cluster' },
{ id: 'rail', label: 'Rail' },
],
};
import type { BrandContext } from '@/lib/brandContext';
export function writeCopy(_ctx: BrandContext) {
return {};
}