# Split layout

> Two content regions side by side on proportional columns, stacking to one column in a chosen order below a breakpoint measured on the component's own width. The DOM order is always primary then secondary.

- ID: `cmp_split_layout_01`
- Slug: `split-layout-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-09-30
- Updated: 2026-09-30
- Available versions: `1.0.0`
- Kind: control
- Primary category: `layout`
- Detail page: https://pagesugar.com/components/split-layout-01
- Preview: https://pagesugar.com/preview/split-layout-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `default` | Default | yes | `sha256-883efa8d67c109742b516ea82b2d8d691bfc48272b14f95564512ad9aae8a91c` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: presentational
- Appearance modes: light
- Suggested directory: `src/lib/components/split-layout-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Pass two snippets, primary and secondary, and pick a ratio, alignment, gap and breakpoint. The component is layout only: it paints no colour, sets no type and adds no section padding or max width, so put it inside your own section container. It holds exactly two regions, carries no landmark role (use a content-and-sidebar layout for an aside with navigation) and is not sticky. Reverse and secondary-first change where the regions appear, never the reading or focus order.

Required props: `primary`, `secondary`

```svelte
<script lang="ts">
	import SplitLayout from '$lib/components/split-layout-01/SplitLayout.svelte';
</script>

<section class="mx-auto max-w-6xl px-4 py-16 sm:px-6 sm:py-24 lg:px-8">
	<SplitLayout ratio="2:3" align="center">
		{#snippet primary()}
			<h2 class="text-3xl font-semibold tracking-tight text-zinc-950">Move one date. The rest of the plan keeps up.</h2>
			<p class="mt-4 text-lg text-zinc-600">Halcyon links every task to the work it waits on.</p>
		{/snippet}
		{#snippet secondary()}
			<img src="/timeline.png" alt="The Q4 launch timeline with three tasks moved" class="w-full rounded-2xl" />
		{/snippet}
	</SplitLayout>
</section>
```

Limitations:

- Exactly two regions. For three or more columns use a responsive grid; for a fixed-width, sticky or landmark sidebar use a content-and-sidebar layout.
- The breakpoint is a container query on the component's root: md at 42rem and lg at 56rem (672 and 896 px at the default root font size), chosen so md splits inside a 768 px screen's content column and lg inside a 1024 px one. A split inside a narrow column stays stacked on a wide screen. Browsers without container queries (before 2023) always show the stacked layout.
- stackOrder='secondary-first' moves the secondary region above the primary only visually. Keep the secondary media-only (an image, an illustration) when you use it, or screen reader and keyboard order will not match what is on screen.
- align='stretch' makes both regions the height of the taller one; media inside the shorter region needs h-full and object-cover (or its own aspect ratio) to fill it without distortion.
- The root uses inline-size containment, so it takes its width from its parent rather than its content. It sets w-full; inside a shrink-to-fit parent (an inline-block, a float, a row flex container whose children do not grow) give it or its parent a width.
- The tracks shrink, but content inside a region does not wrap by itself: break long words (break-words), size images with w-full or max-w-full, and put wide tables or code in their own horizontal scroller.

## Usage guide

### Split layout

A layout primitive: two regions, one grid, nothing painted. Put it inside your own section
container and give each region its own content.

```svelte
<SplitLayout ratio="2:3" align="center">
	{#snippet primary()}…text…{/snippet}
	{#snippet secondary()}…image…{/snippet}
</SplitLayout>
```

#### Choosing the props

| You have                             | Use                                                       |
| ------------------------------------ | --------------------------------------------------------- |
| Text beside a screenshot or image    | `ratio="2:3"` or `"1:1"`, `align="center"`                |
| A description beside a details panel | `ratio="2:1"`, `align="start"`, `gap="md"`                |
| Text beside a colour field or map    | `align="stretch"`, media with `h-full`                    |
| Alternating feature rows down a page | `reverse` on every other instance                         |
| A split inside a narrow app column   | `breakpoint="lg"`, or leave it: it measures its own width |

#### Reading order

The markup is always primary, then secondary. `reverse` and `stackOrder="secondary-first"`
only move the regions on screen. That is safe when the region you move ahead is media; if it
holds links, fields or text that should be read first, put that content in `primary` instead
of reordering.

#### Container, not viewport

`breakpoint` is a container query on the root, so `md` means "this component is at least
42rem wide" (672 px at the default root size, which a 768 px screen's content column clears), not "the screen is". A split in a 40rem card on a desktop stays stacked, which is
usually what you want. Replace the `@2xl:` and `@4xl:` prefixes with `md:` and `lg:` if you
prefer viewport breakpoints.

#### What the snippets own

