# Alternating feature sections

> A product tour as a run of text-and-media rows: a compact text column beside a larger framed image, the image swapping sides row by row from lg and stacking under the copy on phones.

- ID: `cmp_alternating_features_01`
- Slug: `alternating-features-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-09-30
- Updated: 2026-09-30
- Available versions: `1.0.0`
- Kind: section
- Primary category: `features`
- Detail page: https://pagesugar.com/components/alternating-features-01?variant=blue
- Preview: https://pagesugar.com/preview/alternating-features-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-8c60d440e64598857b61061c1978d1f43941b4b43d5d799f7885502eaf05ad93` |
| `blue` | Blue accent | no | `sha256-f951b57f3032ac356a244c131493f4233e62b2b8f5647ac6d75334e53ab21897` |

## 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/alternating-features-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Presentational: renders your rows, images and links exactly as supplied. It ships no images and does not resize or optimise them; supply files about 1400 px wide for a 2x screen. Images load lazily, so it is meant for sections below the hero. Each row has at most one text link; there are no buttons, video or scroll animation.

Required props: `items`

```svelte
<script lang="ts">
	import AlternatingFeatures from '$lib/components/alternating-features-01/AlternatingFeatures.svelte';
</script>

<AlternatingFeatures
	title="Bread, classes and a morning round"
	startWith="media-start"
	items={[
		{
			eyebrow: 'Order ahead',
			title: 'Your Saturday loaf, set aside',
			body: 'Order by 6 pm on Thursday and collect from 8 am.',
			points: ['Sourdough, rye and spelt', 'Change or cancel until Thursday'],
			media: { src: '/images/order.jpg', alt: 'A Saturday order slip.', width: 1200, height: 900 },
			link: { label: 'Place an order', href: '/order' }
		},
		{
			eyebrow: 'Bread school',
			title: 'Learn to bake it at home',
			body: 'Saturday classes of eight in the bakehouse.',
			media: { src: '/images/class.jpg', alt: 'The November class list.', width: 1200, height: 900 }
		}
	]}
/>
```

Limitations:

- Ships no media. The preview’s illustrations are preview-only and are not part of the export.
- Images only: no video, picture element or snippet in the frame. Rows needing art direction or a clip are a different section.
- Images use loading="lazy". If the section sits at the very top of a page, change the first image to loading="eager".
- With mediaAspect "auto" each frame takes its own file’s ratio, so mixed images give rows of different heights. Supply one shape or set "video" or "square", which crop with object-cover from the centre.
- Past five or six rows the zig-zag gets long; a grid or tabs suit a longer list better.
- Light appearance only. The tokens retone it for a dark or tinted page, but no dark mode is declared.

## Usage guide

### Alternating feature sections

A run of text-and-media rows for the middle of a landing page. From the `lg` breakpoint each row
gives its copy five parts of the width and its image seven, and the image swaps sides from row
to row. Below `lg`, every row stacks the same way: copy first, image under it.

#### Rows

Each row takes a heading and a short body, and optionally an eyebrow, up to three or four
points, one image and one link.

```svelte
<AlternatingFeatures
	title="Plan the work, then watch it move"
	numbered
	items={[
		{
			eyebrow: 'Boards',
			title: 'Every project on one board',
			body: 'Cards carry an owner, a due date and a status.',
			points: ['Backlog, Doing, Review and Done', 'Files on every card'],
			media: { src: '/img/board.png', alt: 'A board with four columns.', width: 1200, height: 900 },
			link: { label: 'Explore boards', href: '/features/boards' }
		}
	]}
/>
```

Keep bodies to one to three sentences. The text column is about 480 px wide at 1280 px; a
paragraph that runs longer than the image beside it makes the row look lopsided.

#### Alternation

`startWith` sets the first image's side (`'media-end'` by default) and each later image takes the
other side. Only rows with media count, so a text-only row in the middle does not leave two
images on the same side one after the other. Start and end follow the text direction, so under
`dir="rtl"` the whole zig-zag mirrors.

