# Heading

> An h1 to h6 whose visual size is chosen separately from its outline level, on an eight-step scale where leading and tracking tighten as the type grows. Optional eyebrow; inherits the page's font and colour.

- ID: `cmp_heading_01`
- Slug: `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: `typography`
- Detail page: https://pagesugar.com/components/heading-01
- Preview: https://pagesugar.com/preview/heading-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `default` | Inherited colour | yes | `sha256-224ec32f3f82f0b0a7ed0dd58388040192455f336dc11cc87c15d0f6c2706f0b` |

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

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Pass a level for the outline and, when the default does not suit, a size for the look; the content goes in as children. It renders one native h1 to h6 (inside an hgroup when there is an eyebrow). It does not choose the level from its nesting, set a font family, add anchor links or build a table of contents.

Required props: `level`, `children`

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

<Heading level={2} size="3xl" eyebrow="Timelines" id="timelines">
	Plan the quarter on one timeline.
</Heading>

<Heading level={3} size="md">Guest access</Heading>
```

Limitations:

- The level is always yours to choose: the component cannot see where it sits in the page, so it never infers or corrects the outline.
- No font family is set; the heading inherits font-sans from the page. The leading and tracking are tuned for a neo-grotesque such as Inter and may want adjusting for a serif or a condensed face.
- Colour is inherited from the surrounding text unless --heading-ink is set; there is no separate dark mode because the heading follows whatever colour its container sets.
- The eyebrow is plain text. For a pill, an icon or a link above the heading, drop the eyebrow prop and render your own element before the component.
- text-wrap: balance and pretty are progressive: browsers without them wrap normally. Phrase breaking for Japanese uses word-break: auto-phrase where supported (Chromium) and ordinary CJK breaking elsewhere.

## Usage guide

### Heading

`level` is the outline and `size` is the look. Pick the level from where the heading sits in
the page and the size from the design, and never let one decide the other.

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

<!-- A landing page section: an h2 set large, with an eyebrow. -->
<Heading level={2} size="3xl" eyebrow="Timelines" id="timelines" class="max-w-[22ch]">
	Plan the quarter on one timeline.
</Heading>

<!-- A card inside that section: an h3 set small. -->
<Heading level={3} size="md">Guest access</Heading>
```

#### The scale

| Size  | Font size (base · sm · lg) | Leading | Tracking  | Default for |
| ----- | -------------------------- | ------- | --------- | ----------- |
| `4xl` | 40 · 60 · 72 px            | 1.05    | −0.025 em |             |
| `3xl` | 36 · 44 · 48 px            | 1.1     | −0.022 em | h1          |
| `2xl` | 28 · 30 · 36 px            | 1.15    | −0.021 em | h2          |
| `xl`  | 24 px                      | 1.2     | −0.019 em | h3          |
| `lg`  | 20 px                      | 1.3     | −0.016 em | h4          |
| `md`  | 18 px                      | 1.3     | −0.014 em | h5          |
| `sm`  | 16 px                      | 1.35    | −0.011 em | h6          |
| `xs`  | 14 px                      | 1.4     | 0         |             |

Headings from `xl` up balance their lines by default; smaller ones use `text-wrap: pretty`.

#### A badge in the heading

Children can hold inline content. Set a badge as an inline block raised about 0.3 em so it sits
level with the first line of text however the heading wraps:

```svelte
<Heading level={3} size="xl">
	Guest access
	<span
		class="ms-2 inline-block rounded-full border border-black/10 px-2 align-[0.3em] text-xs/5 font-medium tracking-normal text-zinc-600"
	>
		New
	</span>
</Heading>
```

The badge text becomes part of the heading's accessible name ("Guest access New"). If it should
not, give it `aria-hidden="true"` and say the same thing elsewhere.

#### Colour

The heading takes the surrounding text colour and the eyebrow a 72% mix of it. On a dark band,
set the band's text colour and leave the tokens alone. To tint only the heading:

```svelte
<div style="--heading-ink: #7c2d12">
	<Heading level={2}>Sourdough out of the oven at 7</Heading>