The columns shrink to fit, but the content inside them is yours. Break long words and URLs
with `break-words`, give images `w-full` (or `h-full object-cover` with `align="stretch"`), and
put wide tables or code blocks in their own `overflow-x-auto` wrapper. Inside a shrink-to-fit
parent such as an inline-block or a non-growing flex item, give the component a width: its
container query means it sizes from its parent, not from its content.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `primary` | `Snippet` | yes |  | Main content. Always first in the DOM, so it is read and focused first. |
| `secondary` | `Snippet` | yes |  | Complementary content: media, details, a form or a summary. |
| `ratio` | '1:1' \| '2:1' \| '1:2' \| '3:2' \| '2:3' | no | `'1:1'` | Width of primary to secondary once side by side. The ratio follows the regions, so reverse keeps each region's share. |
| `reverse` | `boolean` | no | `false` | Put the secondary region on the start side (left in left-to-right text) once side by side. Visual only; the DOM keeps primary first. |
| `stackOrder` | 'primary-first' \| 'secondary-first' | no | `'primary-first'` | Which region comes first when stacked. secondary-first is visual only; use it for a media-only secondary. |
| `breakpoint` | 'md' \| 'lg' | no | `'md'` | Component width at which the regions go side by side: md at 42rem, lg at 56rem (672 and 896 px at the default root size). Measured on the component, not the viewport. |
| `align` | 'start' \| 'center' \| 'stretch' | no | `'center'` | Vertical alignment once side by side. start for text beside details, center for text beside media, stretch for equal-height regions. |
| `gap` | 'md' \| 'lg' \| 'xl' | no | `'lg'` | Space between the regions: 32, 40 or 48 px when stacked, and a wider 48, 64 or 96 px gutter when side by side. |
| `class` | `string` | no |  | Extra classes for the root element, for example a margin or a max width. |

## Customization

There are no colour tokens because the component paints nothing; everything visible comes from your snippets. Layout values are static class maps at the top of the source.

- Content: everything inside the regions is yours. Give each region its own type and surfaces in the snippets.
- Ratios: add a preset and its reciprocal to both TRACKS maps, for example '5:7' and '7:5' ('@2xl:grid-cols-\[minmax(0,5fr)\_minmax(0,7fr)\]' and the swapped template), because reverse looks up the swapped pair. Add the preset to the SplitRatio type and RATIOS list.
- Breakpoint: TRACKS, START and END use @2xl (42rem) and @4xl (56rem). Swap the prefixes for md: and lg: if you want viewport media queries instead of container queries.
- Gap: GAP maps each size to a stacked gap (gap-y-\*) and a side-by-side gutter (gap-x-\*); change them together to keep the gutter the wider one.
- Media: in the secondary snippet, give images w-full and an aspect ratio, or h-full object-cover with align='stretch'.
- Alternating rows: render several instances down a page with reverse on every other one; the reading order of each stays text first.

No public CSS variables.

## Accessibility

- The component renders plain div elements with no role or landmark. Headings, landmarks and labels inside the regions are the consumer's.
- The DOM order is always primary then secondary, in every ratio, breakpoint and direction, so reading order and focus order follow it.
- reverse and stackOrder='secondary-first' change the visual position only (WCAG 1.3.2 and 2.4.3). Use them when the region moved ahead holds media or decoration, not links, form fields or text that should be read first.
- Grid columns follow the writing direction: under dir='rtl' the start side is the right, so the primary region sits on the right unless reverse is set, which puts the secondary there. No extra classes are needed.
- Media in the secondary region needs its own text alternative, or an empty alt when it is decorative.

Known limitations:

- CSS reading-flow, which would let focus follow the visual order, is not used because it is not yet supported across browsers.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Default (`default`)
- Entry: `SplitLayout.svelte`
- Suggested directory: `src/lib/components/split-layout-01`
- Files: 1
- Artifact digest: `sha256-883efa8d67c109742b516ea82b2d8d691bfc48272b14f95564512ad9aae8a91c`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `SplitLayout.svelte`

Role: entry · 4512 bytes · SHA-256 `7e00b5692cd127db4095e0b65bae17a5a230a9f73f51256abe89ab8617e98fc1`

