# Process steps

> Numbered steps of a linear process for a "How it works" section: a row with a rule running between the markers from 1024 px, a vertical rail below it, and an optional closing action.

- ID: `cmp_process_steps_01`
- Slug: `process-steps-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/process-steps-01
- Preview: https://pagesugar.com/preview/process-steps-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-f196fdae3769cfbefc07de81ceba8985dae8cb3b16ba0ce3873dbba78263f9da` |
| `blue` | Blue accent | no | `sha256-df9d3c99d2de8c002be0802e60d98a51393740277125d3e2f2913ea58debfdb6` |

## 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/process-steps-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Presentational only: renders the steps, copy and link you pass in. It is not a stepper or wizard: nothing is selected, tracked or navigable between steps, and the action is an ordinary link to your own page. No runtime packages beyond Svelte and Tailwind CSS.

Required props: `steps`

```svelte
<!-- Illustrative content: replace the steps and link before publishing. -->
<script lang="ts">
	import ProcessSteps, { type ProcessStep } from '$lib/components/process-steps-01/ProcessSteps.svelte';

	const steps: ProcessStep[] = [
		{ title: 'Book online', description: 'Choose a time that suits you.' },
		{ title: 'Fill in your form', description: 'We email it the day before.' },
		{ title: 'Come in', description: 'Your first visit takes about an hour.' }
	];
</script>

<ProcessSteps
	eyebrow="New patients"
	title="Your first visit, step by step"
	{steps}
	action={{ label: 'Book a visit', href: '/book' }}
/>
```

Limitations:

- Not interactive: no current step, progress state or navigation between steps. Use a walkthrough or stepper component for that.
- A row holds at most five steps. Six or more, like a single step, are always laid out as the vertical column.
- Below 1024 px every layout is the vertical column; there is no two-column tablet row.
- With an empty steps array the introduction and action still render and the list is omitted.
- Icons are snippets you supply; with numbering "icons", a step without an icon shows its number.
- Light appearance by default. The tokens retone it for a dark or tinted page, but no dark mode is declared or selected automatically.

## Usage guide

### Process steps

A "How it works" section: numbered steps in an ordered list, with a one-pixel rule running from
each marker to the next. Two to five steps sit in a row from 1024 px; below that, and for one
step, six or more, or `orientation="vertical"`, the steps run down a rail, beside the
introduction on wide screens.

#### Icons in the markers

Pass `numbering="icons"` and give each step an `icon` snippet holding a 16 px SVG drawn with
`currentColor`. A step without an icon shows its number, so a mixed list still reads in order.

```svelte
{#snippet calendar()}
	<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><!-- … --></svg>
{/snippet}

<ProcessSteps numbering="icons" steps={[{ title: 'Book online', icon: calendar }]} />
```

#### Retoning for a dark band

```svelte
<div
	class="bg-zinc-950"
	style="--process-steps-ink:#fafafa; --process-steps-muted:#a1a1aa; --process-steps-subtle:#a1a1aa; --process-steps-hairline:rgb(255 255 255 / 0.16); --process-steps-accent:#fafafa; --process-steps-on-accent:#18181b;"
>
	<ProcessSteps {steps} title="How it works" />
</div>
```

#### What it does not do

It is not a stepper. There is no current step, no progress and no navigation between steps;
the action is a plain link.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `steps` | `ProcessStep[]` | yes |  | Steps in order. Each has a title, an optional description and an optional icon snippet (16 px SVG using currentColor). |
| `eyebrow` | `string` | no |  | Short uppercase label above the title, such as "How it works". |
| `title` | `string` | no |  | Section heading text. |
| `description` | `string` | no |  | One or two sentences under the title, held to a readable measure. |
| `orientation` | 'auto' \| 'vertical' | no | `'auto'` | auto lays two to five steps in a row from 1024 px and in a column below; vertical keeps the column at every width, beside the introduction from 1024 px. |
| `numbering` | 'numbers' \| 'icons' | no | `'numbers'` | What each marker shows. icons renders the step's icon snippet and falls back to the number. |
| `headingLevel` | 2 \| 3 | no | `2` | Level of the section heading; step titles use the next level down. |
| `action` | `ProcessStepsAction` | no |  | Optional closing link { label, href }, shown after the steps as the section's one filled button. |