</div>
```

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `level` | 1 \| 2 \| 3 \| 4 \| 5 \| 6 | yes |  | Outline level. Picks the element (h1 to h6) and never the size. An out-of-range value renders an h2. |
| `size` | 'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl' \| '3xl' \| '4xl' | no |  | Visual size step. Omitted, the level decides: h1 3xl, h2 2xl, h3 xl, h4 lg, h5 md, h6 sm. Changing it never changes the element. |
| `weight` | 'medium' \| 'semibold' \| 'bold' | no | `'semibold'` | Font weight. Semibold suits most headings; medium reads calmer at display sizes. |
| `balance` | `boolean` | no |  | Balance the lengths of wrapped lines. Defaults to true for xl and larger, where a lone last word shows most; false uses text-wrap: pretty. |
| `eyebrow` | `string` | no |  | Short label set small and uppercase above the heading. It is a separate paragraph inside an hgroup, so it is not part of the heading's accessible name. |
| `id` | `string` | no |  | Id on the heading element itself (not the hgroup), for anchor links and aria-labelledby. |
| `class` | `string` | no |  | Extra classes on the outermost element: the heading, or the hgroup when there is an eyebrow. Use it for margins and max-width. |
| `children` | `Snippet` | yes |  | The heading's content: its text, plus inline pieces such as a badge. |

## Customization

Choose level, size and weight through props; retone the heading and eyebrow with --heading-ink and --heading-muted, or leave them to inherit the surrounding text colour. Edit the SIZES map in the source to change the scale.

- Colour: by default the heading is the surrounding text colour and the eyebrow is a 72% mix of it, so the pair works on white, on a dark band and on a tinted card without changes. Set --heading-ink for a brand or muted heading and --heading-muted for the eyebrow, on the component or any ancestor.
- Dark or tinted page: set the container's text colour (for example text-zinc-50 on bg-zinc-950) and the heading follows. Only set --heading-ink when the heading should differ from the body text.
- Level and size: pick the level from the outline (one h1 per page, no skipped levels) and the size from the design. A card title is often level 3 at size md or lg; a hero is level 1 at 4xl.
- Scale: the SIZES map at the top of the script holds each step's font size, leading and tracking as complete Tailwind classes. Edit a step there to change it everywhere; keep the leading and tracking tightening as size grows.
- Eyebrow: it sits 8 px above the heading in the heading's own hgroup. For a coloured eyebrow set --heading-muted; keep it at 4.5:1 against the background.
- Badges and other inline content: put them in children after the text, as an inline-block with vertical-align about 0.3em and an 8 px gap so they sit level with the first line. usage.md has an example.
- Width: pass class="max-w-\[20ch\]" or similar for display headings so balanced lines stay short.

| Token | Public CSS variable |
| --- | --- |
| `ink` | `--heading-ink` |
| `muted` | `--heading-muted` |

## Accessibility

- Renders a native h1 to h6 chosen by the required level prop, so assistive technology reads the level from the element; no role or aria-level is added.
- Changing size or weight never changes the element, so a visual choice cannot break the outline. Choosing a level that fits the page (no skipped levels, one h1) is the consumer's responsibility.
- The eyebrow is a paragraph before the heading inside an hgroup. It is read as ordinary text and is not part of the heading's accessible name; put it in children instead if it should be.
- The id prop lands on the heading element, so aria-labelledby on a section names it by the heading text alone.
- The default colour is inherited, so contrast is whatever the page sets for its text. The eyebrow mixes that colour at 72%: about 8:1 for zinc-950 on white and 10:1 for zinc-50 on zinc-950. Re-check both if you set --heading-ink or --heading-muted.
- Long words and addresses wrap with overflow-wrap: anywhere, so a heading never forces horizontal scrolling at 320 px.
- Letter-spacing and uppercase are removed under dir="rtl", and letter-spacing is removed for Chinese, Japanese and Korean.

Known limitations:

- An eyebrow inside hgroup is exposed as generic text in current browsers; some older screen readers announce the hgroup as a group.
- Contrast is not checked by the component; it follows the colour of the surrounding text.

## License

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

## Source

- Palette: Inherited colour (`default`)
- Entry: `Heading.svelte`
- Suggested directory: `src/lib/components/heading-01`
- Files: 1
- Artifact digest: `sha256-224ec32f3f82f0b0a7ed0dd58388040192455f336dc11cc87c15d0f6c2706f0b`

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

#### `Heading.svelte`

Role: entry · 5057 bytes · SHA-256 `a5d62ff5fb0b43d2abc59d0b517975c17ef2bea9e4ac013b28b911a4c8417aa2`

```svelte
<script lang="ts" module>
	export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
	export type HeadingSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
	export type HeadingWeight = 'medium' | 'semibold' | 'bold';
