# Responsive grid

> A CSS grid that fits as many columns as a minimum item width allows, or takes explicit counts per breakpoint. Works in sidebars through container queries and lines card parts up with subgrid.

- ID: `cmp_responsive_grid_01`
- Slug: `responsive-grid-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/responsive-grid-01?variant=default
- Preview: https://pagesugar.com/preview/responsive-grid-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `default` | Default | yes | `sha256-82abc83ced21ca39ab72dedcd7982c540a6bf8f474402f68d82ecb207da8e0c7` |

## 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/responsive-grid-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Pass items as children; each direct child is one grid item, and with as="ul" each must be an li. The grid paints nothing of its own: no card, colour, heading or empty state. It does no masonry, no mixed spans and no reordering; items always appear in source order.

Required props: `children`

```svelte
<script lang="ts">
	import ResponsiveGrid from '$lib/components/responsive-grid-01/ResponsiveGrid.svelte';

	const templates = [
		{ title: 'Product roadmap', body: 'Quarters across the top, one lane per team.', meta: 'Timeline · 4 lanes' },
		{ title: 'Sprint board', body: 'To do, In progress, Review and Done.', meta: 'Board · 4 columns' },
		{ title: 'Launch plan', body: 'Every step from beta to general availability.', meta: 'Checklist · 18 tasks' }
	];
</script>

