# Section heading

> A section introduction: optional eyebrow, a heading whose level is set apart from its size, a description held to about 70 characters a line and an optional action that sits on the text's last line.

- ID: `cmp_section_heading_01`
- Slug: `section-heading-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/section-heading-01
- Preview: https://pagesugar.com/preview/section-heading-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-eaf58144333ad24d569a9dd7c5c68f3041debc2a1ac9e63c23dfabc47316d8bf` |
| `blue` | Blue eyebrow | no | `sha256-c0610165b2bb2899c3c6d7eebdcc55f53000cabf47bde60786edb0b1efc6f3ef` |

## 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/section-heading-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Presentational only: it renders the text and action you pass and adds no section spacing, background or container, so put it inside your own section and give that section aria-labelledby pointing at the heading id. The built-in link is an ordinary anchor; anything else, such as a button that opens a dialog, goes through the action snippet.

Required props: `title`

```svelte
<script lang="ts">
	import SectionHeading from '$lib/components/section-heading-01/SectionHeading.svelte';
</script>

<section aria-labelledby="templates-heading" class="mx-auto max-w-6xl px-4 py-16 sm:px-6 sm:py-24">
	<SectionHeading
		id="templates-heading"
		eyebrow="Templates"
		title="Start with a board, not a blank page"
		description="Launch plans, hiring pipelines and sprint boards, set up and ready to copy."
		actionLink={{ label: 'Browse templates', href: '/templates' }}
	/>
</section>
```

Limitations:

- Adds no section padding, background or max width; the parent section owns those.
- Heading levels 1 to 4 only. Pick the level that fits your page outline; the size preset never changes it.
- One action. For a pair of buttons, render both inside the action snippet.
- Beside the text, the action column is at most 20rem wide, so a long label or a pair of buttons wraps inside it.
- Centred alignment always places the action below the description, never beside it.
- 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

### Placing it in a section

Copy `SectionHeading.svelte` into `src/lib/components/section-heading-01/`. The heading draws text and an optional action and nothing else: no padding, no background, no container. Put it at the top of your own section and name the section after it:

```svelte
<section aria-labelledby="faq-heading" class="mx-auto max-w-6xl px-4 py-16 sm:px-6 sm:py-24">
	<SectionHeading id="faq-heading" eyebrow="Help" title="Questions before you switch" />
	<!-- the section's content -->
</section>
```

### Level and size

`level` sets the tag and `size` sets the look, and neither affects the other. An h1 page introduction can use `xl`, a section `md`, a subsection inside a long page `level={3} size="sm"`.

| Size | Title (phone → wide) | Eyebrow gap | Description |
| ---- | -------------------- | ----------- | ----------- |
| `sm` | 24 px                | 8 px        | 16 px       |
| `md` | 30 → 36 px           | 8 px        | 16 → 18 px  |
| `lg` | 36 → 48 px           | 8 px        | 18 px       |
| `xl` | 36 → 48 → 60 px      | 8 px        | 18 px       |

Leading and tracking tighten as the title grows. All of it lives in three maps at the top of the script.

### The action

Pass `actionLink={{ label, href }}` for the built-in outline link with an arrow. For anything else (a filled button, a button that opens a dialog, two links) pass a snippet; it takes precedence:

```svelte
<SectionHeading title="Check-ups, cleans and everything after">
	{#snippet action()}
		<a href="/book" class="rounded-lg bg-zinc-900 px-5 py-3 text-sm font-medium text-white">
			Book a check-up
		</a>
	{/snippet}
</SectionHeading>
```

Start-aligned, the action sits beside the text from the `md` breakpoint, on the text's last baseline (the description's last line, or the title's when there is no description), and drops below the text on phones. Centred, it always sits below.

### Retoning