</script>

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

	interface Props {
		/** Outline level. Picks the element (h1 to h6) and nothing else. */
		level: HeadingLevel;
		/** Visual size step, independent of level. Defaults to a step that suits the level. */
		size?: HeadingSize;
		/** Font weight. Semibold by default. */
		weight?: HeadingWeight;
		/** Balance the lines of a wrapping heading. Defaults to true from xl up. */
		balance?: boolean;
		/** Short label set above the heading, outside its accessible name. */
		eyebrow?: string;
		/** Id on the heading element, for anchors and aria-labelledby. */
		id?: string;
		/** Extra classes on the outermost element (the hgroup when there is an eyebrow). */
		class?: string;
		/** The heading's content: text, plus inline pieces such as a badge. */
		children: Snippet;
	}

	let {
		level,
		size,
		weight = 'semibold',
		balance,
		eyebrow,
		id,
		class: className,
		children
	}: Props = $props();

	/*
	 * Each step pairs its size with its own leading and tracking: open at the small steps,
	 * tighter as the type grows, so a large heading is set rather than enlarged. Only the three
	 * display steps change size across breakpoints. Complete class names, never interpolated.
	 */
	const SIZES: Record<HeadingSize, string> = {
		xs: 'text-sm leading-[1.4] tracking-normal',
		sm: 'text-base leading-[1.35] tracking-[-0.011em]',
		md: 'text-lg leading-[1.3] tracking-[-0.014em]',
		lg: 'text-xl leading-[1.3] tracking-[-0.016em]',
		xl: 'text-2xl leading-[1.2] tracking-[-0.019em]',
		'2xl': 'text-[1.75rem] leading-[1.15] tracking-[-0.021em] sm:text-3xl lg:text-4xl',
		'3xl': 'text-4xl leading-[1.1] tracking-[-0.022em] sm:text-[2.75rem] lg:text-5xl',
		'4xl': 'text-[2.5rem] leading-[1.05] tracking-[-0.025em] sm:text-6xl lg:text-7xl'
	};

	const WEIGHTS: Record<HeadingWeight, string> = {
		medium: 'font-medium',
		semibold: 'font-semibold',
		bold: 'font-bold'
	};

	/** The size a level gets when no size is given. */
	const LEVEL_SIZES: Record<HeadingLevel, HeadingSize> = {
		1: '3xl',
		2: '2xl',
		3: 'xl',
		4: 'lg',
		5: 'md',
		6: 'sm'
	};

	const DISPLAY: HeadingSize[] = ['xl', '2xl', '3xl', '4xl'];

	// An out-of-range level from untyped data falls back to h2 rather than an invalid element.
	const safeLevel = $derived<HeadingLevel>(
		Number.isInteger(level) && level >= 1 && level <= 6 ? level : 2
	);
	const tag = $derived(`h${safeLevel}` as const);
	const step = $derived(size && size in SIZES ? size : LEVEL_SIZES[safeLevel]);
	const balanced = $derived(balance ?? DISPLAY.includes(step));