The DOM order never changes: heading, copy, link, then image. The swap is grid placement only,
so keyboard and screen reader order match the phone layout.

#### A row without media

Leave `media` off and the row spans the full width: the eyebrow and heading take the narrow
column and the body, points and link take the wide one. Use it for a capability that has no
good picture, rather than inventing one.

#### Media

Pass each file's own `width` and `height`; they reserve the frame before the image loads.

- `mediaAspect: 'auto'` (default) keeps each file's ratio. Give every row the same shape, or
  the rows will be different heights.
- `'video'` (16:9) and `'square'` (1:1) crop every image with `object-cover` from the centre.

Images load lazily. If this section is the first thing on a page, change the first image to
`loading="eager"`.

#### Numbering

`numbered` puts `01`, `02`, `03` before each eyebrow and renders the rows as an ordered list. Use
it when the rows are a tour that reads in order; leave it off for a list of services.

#### Links

One text link per row, never a button: the page's primary action belongs to the hero or a CTA
band. Make each label specific ("See how timelines work", not "Learn more"). The row heading is
attached as the link's description, but a screen reader's links list usually shows only the
label, so the label has to make sense on its own.

#### Empty

An empty `items` array renders nothing at all, not a heading with nothing under it.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `items` | `AlternatingFeature[]` | yes |  | Rows in order: { title, body, eyebrow?, points?, media?: { src, alt, width, height }, link?: { label, href } }. Width and height are the file’s intrinsic size. A row without media spans the full width. An empty array renders nothing. |
| `title` | `string` | no |  | Section heading, left-aligned above the rows. Omitted, the rows start the section. |
| `description` | `string` | no |  | One or two sentences under the section heading. Shown only with a title. |
| `startWith` | 'media-start' \| 'media-end' | no | `'media-end'` | Which side the first row’s media takes from the lg breakpoint; later media rows alternate. Start and end follow the text direction. |
| `headingLevel` | 2 \| 3 | no | `2` | Level of the section heading. Row headings are one level below it, or at this level when there is no title. |
| `mediaAspect` | 'auto' \| 'video' \| 'square' | no | `'auto'` | Frame shape for every row: the file’s own ratio, 16:9 or 1:1. The last two crop with object-cover. |
| `numbered` | `boolean` | no | `false` | Numbers the rows 01, 02, 03 before the eyebrow and renders them as an ordered list. Use it for a tour that reads in order. |

## Customization

Change content through props, retone the section through six --alternating-features-\* CSS variables, and edit Tailwind classes in the source for the column split, the row rhythm or the type scale.