<ResponsiveGrid as="ul" minItemWidth="16rem" itemRows={3} aria-label="Templates">
	{#each templates as t (t.title)}
		<li class="gap-y-2 rounded-xl bg-white p-5 ring-1 ring-black/8">
			<h3 class="text-base font-semibold">{t.title}</h3>
			<p class="text-sm text-zinc-600">{t.body}</p>
			<p class="text-xs text-zinc-500">{t.meta}</p>
		</li>
	{/each}
</ResponsiveGrid>
```

Limitations:

- No masonry and no items spanning several columns; an asymmetric layout is a bento grid, a different component.
- itemRows needs every item to have exactly that many direct children, in the same order. A subgrid cannot add rows, so an extra child overlaps the last row; group optional content inside an existing part.
- With itemRows, the grid's gap also separates the rows inside an item unless the item sets its own row gap (gap-y-\*), which the example does.
- itemRows uses CSS subgrid (Chrome 117, Safari 16, Firefox 71). In older browsers each item still spans its rows as its own grid, so the layout holds but parts no longer line up across items.
- Container mode wraps the grid in a div with container-type: inline-size and queries that div's width. The class prop lands on the wrapper in this mode, so a width cap or flex-1 there sizes what is measured; inside a flex row the wrapper has no intrinsic width until you give it one.
- In auto mode with repeat="fit", columns that no item occupies collapse, so one or two items widen to fill the row. Once a row is full, a short last row keeps the column width. Use repeat="fill" to keep every item one column wide.
- An empty grid renders an empty element. Render your own empty state instead of the grid when there are no items.
- With itemRows, each direct child's display is set to grid, which overrides a flex or hidden class on it. Wrap an item that needs its own layout in a plain element, and filter hidden items out rather than hiding them.

## Usage guide

### Responsive grid

A layout primitive: it places its children in columns and paints nothing itself.

#### Auto or columns

- **Auto** (`mode="auto"`, the default) fits as many columns as `minItemWidth` allows. The
  minimum is wrapped in `min(100%, …)`, so a 24rem minimum inside a 320 px column becomes one
  full-width column instead of overflowing.
- **Columns** (`mode="columns"`) takes counts per step: `base`, `sm` (640 px), `md` (768 px)
  and `lg` (1024 px). A step you leave out inherits the one below.

`repeat="fit"` collapses columns that no item occupies, so one or two items widen to fill the
row. Once a row is full, a short last row keeps the column width. Use `repeat="fill"` when
items should stay one column wide, for example a gallery that usually holds one or two items.

#### Container mode

With `container`, breakpoints and gap steps are measured against the grid's own width, at the
same 40, 48 and 64rem thresholds as the viewport steps. The grid is wrapped in a `div` with
`container-type: inline-size`, because an element cannot query itself, and `class` lands on
that wrapper, so `max-w-md` or `flex-1` sizes the width being measured. Inside a flex row the
wrapper has no intrinsic width until you give it one.

#### Aligned card parts

Set `itemRows` to the number of parts each item has. Every item then spans that many rows as
a subgrid, so the second part of each card starts on the same line as the second part of its
neighbour, whatever the copy length above it:

```svelte
<ResponsiveGrid as="ul" itemRows={3} aria-label="Treatments">
	{#each treatments as t (t.title)}
		<li class="gap-y-2 rounded-xl p-5 ring-1 ring-black/8">
			<h3>{t.title}</h3>
			<p>{t.body}</p>
			<p class="self-end">{t.price}</p>
		</li>
	{/each}
</ResponsiveGrid>
```

Give each item its own `gap-y-*`; without it the rows inside an item are as far apart as the
items themselves. Every item needs exactly `itemRows` direct children: a subgrid cannot add
rows, so an extra child overlaps the last one. `itemRows` also sets each item's `display` to
`grid`, so wrap an item that needs its own flex layout in a plain element.

Subgrid shipped in Chrome 117, Safari 16 and Firefox 71. Older browsers keep the layout but
lose the shared lines.

#### Empty

The grid renders an empty element when it has no children. Render your own empty state in
its place.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `mode` | 'auto' \| 'columns' | no | `'auto'` | auto fits as many columns as minItemWidth allows; columns uses the counts in columns. |
| `minItemWidth` | '12rem' \| '16rem' \| '20rem' \| '24rem' | no | `'16rem'` | Auto mode: the narrowest an item may get before the grid drops a column. Capped at the grid's width, so it never overflows. |
| `repeat` | 'fit' \| 'fill' | no | `'fit'` | Auto mode: fit collapses columns no item occupies, so a single item widens to fill the row; fill keeps them, so every item stays one column wide. |
| `columns` | { base?: 1 \| 2; sm?: 1 \| 2 \| 3; md?: 2 \| 3 \| 4; lg?: 2 \| 3 \| 4 \| 6 } | no | `{ base: 1, sm: 2, lg: 3 }` | Columns mode: count per breakpoint (base, 640, 768 and 1024 px). Unset steps inherit the one below. |
| `gap` | 'sm' \| 'md' \| 'lg' | no | `'md'` | Space between items: 12 to 16 px, 16 to 24 px or 24 to 32 px, stepping up from 640 px. |
| `container` | `boolean` | no | `false` | Take breakpoints and gap steps from the grid's own width (container queries) instead of the viewport, for sidebars and unknown columns. |
| `itemRows` | 2 \| 3 \| 4 \| 5 | no |  | Each item spans this many rows as a subgrid, so its first N children line up with the same parts of its neighbours. Omitted, items share a height only. |
| `as` | 'div' \| 'ul' | no | `'div'` | Element to render. ul adds role="list" (not overridable) so Safari keeps list semantics; its children must be li. |
| `class` | `ClassValue` | no |  | Extra classes on the outermost element: the grid, or the query wrapper in container mode, so a width cap limits what the container query measures. |
| `children` | `Snippet` | yes |  | The items. Each direct child is one grid item. |

## Customization

The grid has no colours or surfaces, so it declares no tokens. Change layout through props; anything else (aria-label, id, data attributes) passes through to the grid element.

- Pick auto mode when items should be about the same width wherever they land; pick columns mode when the design fixes a count per breakpoint.
- Sidebars and unknown columns: set container so the column count follows the space the grid actually has.
- Aligned cards: give each item the same N parts (media, title, text, footer) and set itemRows to N. Set the item's own gap-y-\* for the spacing inside it.
- Other widths: the class maps at the top of the script are plain lookups. Add a width by adding one complete class string, for example 'grid-cols-\[repeat(auto-fit,minmax(min(100%,14rem),1fr))\]'.
- Labelling: with as="ul", pass aria-label or aria-labelledby so the list has a name.

No public CSS variables.

## Accessibility

- Items render in source order: no dense packing, no order or placement utilities. Reading order, tab order and visual order match.
- as="ul" renders a list with role="list", because Safari drops list semantics from a ul whose list-style is none. Name it with aria-label or aria-labelledby when the page has more than one.
- The grid is not interactive and adds no roles beyond the list. Headings, links and focus styles inside the items are the consumer's.
- Items can shrink below their content width, so long words wrap only if the item's text allows it; add break-words or overflow-wrap: anywhere to titles that may hold filenames or URLs.
- A div grid is a generic element: an aria-label on it names nothing. Add role="group" with the label when the items form a named group.

Known limitations:

- No keyboard grid navigation; items are reached with Tab in the usual way. A widget grid with arrow keys is a different pattern.

## License

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

## Source

- Palette: Default (`default`)
- Entry: `ResponsiveGrid.svelte`
- Suggested directory: `src/lib/components/responsive-grid-01`
- Files: 1
- Artifact digest: `sha256-82abc83ced21ca39ab72dedcd7982c540a6bf8f474402f68d82ecb207da8e0c7`

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

#### `ResponsiveGrid.svelte`

Role: entry · 5582 bytes · SHA-256 `fb6f0c4f586b438ab961e74321b56a24c827c75f3c1d45b0194546d7feaa5ea2`

```svelte
<script lang="ts" module>
	export type ResponsiveGridColumns = {
		/** Columns below the first breakpoint. Default 1. */
		base?: 1 | 2;
		/** From 40rem (640 px) of viewport, or of container width in container mode. */
		sm?: 1 | 2 | 3;
		/** From 48rem (768 px). */
		md?: 2 | 3 | 4;
		/** From 64rem (1024 px). */
		lg?: 2 | 3 | 4 | 6;
	};
</script>

<script lang="ts">
	import type { Snippet } from 'svelte';
	import type { ClassValue, HTMLAttributes } from 'svelte/elements';

	interface Props extends Omit<HTMLAttributes<HTMLElement>, 'class' | 'children'> {
		/** `auto` fits as many columns as the minimum item width allows; `columns` sets counts per breakpoint. */
		mode?: 'auto' | 'columns';
		/** Auto mode: the narrowest an item may get before the grid drops a column. */
		minItemWidth?: '12rem' | '16rem' | '20rem' | '24rem';
		/** Auto mode: `fit` collapses unused columns so fewer items widen to fill the row; `fill` keeps them. */
		repeat?: 'fit' | 'fill';
		/** Columns mode: column count per breakpoint. */
		columns?: ResponsiveGridColumns;
		/** Space between items; each preset steps up once the grid has room. */
		gap?: 'sm' | 'md' | 'lg';
		/** Respond to the width of the grid's own box instead of the viewport. */
		container?: boolean;
		/** Line each item's first N children up with its neighbours on shared rows (CSS subgrid). */
		itemRows?: 2 | 3 | 4 | 5;
		/** `ul` for a list of items; children are then `li` elements. */
		as?: 'div' | 'ul';
		/** Extra classes on the outermost element (the query wrapper in container mode), for margins or a width cap. */
		class?: ClassValue;
		/** The items. Each direct child is one grid item. */
		children: Snippet;
	}

	let {
		mode = 'auto',
		minItemWidth = '16rem',
		repeat = 'fit',
		columns,
		gap = 'md',
		container = false,
		itemRows,
		as = 'div',
		class: className,
		children,
		...rest
	}: Props = $props();

	/*
	 * Every class below is a complete static string so Tailwind can see it. `min(100%, …)` lets
	 * the minimum give way inside anything narrower than itself, so 24rem never overflows 360 px.
	 */
	const AUTO = {
		fit: {
			'12rem': 'grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))]',
			'16rem': 'grid-cols-[repeat(auto-fit,minmax(min(100%,16rem),1fr))]',
			'20rem': 'grid-cols-[repeat(auto-fit,minmax(min(100%,20rem),1fr))]',
			'24rem': 'grid-cols-[repeat(auto-fit,minmax(min(100%,24rem),1fr))]'
		},
		fill: {
			'12rem': 'grid-cols-[repeat(auto-fill,minmax(min(100%,12rem),1fr))]',
			'16rem': 'grid-cols-[repeat(auto-fill,minmax(min(100%,16rem),1fr))]',
			'20rem': 'grid-cols-[repeat(auto-fill,minmax(min(100%,20rem),1fr))]',
			'24rem': 'grid-cols-[repeat(auto-fill,minmax(min(100%,24rem),1fr))]'
		}
	} as const;

	const BASE = { 1: 'grid-cols-1', 2: 'grid-cols-2' } as const;

	/* Viewport breakpoints, and the same widths measured against the grid's container. */
	const VIEWPORT = {
		sm: { 1: 'sm:grid-cols-1', 2: 'sm:grid-cols-2', 3: 'sm:grid-cols-3' },
		md: { 2: 'md:grid-cols-2', 3: 'md:grid-cols-3', 4: 'md:grid-cols-4' },
		lg: { 2: 'lg:grid-cols-2', 3: 'lg:grid-cols-3', 4: 'lg:grid-cols-4', 6: 'lg:grid-cols-6' }
	} as const;

	const CONTAINER = {
		sm: {
			1: '@min-[40rem]:grid-cols-1',
			2: '@min-[40rem]:grid-cols-2',
			3: '@min-[40rem]:grid-cols-3'
		},
		md: {
			2: '@min-[48rem]:grid-cols-2',
			3: '@min-[48rem]:grid-cols-3',
			4: '@min-[48rem]:grid-cols-4'
		},
		lg: {
			2: '@min-[64rem]:grid-cols-2',
			3: '@min-[64rem]:grid-cols-3',
			4: '@min-[64rem]:grid-cols-4',
			6: '@min-[64rem]:grid-cols-6'
		}
	} as const;

	/* 12 → 16, 16 → 24 and 24 → 32 px: tight on phones and in sidebars, roomier with width. */
	const GAP = {
		viewport: { sm: 'gap-3 sm:gap-4', md: 'gap-4 sm:gap-6', lg: 'gap-6 sm:gap-8' },
		container: {
			sm: 'gap-3 @min-[40rem]:gap-4',
			md: 'gap-4 @min-[40rem]:gap-6',
			lg: 'gap-6 @min-[40rem]:gap-8'
		}
	} as const;

	/*
	 * Each item spans N rows of the parent and adopts them, so its parts share lines across a row.
	 * This owns the item's display: wrap an item that needs its own flex layout in a plain element.
	 */
	const ROWS = {
		2: '*:row-span-2 *:grid *:grid-rows-subgrid',
		3: '*:row-span-3 *:grid *:grid-rows-subgrid',
		4: '*:row-span-4 *:grid *:grid-rows-subgrid',
		5: '*:row-span-5 *:grid *:grid-rows-subgrid'
	} as const;

	const DEFAULT_COLUMNS: ResponsiveGridColumns = { base: 1, sm: 2, lg: 3 };

	const tracks = $derived.by(() => {
		if (mode === 'auto') return AUTO[repeat][minItemWidth];
		const cols = columns ?? DEFAULT_COLUMNS;
		const steps = container ? CONTAINER : VIEWPORT;
		return [
			BASE[cols.base ?? 1],
			cols.sm && steps.sm[cols.sm],
			cols.md && steps.md[cols.md],
			cols.lg && steps.lg[cols.lg]
		];
	});
</script>

{#snippet grid()}
	<!--
		Items flow in DOM order (no dense packing), so reading and tab order match what is seen.
		`min-w-0` stops a long URL or filename widening its column past the track.
	-->
	<svelte:element
		this={as}
		{...rest}
		role={as === 'ul' ? 'list' : rest.role}
		class={[
			'responsive-grid grid *:min-w-0',
			tracks,
			GAP[container ? 'container' : 'viewport'][gap],
			itemRows && ROWS[itemRows],
			!container && className
		]}
	>
		{@render children?.()}
	</svelte:element>
{/snippet}

{#if container}
	<!--
		An element cannot query its own width, so container mode measures this wrapper. The
		consumer's classes land here, so a width cap or flex-1 sizes what is measured.
	-->
	<div class={['responsive-grid-container @container', className]}>
		{@render grid()}
	</div>
{:else}
	{@render grid()}
{/if}
```

## Artifacts

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

- Artifact digest: `sha256-82abc83ced21ca39ab72dedcd7982c540a6bf8f474402f68d82ecb207da8e0c7`
- Entry: `ResponsiveGrid.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_responsive_grid_01/1.0.0/default/sha256-82abc83ced21ca39ab72dedcd7982c540a6bf8f474402f68d82ecb207da8e0c7/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_responsive_grid_01/1.0.0/default/sha256-82abc83ced21ca39ab72dedcd7982c540a6bf8f474402f68d82ecb207da8e0c7/bundle.zip (5826 bytes, sha256 `b995fec4d1b5599d6574c8b44bbb0bafb534281f7a79c1889b4619276a9f9ec8`)

Files:

- `ResponsiveGrid.svelte` (entry, 5582 bytes): https://pagesugar.com/artifacts/cmp_responsive_grid_01/1.0.0/default/sha256-82abc83ced21ca39ab72dedcd7982c540a6bf8f474402f68d82ecb207da8e0c7/source/ResponsiveGrid.svelte