| Variable                     | Default          | Draws                    |
| ---------------------------- | ---------------- | ------------------------ |
| `--section-heading-ink`      | `#18181b`        | Title, link label        |
| `--section-heading-muted`    | `#52525b`        | Description              |
| `--section-heading-eyebrow`  | `#52525b`        | Eyebrow                  |
| `--section-heading-hairline` | `rgb(0 0 0/.12)` | Built-in link outline    |
| `--section-heading-accent`   | `#18181b`        | Built-in link focus ring |

On a dark band:

```svelte
<div
	class="bg-zinc-950"
	style="--section-heading-ink: #fafafa; --section-heading-muted: #a1a1aa; --section-heading-eyebrow: #a1a1aa; --section-heading-hairline: rgb(255 255 255 / 0.16); --section-heading-accent: #fafafa;"
>
	<SectionHeading title="Plan the quarter where the work happens" />
</div>
```

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `title` | `string` | yes |  | Heading text. |
| `level` | 1 \| 2 \| 3 \| 4 | no | `2` | Semantic heading level. It changes the tag only, never the visual size. |
| `size` | 'sm' \| 'md' \| 'lg' \| 'xl' | no | `'md'` | Visual size preset: 24, 36, 48 and 60 px titles on wide screens, with leading, tracking and description size stepping with them. |
| `eyebrow` | `string` | no |  | Short label set small and uppercase in a paragraph before the heading, so the heading text stays clean. |
| `description` | `string` | no |  | One or two sentences under the title, held to 48ch, under 70 characters a line (40ch in Arabic). |
| `align` | 'start' \| 'center' | no | `'start'` | Text alignment. Centred places the action below the description. |
| `action` | `Snippet` | no |  | Your own link or button. Start-aligned, it sits beside the text from md on the text's last baseline, at most 20rem wide, and below it on phones. Takes precedence over actionLink. |
| `actionLink` | `SectionHeadingLink` | no |  | { label, href } for the built-in outline link with an arrow, used when no action snippet is given. Make the label specific, such as "Browse templates". |
| `id` | `string` | no |  | Heading id, for aria-labelledby on your section. Generated with $props.id() when omitted. |

## Customization

Change content through props, retone it through five --section-heading-\* CSS variables (accent, ink, muted, eyebrow and hairline), and edit the size map at the top of the script for a different type ramp.