</script>

{#snippet heading(extra?: string)}
	<svelte:element
		this={tag}
		{id}
		class={[
			'heading__title [overflow-wrap:anywhere] text-[var(--_ink)]',
			SIZES[step],
			WEIGHTS[weight] ?? WEIGHTS.semibold,
			balanced ? 'text-balance' : 'text-pretty',
			extra
		]}
	>
		{@render children()}
	</svelte:element>
{/snippet}

{#if eyebrow}
	<hgroup class={['heading', className]}>
		<p
			class="heading__eyebrow text-xs leading-none font-medium tracking-[0.06em] text-balance [overflow-wrap:anywhere] text-[var(--_muted)] uppercase"
		>
			{eyebrow}
		</p>
		{@render heading('mt-2')}
	</hgroup>
{:else}
	{@render heading(['heading', className].filter(Boolean).join(' '))}
{/if}

<style>
	/*
	 * Public tokens: set --heading-ink or --heading-muted on the heading or any ancestor. By
	 * default the heading takes the surrounding text colour, and the eyebrow a softer mix of it,
	 * so it sits on a light page, a dark band or a tinted card without a change.
	 */
	.heading__title {
		--_ink: var(--heading-ink, currentColor);
	}

	.heading__eyebrow {
		--_muted: var(--heading-muted, color-mix(in srgb, currentColor 72%, transparent));
	}

	/* Arabic, Hebrew and other right-to-left scripts are never letter-spaced or set in caps. */
	.heading__title:dir(rtl),
	.heading__eyebrow:dir(rtl) {
		letter-spacing: 0;
		text-transform: none;
	}

	/* Arabic, Hebrew and CJK glyphs set small at 12 px; their eyebrow steps up to 13 px. */
	.heading__eyebrow:dir(rtl),
	.heading__eyebrow:lang(zh),
	.heading__eyebrow:lang(ja),
	.heading__eyebrow:lang(ko) {
		font-size: 0.8125rem;
	}

	/*
	 * Chinese, Japanese and Korean: no Latin tracking, and lines break at sensible points.
	 * Korean keeps its words whole; Japanese breaks at phrases where the browser can find them.
	 */
	.heading__title:lang(zh),
	.heading__title:lang(ja),
	.heading__title:lang(ko),
	.heading__eyebrow:lang(zh),
	.heading__eyebrow:lang(ja),
	.heading__eyebrow:lang(ko) {
		letter-spacing: 0;
	}

	.heading__title:lang(zh),
	.heading__title:lang(ja),
	.heading__title:lang(ko) {
		line-break: strict;
	}

	.heading__title:lang(ko) {
		word-break: keep-all;
	}

	@supports (word-break: auto-phrase) {
		.heading__title:lang(ja) {
			word-break: auto-phrase;
		}
	}
</style>
```

## Artifacts

### Inherited colour (`default`) (default)

- Artifact digest: `sha256-224ec32f3f82f0b0a7ed0dd58388040192455f336dc11cc87c15d0f6c2706f0b`
- Entry: `Heading.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_heading_01/1.0.0/default/sha256-224ec32f3f82f0b0a7ed0dd58388040192455f336dc11cc87c15d0f6c2706f0b/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_heading_01/1.0.0/default/sha256-224ec32f3f82f0b0a7ed0dd58388040192455f336dc11cc87c15d0f6c2706f0b/bundle.zip (4869 bytes, sha256 `5240b89cb3dab54c64fad0133deae711f70cacdbb3380184bc4d90b574b8569f`)

Files:

- `Heading.svelte` (entry, 5057 bytes): https://pagesugar.com/artifacts/cmp_heading_01/1.0.0/default/sha256-224ec32f3f82f0b0a7ed0dd58388040192455f336dc11cc87c15d0f6c2706f0b/source/Heading.svelte
