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.
cmp_section_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_section_heading_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-eaf58144333ad24d569a9dd7c5c68f3041debc2a1ac9e63c23dfabc47316d8bf
<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>
Usage#
On this pagePresentational 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.
- Suggested location
src/lib/components/section-heading-01- Required props
title
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.
Example
<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>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:
<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:
<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/ |
Built-in link outline |
--section-heading-accent |
#18181b |
Built-in link focus ring |
On a dark band:
<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 and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
title | string | Yes | None | 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 | None | Short label set small and uppercase in a paragraph before the heading, so the heading text stays clean. |
description | string | No | None | 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 | None | 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 | None | { 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 | None | Heading id, for aria-labelledby on your section. Generated with $props.id() when omitted. |
Customization#
On this pageChange 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-eyebrowcolours 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-inksets the title and the link label;--section-heading-mutedsets the description. - Link:
--section-heading-hairlineoutlines the built-in link and--section-heading-accentdraws its focus ring. The hover fill is mixed from ink, so it follows. - Dark page retone: ink
#fafafa, muted#a1a1aa, eyebrow#a1a1aa(or#93c5fdfor blue), hairline rgb(255 255 255 / 0.16), accent#fafafa(all--section-heading-*).usage.mdhas the snippet. - Sizes: the
titleClass,eyebrowClassanddescriptionClassmaps 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-labelledbyat the id you pass. - Action: pass
actionLinkfor a single link, or an action snippet for a button, a filled primary or two links.
Public CSS variables
| Variable | Token |
|---|---|
--section-heading-accent | accent |
--section-heading-ink | ink |
--section-heading-muted | muted |
--section-heading-eyebrow | eyebrow |
--section-heading-hairline | hairline |
Accessibility#
On this page- 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-labelledbyat 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.
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.