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.
cmp_heading_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_heading_01 version 1.0.0 with variant "default", 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
- Inherited colour
- Version
- 1.0.0
- Digest
Full digest
sha256-224ec32f3f82f0b0a7ed0dd58388040192455f336dc11cc87c15d0f6c2706f0b
<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>
Usage#
On this pagePass 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.
- Suggested location
src/lib/components/heading-01
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-sansfrom 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-inkis 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.
Example
<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>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.
<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:
<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:
<div style="--heading-ink: #7c2d12">
<Heading level={2}>Sourdough out of the oven at 7</Heading>
</div>Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
level | 1 | 2 | 3 | 4 | 5 | 6 | Yes | None | 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 | None | 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 | None | 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 | None | 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 | None | Id on the heading element itself (not the hgroup), for anchor links and aria-labelledby. |
class | string | No | None | 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 | None | The heading's content: its text, plus inline pieces such as a badge. |
Customization#
On this pageChoose 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-inkfor a brand or muted heading and--heading-mutedfor the eyebrow, on the component or any ancestor. - Dark or tinted page: set the container's text colour (for example
text-zinc-50onbg-zinc-950) and the heading follows. Only set--heading-inkwhen 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.mdhas an example. - Width: pass class="max-w-[20ch]" or similar for display headings so balanced lines stay short.
Public CSS variables
| Variable | Token |
|---|---|
--heading-ink | ink |
--heading-muted | muted |
Accessibility#
On this page- 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-levelis 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-labelledbyon 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-950on white and 10:1 forzinc-50onzinc-950. Re-check both if you set--heading-inkor--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.
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.