- Accent: --alternating-features-accent colours the eyebrows, the point marks, the row links and the focus ring. Keep it at 4.5:1 against your page, since it sets text.
- Text: --alternating-features-ink sets the headings and points; --alternating-features-muted sets the descriptions and the row numbers. Keep muted at 4.5:1.
- Frames: --alternating-features-frame-radius rounds every media frame (16px by default; 0 for square corners), --alternating-features-hairline draws the one-pixel edge over each image, and --alternating-features-surface fills the frame while an image loads or behind a transparent one (retone it with ink on a dark page). Frames sit flat, with no shadow.
- Dark page retone: ink #fafafa, muted #a1a1aa, hairline rgb(255 255 255 / 0.1), surface rgb(255 255 255 / 0.04), accent #93c5fd (all --alternating-features-\*).
- Column split: the rows use lg:grid-cols-\[minmax(0,5fr)\_minmax(0,7fr)\] (and 7fr 5fr when the media is on the start side). Change both pairs to 1fr for an even split.
- Rhythm: rows sit 80 px apart on phones and 128 px apart from lg (gap-20 lg:gap-32 on the list), and each row keeps its image 24 px under its copy until lg. Change the list gaps together if the page around it is denser.
- Media: supply width and height from the file itself. Product screenshots read best as framed surfaces on a quiet field; photographs work with mediaAspect "square" or "video".
- Links: each link label should make sense on its own, such as "See how timelines work" rather than "Learn more". The row heading is added as the link’s description, but a links list usually shows only the label, so the label has to stand alone.
- Headings: set headingLevel to 3 when the section sits under another h2.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--alternating-features-accent` |
| `ink` | `--alternating-features-ink` |
| `muted` | `--alternating-features-muted` |
| `hairline` | `--alternating-features-hairline` |
| `surface` | `--alternating-features-surface` |
| `frameRadius` | `--alternating-features-frame-radius` |

## Accessibility

- With a title, the section is labelled by its heading (h2 by default) and each row heading is one level below it. Without a title the rows take headingLevel themselves.
- Rows are list items: an unordered list, or an ordered list when numbered is set. The visible 01, 02 numbers are hidden from assistive technology because the list already announces them.
- The copy comes before the media in the DOM at every width, so reading and tab order are heading, copy, link, image on every row. startWith and the alternation move the media with grid placement only.
- Alt text comes from you. Describe what the image shows when it carries meaning; pass alt: "" only when the row’s copy already says it.
- Each row link is a 44 px tall target with a two-pixel accent focus ring on :focus-visible. It also takes the row heading as its description (aria-describedby). Screen reader links lists usually show only the name, so every visible label still has to make sense on its own.
- Neutral text measures 17.7:1 (ink #18181b) and 7.7:1 (muted #52525b) on white; the blue accent #1d4ed8 measures 6.7:1. Ratios use the WCAG relative-luminance formula.
- Layout uses logical properties and text-start, so it mirrors under dir="rtl"; tracking resets to 0 for right-to-left and CJK text, and Japanese and Chinese headings break between phrases.
- Element IDs come from $props.id(), so two sections on one page stay unique. Hover and press motion are removed under prefers-reduced-motion.

Known limitations:

- Contrast ratios are computed for the shipped palettes only; re-check any changed token.
- The component cannot check that alt text is accurate or that link labels are descriptive.

## License

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

## Source

- Palette: Blue accent (`blue`)
- Entry: `AlternatingFeatures.svelte`
- Suggested directory: `src/lib/components/alternating-features-01`
- Files: 1
- Artifact digest: `sha256-f951b57f3032ac356a244c131493f4233e62b2b8f5647ac6d75334e53ab21897`

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

#### `AlternatingFeatures.svelte`

Role: entry · 11351 bytes · SHA-256 `9eb127a765f704fb571061a187db393f519dffb8f118c33d320ad6cb5509db54`

```svelte
<script lang="ts" module>
	/**
	 * Width and height are the file's intrinsic size. They reserve the frame's shape before the file
	 * loads. Use alt: '' only when the row's copy already says everything the image shows.
	 */
	export interface AlternatingFeatureMedia {
		src: string;
		alt: string;
		width: number;
		height: number;
	}

	export interface AlternatingFeatureLink {
		/** Visible link text. The row's heading is added as its description. */
		label: string;
		href: string;
	}

	export interface AlternatingFeature {
		/** Row heading, one level below the section heading. */
		title: string;
		/** One to three sentences of plain text. */
		body: string;
		/** Short label above the row heading, set in the accent. */
		eyebrow?: string;
		/** Supporting points, one short line each. */
		points?: string[];
		/** The row's picture. Omit it and the row spans the full width as a heading-and-copy split. */
		media?: AlternatingFeatureMedia;
		/** One text link under the copy. */
		link?: AlternatingFeatureLink;
	}
</script>