## Customization

Change content through props, retone the section through six --process-steps-\* CSS variables, and edit the source for spacing, the row breakpoint or the marker size (see the guide: the marker and the rule's insets move together).

- Accent: set --process-steps-accent and --process-steps-on-accent together. The accent colours the marker numbers and icons, fills the action and draws the focus ring; keep on-accent text at 4.5:1 against it. The hover shade is mixed from the pair.
- Text: --process-steps-ink sets the section and step titles; --process-steps-muted sets the section description and step descriptions; --process-steps-subtle sets the eyebrow, one step quieter. Keep muted and subtle at 4.5:1 against your background.
- Rule: --process-steps-hairline draws the line between markers and each marker's ring. It is decorative; keep it visible but quiet (8 to 16 percent black on light pages).
- Dark page retone: ink #fafafa, muted #a1a1aa, subtle #a1a1aa, hairline rgb(255 255 255 / 0.16), accent #fafafa, on-accent #18181b (all --process-steps-\*). The section has no background of its own; set one on its parent. usage.md has the snippet.
- Layout: the row starts at the lg breakpoint (1024 px). Change the lg: classes on the list and steps, and the 64rem media query in the style block, together.
- Marker size: the 32 px marker (size-8) is tied to the step grid's first track (grid-cols-\[2rem\_…\]) and to the rule's insets in the style block (1rem centre line, 2.75rem start). Change all three together.
- Copy: lead each step title with a verb and keep descriptions to one or two sentences; the row's columns are narrow at five steps.
- Action: point action.href at your real signup, booking or contact page. Leave it out when the page already has a primary action nearby.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--process-steps-accent` |
| `onAccent` | `--process-steps-on-accent` |
| `ink` | `--process-steps-ink` |
| `muted` | `--process-steps-muted` |
| `subtle` | `--process-steps-subtle` |
| `hairline` | `--process-steps-hairline` |

## Accessibility

- Steps are an ordered list (\<ol role="list"\>), so screen readers announce the position and count; role="list" keeps that in Safari, which drops list semantics when markers are removed.
- The visible numbers and icons in the markers are aria-hidden, so a step is not announced as "1, 1, Import your roadmap".
- The rule between markers is a CSS pseudo-element with no content, so assistive technology ignores it.
- Step titles are headings one level below the section heading, set with headingLevel.
- The action is an ordinary link with a two-pixel focus outline in the accent colour, offset by two pixels, on :focus-visible only; it is at least 44 px tall. Its pressed movement is removed under prefers-reduced-motion.
- Muted text (#52525b) measures 7.7:1 on white and the subtle eyebrow (#71717a) 4.8:1; white on the neutral accent (#18181b) measures 17.7:1 and on the blue accent (#1d4ed8) 6.7:1.
- Logical properties throughout: under dir="rtl" the markers, the rule and the arrow mirror, and letter-spacing resets to 0.
- The section is labelled by its heading. Without a title it renders as an unnamed section: wrap it in your own labelled region or add aria-label in the source.

Known limitations:

- Contrast is computed for the shipped palettes only; re-check any changed token.
- The rule and marker ring are decorative and fall under 3:1 by design; the numbers carry the meaning.

## License

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

## Source

- Palette: Neutral (`neutral`)
- Entry: `ProcessSteps.svelte`
- Suggested directory: `src/lib/components/process-steps-01`
- Files: 1
- Artifact digest: `sha256-f196fdae3769cfbefc07de81ceba8985dae8cb3b16ba0ce3873dbba78263f9da`

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

#### `ProcessSteps.svelte`

Role: entry · 8785 bytes · SHA-256 `6b7a04d41f587e24aa8720b6436807d1cbef5164335226e3b8b42c7bba0e0795`

```svelte
<script module lang="ts">
	import type { Snippet } from 'svelte';

	export interface ProcessStep {
		/** Short step name, best led by a verb: "Import your roadmap". */
		title: string;
		/** One or two sentences on what happens in this step. */
		description?: string;
		/** A 16 px inline SVG drawn with currentColor, shown in the marker when numbering is "icons". */
		icon?: Snippet;
	}

	export interface ProcessStepsAction {
		label: string;
		href: string;
	}
</script>

<script lang="ts">
	interface Props {
		/** Steps in order. More than five are always laid out as a vertical list. */
		steps: ProcessStep[];
		/** Short label set small and uppercase above the title, e.g. "How it works". */
		eyebrow?: string;
		/** Section heading. */
		title?: string;
		/** One or two sentences under the title. */
		description?: string;
		/** "auto" runs the steps in a row from 1024 px; "vertical" keeps them in a column. */
		orientation?: 'auto' | 'vertical';
		/** What the markers show: the step's number, or its icon (falling back to the number). */
		numbering?: 'numbers' | 'icons';
		/** Level of the section heading; step titles use the next level down. */
		headingLevel?: 2 | 3;
		/** Optional closing link after the steps, styled as the section's one primary button. */
		action?: ProcessStepsAction;
	}

	let {
		steps,
		eyebrow,
		title,
		description,
		orientation = 'auto',
		numbering = 'numbers',
		headingLevel = 2,
		action
	}: Props = $props();

	const uid = $props.id();
	const sectionHeading = $derived(`h${headingLevel}`);
	const stepHeading = $derived(`h${headingLevel + 1}`);
	const hasIntro = $derived(Boolean(eyebrow || title || description));

	/*
	 * A row holds two to five steps. One step, six or more, or orientation "vertical" keep the
	 * column at every width; below 1024 px every layout is the column.
	 */
	const row = $derived(orientation === 'auto' && steps.length >= 2 && steps.length <= 5);
	const rowClass = $derived(
		steps.length === 2
			? 'lg:grid-cols-2'
			: steps.length === 3
				? 'lg:grid-cols-3'
				: steps.length === 4
					? 'lg:grid-cols-4'
					: 'lg:grid-cols-5'
	);
	/* A column with an introduction sits beside it from 1024 px, so the list never strands on the left. */
	const split = $derived(!row && hasIntro && steps.length > 0);
</script>

<section
	class="process-steps 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-6xl',
			split &&
				'lg:grid lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] lg:grid-rows-[auto_1fr] lg:gap-x-16'
		]}
	>
		{#if hasIntro}
			<div class="max-w-2xl text-start">
				{#if eyebrow}
					<p
						class="process-steps__tracked text-xs leading-none font-medium tracking-[0.06em] text-balance text-[var(--_subtle)] uppercase"
					>
						{eyebrow}
					</p>
				{/if}
				{#if title}
					<svelte:element
						this={sectionHeading}
						id="{uid}-title"
						class={[
							'process-steps__tracked text-3xl leading-[1.15] font-semibold tracking-tight text-balance break-words text-[var(--_ink)] sm:text-4xl',
							eyebrow && 'mt-2'
						]}
					>
						{title}
					</svelte:element>
				{/if}
				{#if description}
					<p
						class={[
							'max-w-xl text-base leading-6 text-pretty break-words text-[var(--_muted)] sm:text-lg sm:leading-7',
							(eyebrow || title) && 'mt-4'
						]}
					>
						{description}
					</p>
				{/if}
			</div>
		{/if}

		{#if steps.length > 0}
			<!-- role="list" keeps list semantics in Safari, which drops them from a list with no markers. -->
			<ol
				role="list"
				class={[
					'grid grid-cols-1',
					row && ['process-steps__row', rowClass],
					split && 'lg:row-span-2',
					hasIntro && (split ? 'mt-12 sm:mt-16 lg:mt-0' : 'mt-12 sm:mt-16')
				]}
			>
				{#each steps as step, index (index)}
					<li
						class={[
							'process-steps__step relative grid min-w-0 grid-cols-[2rem_minmax(0,1fr)] gap-x-4 pb-8 last:pb-0',
							// Three shared rows from 1024 px (marker, title, description), so descriptions start on one line
							// however many lines each title takes.
							row && 'lg:row-span-3 lg:grid-cols-1 lg:grid-rows-subgrid lg:gap-x-0 lg:pe-8 lg:pb-0'
						]}
					>
						<!-- The marker repeats the list's own numbering, so it is hidden from assistive technology. -->
						<span
							class="process-steps__marker flex size-8 items-center justify-center rounded-full text-sm leading-none font-medium text-[var(--_accent)] tabular-nums ring-1 ring-[var(--_hairline)] ring-inset"
							aria-hidden="true"
						>
							{#if numbering === 'icons' && step.icon}
								{@render step.icon()}
							{:else}
								{index + 1}
							{/if}
						</span>
						<div class={['min-w-0', row && 'lg:row-span-2 lg:grid lg:grid-rows-subgrid']}>
							<svelte:element
								this={stepHeading}
								class={[
									'process-steps__tracked mt-1 text-lg leading-6 font-semibold tracking-[-0.015em] text-balance break-words text-[var(--_ink)]',
									row && 'lg:mt-6'
								]}
							>
								{step.title}
							</svelte:element>
							{#if step.description}
								<p
									class="mt-2 max-w-[30em] text-base leading-6 text-pretty break-words text-[var(--_muted)]"
								>
									{step.description}
								</p>
							{/if}
						</div>
					</li>
				{/each}
			</ol>
		{/if}

		{#if action}
			<a
				href={action.href}
				class={[
					'process-steps__action inline-flex min-h-11 max-w-full items-center rounded-lg bg-[var(--_accent)] px-4 py-2 text-sm leading-5 font-medium break-words text-[var(--_on-accent)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)]',
					steps.length > 0 ? 'mt-12 sm:mt-16' : hasIntro && 'mt-8',
					split && 'lg:mt-8 lg:self-start lg:justify-self-start'
				]}
			>
				<span class="min-w-0 [overflow-wrap:anywhere]"
					>{action.label}&#8288;<svg
						class="process-steps__arrow ms-2 inline-block size-4 align-[-0.1875rem] rtl:-scale-x-100"
						viewBox="0 0 16 16"
						fill="none"
						aria-hidden="true"
						><path
							d="M3 8h9.5M8.5 4l4 4-4 4"
							stroke="currentColor"
							stroke-width="1.75"
							stroke-linecap="round"
							stroke-linejoin="round"
						/></svg
					></span
				>
			</a>
		{/if}
	</div>
</section>

<style>
	/* Public tokens: set --process-steps-* on this section or any ancestor to retone it. */
	.process-steps {
		--_accent: var(--process-steps-accent, #18181b);
		--_on-accent: var(--process-steps-on-accent, #ffffff);
		--_ink: var(--process-steps-ink, #18181b);
		--_muted: var(--process-steps-muted, #52525b);
		/* The third text tone, for the eyebrow only: context set quieter than the copy it introduces. */
		--_subtle: var(--process-steps-subtle, #71717a);
		--_hairline: var(--process-steps-hairline, rgb(0 0 0 / 0.12));
	}

	.process-steps__marker :global(svg) {
		width: 1rem;
		height: 1rem;
	}

	/*
	 * The rule that runs through the steps: a one-pixel line from each marker to the next, stopping
	 * 12 px short of both. In the column it runs down the markers' centre line; in the row it runs
	 * along it. Logical insets, so it follows the reading direction under dir="rtl".
	 */
	.process-steps__step:not(:last-child)::after {
		content: '';
		position: absolute;
		inset-inline-start: calc(1rem - 0.5px);
		inset-block: 2.75rem 0.75rem;
		width: 1px;
		background: var(--_hairline);
		pointer-events: none;
	}
	@media (min-width: 64rem) {
		.process-steps__row > .process-steps__step:not(:last-child)::after {
			inset-inline: 2.75rem 0.75rem;
			inset-block: calc(1rem - 0.5px) auto;
			width: auto;
			height: 1px;
		}
	}

	.process-steps__action {
		transition-property: background-color, transform;
		transition-duration: 150ms;
		transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
	}
	.process-steps__arrow {
		transition: translate 150ms cubic-bezier(0.2, 0, 0, 1);
	}
	/* Hover shifts the fill one step towards the label colour instead of fading the button. */
	.process-steps__action:hover {
		background-color: color-mix(in oklab, var(--_accent) 86%, var(--_on-accent));
	}
	.process-steps__action:hover .process-steps__arrow {
		translate: 2px 0;
	}
	.process-steps__action:hover .process-steps__arrow:dir(rtl) {
		translate: -2px 0;
	}
	.process-steps__action:active {
		transform: translateY(1px);
		transition-duration: 80ms;
	}

	/* Arabic and Hebrew are never letter-spaced; tracked text resets under right-to-left. */
	.process-steps__tracked:dir(rtl) {
		letter-spacing: 0;
	}

	@media (prefers-reduced-motion: reduce) {
		.process-steps__action:active {
			transform: none;
		}
		.process-steps__action:hover .process-steps__arrow,
		.process-steps__action:hover .process-steps__arrow:dir(rtl) {
			translate: none;
		}
	}
</style>
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-f196fdae3769cfbefc07de81ceba8985dae8cb3b16ba0ce3873dbba78263f9da`
- Entry: `ProcessSteps.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_process_steps_01/1.0.0/neutral/sha256-f196fdae3769cfbefc07de81ceba8985dae8cb3b16ba0ce3873dbba78263f9da/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_process_steps_01/1.0.0/neutral/sha256-f196fdae3769cfbefc07de81ceba8985dae8cb3b16ba0ce3873dbba78263f9da/bundle.zip (6060 bytes, sha256 `2caba4b98719fb3cd2d5ec92380a6aab29e74d61ec28ac09210441876393080c`)

Files:

- `ProcessSteps.svelte` (entry, 8785 bytes): https://pagesugar.com/artifacts/cmp_process_steps_01/1.0.0/neutral/sha256-f196fdae3769cfbefc07de81ceba8985dae8cb3b16ba0ce3873dbba78263f9da/source/ProcessSteps.svelte

### Blue accent (`blue`)

- Artifact digest: `sha256-df9d3c99d2de8c002be0802e60d98a51393740277125d3e2f2913ea58debfdb6`
- Entry: `ProcessSteps.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_process_steps_01/1.0.0/blue/sha256-df9d3c99d2de8c002be0802e60d98a51393740277125d3e2f2913ea58debfdb6/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_process_steps_01/1.0.0/blue/sha256-df9d3c99d2de8c002be0802e60d98a51393740277125d3e2f2913ea58debfdb6/bundle.zip (6070 bytes, sha256 `dbedf76a43ccd084f50d48ba9196680832e777ade9343c66c30383513fdcb046`)

Files:

- `ProcessSteps.svelte` (entry, 8785 bytes): https://pagesugar.com/artifacts/cmp_process_steps_01/1.0.0/blue/sha256-df9d3c99d2de8c002be0802e60d98a51393740277125d3e2f2913ea58debfdb6/source/ProcessSteps.svelte