```svelte
<!--
	Two regions on proportional grid tracks. The markup is always primary, then secondary; every
	visual change (reverse, stack order) is CSS placement, so reading and focus order never move.
	The breakpoint is a container query on the root, so the split measures the column it sits in,
	not the viewport: md is 42rem and lg 56rem, which a 768 px or 1024 px screen's content column
	clears once page gutters are taken off.
-->
<script lang="ts">
	import type { Snippet } from 'svelte';

	type SplitRatio = '1:1' | '2:1' | '1:2' | '3:2' | '2:3';

	interface Props {
		/** Main content. Always first in the DOM, so it is read and focused first. */
		primary: Snippet;
		/** Complementary content: media, details, a form or a summary. */
		secondary: Snippet;
		/** Width of primary to secondary once side by side. */
		ratio?: SplitRatio;
		/** Put the secondary region on the start side (left in LTR) once side by side. */
		reverse?: boolean;
		/** Which region comes first when stacked. Visual only; see the accessibility notes. */
		stackOrder?: 'primary-first' | 'secondary-first';
		/**
		 * Width of the component at which the regions go side by side: md at 42rem (672 px at the
		 * default root size), lg at 56rem (896 px), so md splits in a 768 px screen's content column.
		 */
		breakpoint?: 'md' | 'lg';
		/** Vertical alignment of the two regions once side by side. */
		align?: 'start' | 'center' | 'stretch';
		/** Space between the regions. The side-by-side gutter is wider than the stacked gap. */
		gap?: 'md' | 'lg' | 'xl';
		/** Extra classes for the root element. */
		class?: string;
	}

	let {
		primary,
		secondary,
		ratio = '1:1',
		reverse = false,
		stackOrder = 'primary-first',
		breakpoint = 'md',
		align = 'center',
		gap = 'lg',
		class: className
	}: Props = $props();

	/*
	 * Column templates by breakpoint, keyed start:end. minmax(0, …) stops a region's content from
	 * widening its track; wrapping long words and sizing media stays the job of the snippets.
	 * Every key needs its reciprocal, because reverse looks up the swapped pair.
	 */
	const TRACKS = {
		md: {
			'1:1': '@2xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]',
			'2:1': '@2xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]',
			'1:2': '@2xl:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]',
			'3:2': '@2xl:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]',
			'2:3': '@2xl:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]'
		},
		lg: {
			'1:1': '@4xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]',
			'2:1': '@4xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]',
			'1:2': '@4xl:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]',
			'3:2': '@4xl:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]',
			'2:3': '@4xl:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]'
		}
	} as const;

	/* Both regions are placed explicitly on one row, so stack order never leaks into the split. */
	const START = {
		md: '@2xl:col-start-1 @2xl:row-start-1',
		lg: '@4xl:col-start-1 @4xl:row-start-1'
	} as const;
	const END = {
		md: '@2xl:col-start-2 @2xl:row-start-1',
		lg: '@4xl:col-start-2 @4xl:row-start-1'
	} as const;

	const ALIGN = {
		start: 'items-start',
		center: 'items-center',
		stretch: 'items-stretch'
	} as const;

	/* Stacked gap, then gutter: the gutter is the wider of the two at every size. */
	const GAP = {
		md: 'gap-y-8 gap-x-12',
		lg: 'gap-y-10 gap-x-16',
		xl: 'gap-y-12 gap-x-24'
	} as const;

	const RATIOS: readonly SplitRatio[] = ['1:1', '2:1', '1:2', '3:2', '2:3'];

	// Unknown values fall back to the defaults rather than rendering an unstyled grid.
	const bp = $derived(breakpoint === 'lg' ? 'lg' : 'md');
	const shares = $derived((RATIOS.includes(ratio) ? ratio : '1:1').split(':'));
	const tracks = $derived(
		TRACKS[bp][(reverse ? `${shares[1]}:${shares[0]}` : `${shares[0]}:${shares[1]}`) as SplitRatio]
	);
	const alignClass = $derived(Object.hasOwn(ALIGN, align) ? ALIGN[align] : ALIGN.center);
	const gapClass = $derived(Object.hasOwn(GAP, gap) ? GAP[gap] : GAP.lg);
</script>

<!-- Inline-size containment ignores content width, so the root claims its parent's width. -->
<div class={['@container w-full min-w-0', className]} data-split-layout>
	<div class={['grid grid-cols-1', tracks, alignClass, gapClass]}>
		<div class={['min-w-0', reverse ? END[bp] : START[bp]]} data-split-region="primary">
			{@render primary()}
		</div>
		<div
			class={[
				'min-w-0',
				reverse ? START[bp] : END[bp],
				stackOrder === 'secondary-first' && 'order-first'
			]}
			data-split-region="secondary"
		>
			{@render secondary()}
		</div>
	</div>
</div>
```

## Artifacts

### Default (`default`) (default)

- Artifact digest: `sha256-883efa8d67c109742b516ea82b2d8d691bfc48272b14f95564512ad9aae8a91c`
- Entry: `SplitLayout.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_split_layout_01/1.0.0/default/sha256-883efa8d67c109742b516ea82b2d8d691bfc48272b14f95564512ad9aae8a91c/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_split_layout_01/1.0.0/default/sha256-883efa8d67c109742b516ea82b2d8d691bfc48272b14f95564512ad9aae8a91c/bundle.zip (5211 bytes, sha256 `a5b24d9eecae25996907df6e4a567147e2578b610ff0669ad576b028926f4c3c`)

Files:

- `SplitLayout.svelte` (entry, 4512 bytes): https://pagesugar.com/artifacts/cmp_split_layout_01/1.0.0/default/sha256-883efa8d67c109742b516ea82b2d8d691bfc48272b14f95564512ad9aae8a91c/source/SplitLayout.svelte