<script lang="ts">
	interface Props {
		/** Rows in order. */
		items: AlternatingFeature[];
		/** Section heading. Omitted, the rows start the section. */
		title?: string;
		/** One or two sentences under the section heading. */
		description?: string;
		/** Which side the first row's media takes from the lg breakpoint; later rows alternate. */
		startWith?: 'media-start' | 'media-end';
		/** Section heading level. Rows use one level below it, or this level when there is no title. */
		headingLevel?: 2 | 3;
		/** Frame shape for every row: the file's own ratio, 16:9 or 1:1. */
		mediaAspect?: 'auto' | 'video' | 'square';
		/** Numbers the rows 01, 02, 03 and renders them as an ordered list, for a product tour. */
		numbered?: boolean;
	}

	let {
		items,
		title,
		description,
		startWith = 'media-end',
		headingLevel = 2,
		mediaAspect = 'auto',
		numbered = false
	}: Props = $props();

	const uid = $props.id();

	const sectionTag = $derived(headingLevel === 3 ? 'h3' : 'h2');
	const rowTag = $derived(!title ? sectionTag : headingLevel === 3 ? 'h4' : 'h3');

	/*
	 * Alternation counts media rows only, so a text-only row in the middle never leaves two frames
	 * on the same side one after the other.
	 */
	const rows = $derived.by(() => {
		let mediaIndex = 0;
		return items.map((item) => {
			if (!item.media) return { item, start: false };
			const first = mediaIndex++ % 2 === 0;
			return { item, start: first === (startWith === 'media-start') };
		});
	});

	/* Reserve the frame's shape before the file loads; a missing or zero size falls back to 4:3. */
	function ratio(media: AlternatingFeatureMedia) {
		if (mediaAspect === 'video') return '16 / 9';
		if (mediaAspect === 'square') return '1 / 1';
		return media.width > 0 && media.height > 0 ? `${media.width} / ${media.height}` : '4 / 3';
	}

	const pad = (n: number) => String(n).padStart(2, '0');
</script>