- Eyebrow: --section-heading-eyebrow colours the eyebrow on its own, so a brand colour can mark it without tinting the title. Keep it at 4.5:1 against the page.
- Text: --section-heading-ink sets the title and the link label; --section-heading-muted sets the description.
- Link: --section-heading-hairline outlines the built-in link and --section-heading-accent draws its focus ring. The hover fill is mixed from ink, so it follows.
- Dark page retone: ink #fafafa, muted #a1a1aa, eyebrow #a1a1aa (or #93c5fd for blue), hairline rgb(255 255 255 / 0.16), accent #fafafa (all --section-heading-\*). usage.md has the snippet.
- Sizes: the titleClass, eyebrowClass and descriptionClass maps at the top of the script hold every size decision. Change a row to change a preset.
- Levels: set level to fit the page outline (an h1 for the page intro, h2 for sections, h3 for subsections) and size for how it should look.
- Section: wrap it in your own section with padding and a container, and point aria-labelledby at the id you pass.
- Action: pass actionLink for a single link, or an action snippet for a button, a filled primary or two links.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--section-heading-accent` |
| `ink` | `--section-heading-ink` |
| `muted` | `--section-heading-muted` |
| `eyebrow` | `--section-heading-eyebrow` |
| `hairline` | `--section-heading-hairline` |

## Accessibility

- The heading is a real h1 to h4 set by level; size only changes how it looks, so the page outline stays correct at any size.
- The eyebrow is a paragraph before the heading, not part of it, so the heading's accessible name is the title alone.
- Pass id and point your section's aria-labelledby at it to name the section after the heading.
- The built-in link's accessible name is its label alone, so write a label that makes sense out of context, such as "Browse templates" rather than "Learn more".
- The link is at least 44 px tall and shows a two-pixel accent outline, offset by two pixels, on :focus-visible only. Its press scale and arrow nudge are removed under prefers-reduced-motion.
- The arrow is decorative, hidden from assistive technology, and points along the reading direction under dir="rtl".
- Muted text (#52525b) measures 7.7:1 on white and the blue eyebrow (#1d4ed8) 6.7:1; the title (#18181b) 17.7:1.
- Letter-spacing and the uppercase eyebrow reset under right-to-left text. Japanese, Chinese and Korean text drops the Latin tracking and uses word-break: keep-all with strict line breaking, so lines break at spaces and punctuation rather than between any two characters; an unbroken run longer than the line still wraps.

Known limitations:

- Contrast is computed for the shipped palettes on white; re-check any retoned token (4.5:1 for text, 3:1 for the focus ring).
- The link's outline (12% black) is a decorative boundary under 3:1; the link is identified by its label and arrow.
- Content passed through the action snippet is yours to make accessible.

## License

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

## Source

- Palette: Neutral (`neutral`)
- Entry: `SectionHeading.svelte`
- Suggested directory: `src/lib/components/section-heading-01`
- Files: 1
- Artifact digest: `sha256-eaf58144333ad24d569a9dd7c5c68f3041debc2a1ac9e63c23dfabc47316d8bf`

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

#### `SectionHeading.svelte`

Role: entry · 7544 bytes · SHA-256 `6b07b1cf84fd14b3d6d18f0695dc52494ceb154f2f5469c2d39f5caf6882b479`

```svelte
<script module lang="ts">
	export type SectionHeadingLevel = 1 | 2 | 3 | 4;
	export type SectionHeadingSize = 'sm' | 'md' | 'lg' | 'xl';

	export interface SectionHeadingLink {
		/** Specific link text, such as "Browse templates"; it is read out of context. */
		label: string;
		href: string;
	}
</script>

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

	interface Props {
		/** Heading text. */
		title: string;
		/** Semantic heading level. It never changes the visual size; `size` does. */
		level?: SectionHeadingLevel;
		/** Visual size preset, independent of `level`. */
		size?: SectionHeadingSize;
		/** Short label set small and uppercase above the title, such as "Pricing". */
		eyebrow?: string;
		/** One or two sentences under the title, at most 48ch wide, under 70 characters a line. */
		description?: string;
		/** Start-aligned by default; centre only a short introduction. */
		align?: 'start' | 'center';
		/** Your own link or button. Takes precedence over `actionLink`. */
		action?: Snippet;
		/** A ready-made outline link with an arrow, used when no `action` snippet is given. */
		actionLink?: SectionHeadingLink;
		/** Heading id, so your section can point at it with aria-labelledby. Generated when omitted. */
		id?: string;
	}

	let {
		title,
		level = 2,
		size = 'md',
		eyebrow,
		description,
		align = 'start',
		action,
		actionLink,
		id
	}: Props = $props();

	const uid = $props.id();
	const headingId = $derived(id ?? `${uid}-title`);
	const tag = $derived(`h${level}`);
	const centered = $derived(align === 'center');
	const hasAction = $derived(Boolean(action || actionLink));

	/*
	 * One type ramp per size, from DESIGN.md §3.1. Leading and tracking tighten as the title grows,
	 * and the description steps up to
	 * the 18 px lead size from md. `level` never touches these.
	 */
	const titleClass = {
		sm: 'text-2xl/[1.2] tracking-[-0.02em]',
		md: 'text-3xl/[1.15] tracking-[-0.02em] sm:text-4xl/[1.15]',
		lg: 'text-4xl/[1.1] tracking-[-0.022em] sm:text-5xl/[1.1]',
		xl: 'text-4xl/[1.1] tracking-[-0.025em] sm:text-5xl/[1.05] lg:text-6xl/[1.05]'
	} as const;
	const descriptionClass = {
		sm: 'mt-3 text-base/6',
		md: 'mt-4 text-base/6 sm:text-lg/7',
		lg: 'mt-4 text-lg/7',
		xl: 'mt-4 text-lg/7'
	} as const;
