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.
cmp_alternating_features_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_alternating_features_01 version 1.0.0 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Neutral
- Version
- 1.0.0
- Digest
Full digest
sha256-8c60d440e64598857b61061c1978d1f43941b4b43d5d799f7885502eaf05ad93
<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}⁠<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, #18181b);
--_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>
Usage#
On this pagePresentational: 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.
- Suggested location
src/lib/components/alternating-features-01- Required props
items
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.
Example
<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 }
}
]}
/>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.
<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 withobject-coverfrom 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 and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
items | AlternatingFeature[] | Yes | None | 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 | None | Section heading, left-aligned above the rows. Omitted, the rows start the section. |
description | string | No | None | 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#
On this pageChange 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-accentcolours 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-inksets the headings and points;--alternating-features-mutedsets the descriptions and the row numbers. Keep muted at 4.5:1. - Frames:
--alternating-features-frame-radiusrounds every media frame (16px by default; 0 for square corners),--alternating-features-hairlinedraws the one-pixel edge over each image, and--alternating-features-surfacefills 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-20lg:gap-32on 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
headingLevelto 3 when the section sits under another h2.
Public CSS variables
| Variable | Token |
|---|---|
--alternating-features-accent | accent |
--alternating-features-ink | ink |
--alternating-features-muted | muted |
--alternating-features-hairline | hairline |
--alternating-features-surface | surface |
--alternating-features-frame-radius | frameRadius |
Accessibility#
On this page- 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
headingLevelthemselves. - 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.
startWithand 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#1d4ed8measures 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.
Release details#
On this page- Integration
- Presentational
- Works without client-side JavaScript
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 30 September 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.