{#snippet head(item: AlternatingFeature, i: number)}
	{#if numbered || item.eyebrow}
		<p
			class="alternating-features__eyebrow alternating-features__tracked mb-2 flex items-baseline gap-x-2 text-xs/4 font-medium tracking-[0.06em] break-words uppercase"
		>
			{#if numbered}
				<!-- The ordered list already announces the number. -->
				<span class="shrink-0 text-[var(--_muted)] tabular-nums" aria-hidden="true"
					>{pad(i + 1)}</span
				>
			{/if}
			{#if item.eyebrow}
				<span class="min-w-0 flex-1 text-balance text-[var(--_accent)]">{item.eyebrow}</span>
			{/if}
		</p>
	{/if}
	<svelte:element
		this={rowTag}
		id="{uid}-row-{i}"
		class="alternating-features__tracked alternating-features__heading text-2xl/[1.2] font-semibold tracking-[-0.02em] text-balance break-words text-[var(--_ink)]"
	>
		{item.title}
	</svelte:element>
{/snippet}

{#snippet copy(item: AlternatingFeature, i: number)}
	<p
		class="alternating-features__prose max-w-[35rem] text-base/6 text-pretty break-words text-[var(--_muted)] sm:text-lg/7"
	>
		{item.body}
	</p>
	{#if item.points?.length}
		<ul role="list" class="mt-6 flex max-w-[35rem] flex-col gap-2">
			{#each item.points as point, p (p)}
				<li
					class="alternating-features__prose flex gap-2 text-base/6 break-words text-[var(--_ink)]"
				>
					<!-- One line tall, so the mark stays level with the first line of a wrapped point. -->
					<span class="flex h-lh shrink-0 items-center text-[var(--_accent)]" aria-hidden="true">
						<svg
							class="size-4"
							viewBox="0 0 16 16"
							fill="none"
							stroke="currentColor"
							stroke-width="1.75"
							stroke-linecap="round"
							stroke-linejoin="round"
						>
							<path d="m3.5 8.5 3 3 6-7" />
						</svg>
					</span>
					<span class="min-w-0">{point}</span>
				</li>
			{/each}
		</ul>
	{/if}
	{#if item.link}
		<!-- The 44 px box adds 10 px above the label, so mt-4 reads as the 24 px text-to-action gap. -->
		<a
			href={item.link.href}
			class="alternating-features__link mt-4 flex min-h-11 w-fit max-w-full items-center rounded-sm text-base/6 font-medium text-[var(--_accent)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)]"
			aria-describedby="{uid}-row-{i}"
		>
			<!-- The arrow is joined to the last word (U+2060) so it follows a wrapped label. -->
			<span class="min-w-0 [overflow-wrap:anywhere]"
				>{item.link.label}&#8288;<svg
					class="alternating-features__arrow ms-2 inline-block size-4 align-[-0.1875em] rtl:-scale-x-100"
					viewBox="0 0 16 16"
					fill="none"
					stroke="currentColor"
					stroke-width="1.75"
					stroke-linecap="round"
					stroke-linejoin="round"
					aria-hidden="true"
				>
					<path d="M3 8h10M9 4l4 4-4 4" />
				</svg></span
			>
		</a>
	{/if}
{/snippet}

<!-- With no rows there is nothing to introduce, so the section renders nothing rather than a lone heading. -->
{#if items.length}
	<section
		class="alternating-features px-4 py-16 sm:px-6 sm:py-24 lg:px-8 lg:py-32"
		aria-labelledby={title ? `${uid}-title` : undefined}
	>
		<div class="mx-auto max-w-7xl">
			{#if title}
				<div class="mb-16 max-w-2xl text-start">
					<svelte:element
						this={sectionTag}
						id="{uid}-title"
						class="alternating-features__tracked alternating-features__heading text-3xl/[1.15] font-semibold tracking-[-0.02em] text-balance break-words text-[var(--_ink)] sm:text-4xl/[1.15]"
					>
						{title}
					</svelte:element>
					{#if description}
						<p
							class="alternating-features__prose mt-4 max-w-[35rem] text-base/6 text-pretty break-words text-[var(--_muted)] sm:text-lg/7"
						>
							{description}
						</p>
					{/if}
				</div>
			{/if}

			<svelte:element
				this={numbered ? 'ol' : 'ul'}
				role="list"
				class="flex flex-col gap-20 lg:gap-32"
			>
				{#each rows as { item, start }, i (i)}
					<li
						class={[
							'alternating-features__row grid',
							item.media
								? [
										'items-center gap-6 lg:gap-16',
										start
											? 'lg:grid-cols-[minmax(0,7fr)_minmax(0,5fr)]'
											: 'lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)]'
									]
								: 'gap-4 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] lg:gap-16'
						]}
					>
						{#if item.media}
							<!-- The copy comes first in the DOM at every width; start only moves it with the grid. -->
							<div class={['min-w-0 text-start', start && 'lg:col-start-2 lg:row-start-1']}>
								{@render head(item, i)}
								<div class="mt-4">{@render copy(item, i)}</div>
							</div>
							<figure class={['m-0 min-w-0', start && 'lg:col-start-1 lg:row-start-1']}>
								<div
									class="relative overflow-hidden rounded-[var(--_frame-radius)] bg-[var(--_surface)]"
									style:aspect-ratio={ratio(item.media)}
								>
									<img
										class="absolute inset-0 size-full object-cover"
										src={item.media.src}
										alt={item.media.alt}
										width={item.media.width || undefined}
										height={item.media.height || undefined}
										loading="lazy"
										decoding="async"
									/>
									<!-- The hairline sits over the image so a pale picture still has an edge. -->
									<span
										class="pointer-events-none absolute inset-0 rounded-[inherit] ring-1 ring-[var(--_hairline)] ring-inset"
										aria-hidden="true"
									></span>
								</div>
							</figure>
						{:else}
							<!-- No media: the heading takes the narrow column and the copy the wide one. -->
							<div class="min-w-0 text-start">
								{@render head(item, i)}
							</div>
							<div class="min-w-0 text-start">
								{@render copy(item, i)}
							</div>
						{/if}
					</li>
				{/each}
			</svelte:element>
		</div>
	</section>
{/if}

<style>
	/* Public tokens: set --alternating-features-* on this section or any ancestor to retone it. */
	.alternating-features {
		--_accent: var(--alternating-features-accent, #1d4ed8);
		--_ink: var(--alternating-features-ink, #18181b);
		--_muted: var(--alternating-features-muted, #52525b);
		--_hairline: var(--alternating-features-hairline, rgb(0 0 0 / 0.08));
		--_frame-radius: var(--alternating-features-frame-radius, 16px);
		/* The frame's fill, seen before the image paints: one quiet step off a white page. */
		--_surface: var(--alternating-features-surface, rgb(24 24 27 / 0.04));
	}

	/* Hover is only on the link: the label underlines and the arrow steps along the reading direction. */
	.alternating-features__link {
		text-decoration-line: underline;
		text-decoration-color: transparent;
		text-decoration-thickness: 1px;
		text-underline-offset: 4px;
		transition-property: text-decoration-color, transform;
		transition-duration: 150ms;
		transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
	}
	.alternating-features__link:hover {
		text-decoration-color: color-mix(in oklab, var(--_accent) 45%, transparent);
	}
	.alternating-features__link:active {
		transform: translateY(1px);
		transition-duration: 80ms;
	}
	.alternating-features__arrow {
		transition: translate 150ms cubic-bezier(0.2, 0, 0, 1);
	}
	/* The nudge follows the reading direction; the arrow's own flip is a separate scale. */
	.alternating-features__link {
		--_nudge: 2px;
	}
	.alternating-features__link:dir(rtl) {
		--_nudge: -2px;
	}
	.alternating-features__link:hover .alternating-features__arrow {
		translate: var(--_nudge) 0;
	}

	/* Arabic and Hebrew are never letter-spaced, and CJK is not tightened. */
	.alternating-features__tracked:dir(rtl),
	.alternating-features__tracked:is(:lang(ja), :lang(zh), :lang(ko)) {
		letter-spacing: 0;
	}
	/* Uppercase does nothing for Hebrew or Arabic, so the eyebrow takes a step up in size instead. */
	.alternating-features__eyebrow:dir(rtl) {
		font-size: 0.8125rem;
	}
	/*
	 * Japanese and Chinese have no spaces: break between phrases where the browser can, keep
	 * closing punctuation on its line, and never break mid-word. Korean spaces its words.
	 */
	:is(.alternating-features__heading, .alternating-features__prose):is(:lang(ja), :lang(zh)) {
		word-break: normal;
		word-break: auto-phrase;
		line-break: strict;
	}
	.alternating-features__heading:lang(ko) {
		word-break: keep-all;
	}

	@media (prefers-reduced-motion: reduce) {
		.alternating-features__link:active {
			transform: none;
		}
		.alternating-features__link {
			--_nudge: 0px;
		}
		.alternating-features__link:dir(rtl) {
			--_nudge: 0px;
		}
	}
</style>
```

## Artifacts

### Blue accent (`blue`)

- Artifact digest: `sha256-f951b57f3032ac356a244c131493f4233e62b2b8f5647ac6d75334e53ab21897`
- Entry: `AlternatingFeatures.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_alternating_features_01/1.0.0/blue/sha256-f951b57f3032ac356a244c131493f4233e62b2b8f5647ac6d75334e53ab21897/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_alternating_features_01/1.0.0/blue/sha256-f951b57f3032ac356a244c131493f4233e62b2b8f5647ac6d75334e53ab21897/bundle.zip (7210 bytes, sha256 `d6364d09c564ac7094b34b466125c8713db52b9a0542bf2da3024d86ce1c304e`)

Files:

- `AlternatingFeatures.svelte` (entry, 11351 bytes): https://pagesugar.com/artifacts/cmp_alternating_features_01/1.0.0/blue/sha256-f951b57f3032ac356a244c131493f4233e62b2b8f5647ac6d75334e53ab21897/source/AlternatingFeatures.svelte