</script>

<div
	class={[
		'section-heading flex flex-col gap-6',
		centered ? 'items-center text-center' : 'items-start text-start',
		!centered && hasAction && 'section-heading--split md:flex-row md:justify-between md:gap-12'
	]}
>
	<div class={['w-full min-w-0', size === 'xl' ? 'max-w-4xl' : 'max-w-3xl']}>
		{#if eyebrow}
			<p
				class={[
					'section-heading__eyebrow mb-2 text-xs/none font-semibold tracking-[0.06em] text-balance break-words text-[var(--_eyebrow)] uppercase'
				]}
			>
				{eyebrow}
			</p>
		{/if}
		<svelte:element
			this={tag}
			id={headingId}
			class={[
				'section-heading__title font-semibold text-balance break-words text-[var(--_ink)]',
				titleClass[size]
			]}
		>
			{title}
		</svelte:element>
		{#if description}
			<p
				class={[
					'section-heading__description max-w-[48ch] text-pretty break-words text-[var(--_muted)]',
					centered && 'mx-auto',
					descriptionClass[size]
				]}
			>
				{description}
			</p>
		{/if}
	</div>

	{#if action}
		<div class="max-w-full min-w-0 md:max-w-xs md:shrink-0">{@render action()}</div>
	{:else if actionLink}
		<div class="max-w-full min-w-0 md:max-w-xs md:shrink-0">
			<!--
				A block-level grid, not inline-flex: an inline box offers the row its first baseline, so a
				label that wraps would align its first line. Here the label's last line is the baseline,
				and the arrow sits level with the label's first line.
			-->
			<a
				href={actionLink.href}
				class="section-heading__link grid min-h-11 grid-cols-[auto_auto] content-center items-start justify-start gap-2 rounded-lg px-4 py-2 text-sm/5 font-medium break-words text-[var(--_ink)] ring-1 ring-[var(--_hairline)] ring-inset focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)]"
			>
				<span class="section-heading__label min-w-0 self-baseline-last md:max-w-[24ch]"
					>{actionLink.label}</span
				>
				<svg
					class="section-heading__arrow h-lh w-4 shrink-0"
					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>
			</a>
		</div>
	{/if}
</div>

<style>
	/* Public tokens: set --section-heading-* on this element or any ancestor to retone it. */
	.section-heading {
		--_accent: var(--section-heading-accent, #18181b);
		--_ink: var(--section-heading-ink, #18181b);
		--_muted: var(--section-heading-muted, #52525b);
		--_eyebrow: var(--section-heading-eyebrow, #52525b);
		--_hairline: var(--section-heading-hairline, rgb(0 0 0 / 0.12));
	}

	/*
	 * Beside the text from md, the action sits on the text's last baseline: the description's last
	 * line, or the title's when there is no description. Browsers without `last baseline` fall back
	 * to bottom alignment.
	 */
	@media (min-width: 48rem) {
		.section-heading--split {
			align-items: flex-end;
			align-items: last baseline;
		}
	}

	.section-heading__link {
		transition-property: background-color, transform;
		transition-duration: 150ms;
		transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
	}
	/* Hover adds one step of ink behind the label instead of fading the link. */
	.section-heading__link:hover {
		background-color: color-mix(in oklab, var(--_ink) 5%, transparent);
	}
	.section-heading__link:active {
		transform: scale(0.98);
		transition-duration: 80ms;
	}

	.section-heading__arrow {
		transition: transform 150ms cubic-bezier(0.2, 0, 0, 1);
	}
	.section-heading__link:hover .section-heading__arrow {
		transform: translateX(2px);
	}
	/* The arrow points along the reading direction, and nudges that way on hover. */
	.section-heading__arrow:dir(rtl) {
		transform: scaleX(-1);
	}
	.section-heading__link:hover .section-heading__arrow:dir(rtl) {
		transform: scaleX(-1) translateX(2px);
	}

	/* Arabic and Hebrew are never letter-spaced and have no case; tracked text resets under RTL. */
	.section-heading__eyebrow:dir(rtl) {
		letter-spacing: 0;
		text-transform: none;
	}
	.section-heading__title:dir(rtl) {
		letter-spacing: 0;
	}
	/* Arabic sets optically smaller than Latin at the same size; the small text steps up one size. */
	.section-heading:is(:lang(ar), :lang(fa), :lang(ur)) .section-heading__eyebrow {
		font-size: 0.875rem;
		line-height: 1.25rem;
	}
	/* Arabic runs wider than Latin at the same measure, so its description line is shorter. */
	.section-heading:is(:lang(ar), :lang(fa), :lang(ur)) .section-heading__description {
		max-width: 40ch;
	}
	.section-heading:is(:lang(ar), :lang(fa), :lang(ur)) .section-heading__label {
		font-size: 1rem;
		line-height: 1.5rem;
	}
	/* CJK has no spaces to balance on: keep words whole and drop the Latin tracking. */
	.section-heading:is(:lang(ja), :lang(zh), :lang(ko)) :is(.section-heading__title, p) {
		letter-spacing: 0;
		word-break: keep-all;
		line-break: strict;
	}

	@media (prefers-reduced-motion: reduce) {
		.section-heading__link {
			transition-property: background-color;
		}
		.section-heading__link:active,
		.section-heading__link:hover .section-heading__arrow {
			transform: none;
		}
		.section-heading__link:hover .section-heading__arrow:dir(rtl) {
			transform: scaleX(-1);
		}
	}
</style>
```

## Artifacts

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

- Artifact digest: `sha256-eaf58144333ad24d569a9dd7c5c68f3041debc2a1ac9e63c23dfabc47316d8bf`
- Entry: `SectionHeading.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_section_heading_01/1.0.0/neutral/sha256-eaf58144333ad24d569a9dd7c5c68f3041debc2a1ac9e63c23dfabc47316d8bf/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_section_heading_01/1.0.0/neutral/sha256-eaf58144333ad24d569a9dd7c5c68f3041debc2a1ac9e63c23dfabc47316d8bf/bundle.zip (5669 bytes, sha256 `d2f6c7df98f3f715fe277cc39786fb8318d4299e2f75ea1f180ec6cf24893dd9`)

Files:

- `SectionHeading.svelte` (entry, 7544 bytes): https://pagesugar.com/artifacts/cmp_section_heading_01/1.0.0/neutral/sha256-eaf58144333ad24d569a9dd7c5c68f3041debc2a1ac9e63c23dfabc47316d8bf/source/SectionHeading.svelte

### Blue eyebrow (`blue`)

- Artifact digest: `sha256-c0610165b2bb2899c3c6d7eebdcc55f53000cabf47bde60786edb0b1efc6f3ef`
- Entry: `SectionHeading.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_section_heading_01/1.0.0/blue/sha256-c0610165b2bb2899c3c6d7eebdcc55f53000cabf47bde60786edb0b1efc6f3ef/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_section_heading_01/1.0.0/blue/sha256-c0610165b2bb2899c3c6d7eebdcc55f53000cabf47bde60786edb0b1efc6f3ef/bundle.zip (5666 bytes, sha256 `989a3ab0d53f026dad2e5fa6e7d66e7665040a8023adca8f85c511ce4b31ca9a`)

Files:

- `SectionHeading.svelte` (entry, 7544 bytes): https://pagesugar.com/artifacts/cmp_section_heading_01/1.0.0/blue/sha256-c0610165b2bb2899c3c6d7eebdcc55f53000cabf47bde60786edb0b1efc6f3ef/source/SectionHeading.svelte
