Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions .specs/section-title.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
---
name: section-title
category: content
category: marketing
structure: monolithic
status: approved
spec_version: 2
checksum: 3a4b15c27d3dda6af9bda55a10bb9e9db13df6865900f9ef805ae97589696e55
spec_version: 3
checksum: 3d3a1e4ec59dd948f5f2f4d236ce30ec39428bc5dd991f0903478658c5e3c7f9
figma:
url: https://www.figma.com/design/QEbHSTFDWfh4VHkBp6NWN3/Azion.com?node-id=7495-24338
node_id: 7495:24338
created: 2026-08-11
last_updated: 2026-08-11
last_updated: 2026-09-25
---

# Section Title — Component Spec
Expand All @@ -26,20 +26,23 @@ The framed header row that opens a page section: an optional overline, the secti

## When NOT to use

- For the page's leading band and its `h1` → use `hero-title` instead.
- For the page's leading band and its `h1` → use `hero` instead.
- For the top bar of an application shell → use `global-header` instead.
- For a plain framed container with arbitrary content → use `frame-box` instead.

## Related

- `hero-title` — the hero counterpart; renders the page's `h1` at hero scale.
- `hero` — the opening band; its `Hero.Title` renders the page's `h1` at hero scale.
- `frame-box` — the frame this component composes.
- `section-gap` — the empty frame that sets the air before and after a section header; this component holds no vertical air of its own beyond its padding.
- `overline` — the eyebrow treatment rendered above the headline.

## Best practices

- Keep one `section-title` per section, and let it own the section's `h2` so the page keeps one document outline.
- Leave `framed` on when the header is a brick of its own in a page column; turn it off when a band composes the header inside a frame it already draws, so the rule and the padding are not drawn twice.

- Keep one `section-title` per section, and let it own the section's `h2` so the page keeps one document outline. `size` changes the headline's step on the type scale, never its heading level — a page with a `large` and a `small` header still reads as two `h2`s.
- Leave `size` at `medium` for an ordinary section opener. Reach for `large` only where the headline is the band's whole statement, and for `small` where the header opens a subsection inside a band that already has one.
- Write the eyebrow as one or two words: it is set uppercase and prefixed with `//`, so a sentence in it reads as noise.
- Reach for `horizontal` when the description is long enough to earn its own column — on a narrow viewport it stacks back under the headline.
- Put the section's CTAs in the `actions` slot rather than in the body; the slot already stacks them fluid below `sm` and aligns them with the chosen `kind` above it.
Expand Down Expand Up @@ -74,6 +77,8 @@ import Button from '@aziontech/webkit/button'
| `description` | `string` | `''` | false | Supporting sentence under the headline; overridden by the default slot. |
| `eyebrow` | `string` | `''` | false | Short uppercase overline rendered above the headline. |
| `kind` | `'centered' \| 'left' \| 'horizontal'` | `'centered'` | false | Layout of the header: `centered` stacks and centers the copy, `left` stacks it at the start edge, `horizontal` sets the headline and its description in two columns. |
| `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | false | Step of the headline on the heading scale: `small` for a subsection inside a band, `medium` for an ordinary section opener, `large` for a band whose headline is the statement. |
| `framed` | `boolean` | `true` | false | Draw the header's own frame and padding. Turn it off when the header is composed inside a band that already owns both. |

## Events

Expand All @@ -90,6 +95,8 @@ import Button from '@aziontech/webkit/button'

- Visual states: `default`
- `data-kind` mirrors the `kind` prop: `centered` | `left` | `horizontal`
- `data-size` mirrors the `size` prop: `small` | `medium` | `large`
- `data-framed` present when the header draws its own frame and padding

## Motion & Animations

Expand All @@ -99,7 +106,9 @@ _none_

| Region | Token (DESIGN.md) |
|---|---|
| typography (headline) | `.text-heading-xl` |
| typography (headline, `size="small"`) | `.text-heading-lg` |
| typography (headline, `size="medium"`) | `.text-heading-xl` |
| typography (headline, `size="large"`) | `.text-heading-2xl` |
| typography (description) | `.text-heading-sm` |
| headline text | `var(--text-default)` |
| description text | `var(--text-muted)` |
Expand All @@ -120,15 +129,16 @@ _none_

- Visible focus: not applicable to the header itself; controls composed into the `actions` slot keep their own `focus-visible:ring-2 focus-visible:ring-(--ring-color) focus-visible:ring-offset-2 focus-visible:ring-offset-(--bg-canvas)` ring.
- Keyboard map: none of its own — `Tab` reaches only the controls placed in the `actions` slot, in DOM order.
- ARIA: the headline is a real `h2` (the page's `h1` belongs to `hero-title`), so no `role` or `aria-label` is added; the frame's rules and corner marks stay `aria-hidden="true"`. In `horizontal` the headline precedes its description in DOM order, so the reading order matches the visual one.
- ARIA: the headline is a real `h2` (the page's `h1` belongs to `Hero.Title`), so no `role` or `aria-label` is added; the frame's rules and corner marks stay `aria-hidden="true"`. In `horizontal` the headline precedes its description in DOM order, so the reading order matches the visual one.
- Contrast ≥4.5:1 (text) / ≥3:1 (large + icons): headline on `var(--text-default)`, description on `var(--text-muted)`, both over the page canvas.
- `motion-reduce:transition-none motion-reduce:transform-none` — not applicable, the component is static.
- Touch target ≥40×40 px — the `actions` row stretches its children to full width below `sm`, so slotted buttons keep their own target size.

## Stories (Storybook)

- Default
- Kinds — composite story rendering every `kind` value stacked, so the three layouts can be compared (justified: `kind` is the component's only enum axis and the difference is structural)
- Kinds — composite story rendering every `kind` value stacked, so the three layouts can be compared (justified: the difference between the layouts is structural)
- Sizes — composite story rendering every `size` value stacked, so the three steps of the headline scale can be compared
- WithActions — an `actions` row under the description (justified: the slot owns its own responsive layout, which no prop-driven story shows)

## Constraints — DO NOT
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const IMPORT_WITH_BUTTON = [IMPORT, "import Button from '@aziontech/webkit/butto

/** @type {import('@storybook/vue3').Meta<typeof SectionTitle>} */
const meta = {
title: 'Components/Content/SectionTitle',
title: 'Components/Marketing/SectionTitle',
component: SectionTitle,
tags: ['autodocs'],
parameters: {
Expand Down Expand Up @@ -56,6 +56,17 @@ const meta = {
defaultValue: { summary: "'centered'" }
}
},
size: {
control: 'inline-radio',
options: ['small', 'medium', 'large'],
description:
'Step of the headline on the heading scale: `small` for a subsection inside a band, `medium` for an ordinary section opener, `large` for a band whose headline is the statement.',
table: {
category: 'props',
type: { summary: "'small' | 'medium' | 'large'" },
defaultValue: { summary: "'medium'" }
}
},
default: {
control: false,
description: 'Description body; replaces the `description` prop when provided.',
Expand All @@ -71,7 +82,8 @@ const meta = {
title: 'Everything runs at the edge',
description: 'One platform for applications, security and observability.',
eyebrow: 'Platform',
kind: 'centered'
kind: 'centered',
size: 'medium'
}
}

Expand Down Expand Up @@ -143,6 +155,46 @@ export const Kinds = {
}
}

const SIZES_TEMPLATE = `<div>
<SectionTitle
size="small"
kind="left"
eyebrow="Small"
title="A subsection inside a band"
description="The headline drops a step so it opens a part of a section without competing with the section's own header."
/>
<SectionTitle
size="medium"
kind="left"
eyebrow="Medium"
title="An ordinary section opener"
description="The default step, and the one nearly every section header wants."
/>
<SectionTitle
size="large"
kind="left"
eyebrow="Large"
title="The headline is the statement"
description="For a band that carries one claim and little else — the copy under it stays at reading size."
/>
</div>`

/** @type {import('@storybook/vue3').StoryObj<typeof SectionTitle>} */
export const Sizes = {
render: () => ({ components: { SectionTitle }, template: SIZES_TEMPLATE }),
parameters: {
controls: { disable: true },
docs: {
controls: { disable: true },
description: {
story:
'The three steps of the headline scale, stacked. `size` moves the headline between `text-heading-lg`, `text-heading-xl` and `text-heading-2xl` — the description stays at reading size in all three, and the heading stays an `h2`, so the page outline is unaffected by how loud a header is.'
},
source: { code: toSfc(IMPORT, SIZES_TEMPLATE) }
}
}
}

const WITH_ACTIONS_TEMPLATE = `<SectionTitle
eyebrow="Platform"
title="Everything runs at the edge"
Expand Down Expand Up @@ -172,4 +224,3 @@ export const WithActions = {
}
}
}

4 changes: 2 additions & 2 deletions packages/webkit/.size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@
},
{
"name": "section-title",
"path": "src/components/content/section-title/section-title.vue",
"limit": "4 KB"
"path": "src/components/marketing/section-title/section-title.vue",
"limit": "3.5 KB"
},
{
"name": "hero-title",
Expand Down
26 changes: 21 additions & 5 deletions packages/webkit/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -2897,10 +2897,10 @@
},
"section-title": {
"import": "@aziontech/webkit/section-title",
"target": "./src/components/content/section-title/section-title.vue",
"target": "./src/components/marketing/section-title/section-title.vue",
"kind": "component",
"treeShakeableImport": "@aziontech/webkit/section-title",
"category": "content",
"category": "marketing",
"structure": "monolithic",
"status": "approved",
"props": [
Expand Down Expand Up @@ -2931,6 +2931,20 @@
"default": "'centered'",
"required": "false",
"doc": "Layout of the header: `centered` stacks and centers the copy, `left` stacks it at the start edge, `horizontal` sets the headline and its description in two columns."
},
{
"name": "size",
"type": "'small' | 'medium' | 'large'",
"default": "'medium'",
"required": "false",
"doc": "Step of the headline on the heading scale: `small` for a subsection inside a band, `medium` for an ordinary section opener, `large` for a band whose headline is the statement."
},
{
"name": "framed",
"type": "boolean",
"default": "true",
"required": "false",
"doc": "Draw the header's own frame and padding. Turn it off when the header is composed inside a band that already owns both."
}
],
"slots": [
Expand All @@ -2952,18 +2966,20 @@
"For a wide band whose headline and supporting sentence should sit side by side rather than stacked — `kind=\"horizontal\"`."
],
"avoidWhen": [
"For the page's leading band and its `h1` → use `hero-title` instead.",
"For the page's leading band and its `h1` → use `hero` instead.",
"For the top bar of an application shell → use `global-header` instead.",
"For a plain framed container with arbitrary content → use `frame-box` instead."
],
"related": [
"hero-title` — the hero counterpart; renders the page's `h1` at hero scale.",
"hero` — the opening band; its `Hero.Title` renders the page's `h1` at hero scale.",
"frame-box` — the frame this component composes.",
"section-gap` — the empty frame that sets the air before and after a section header; this component holds no vertical air of its own beyond its padding.",
"overline` — the eyebrow treatment rendered above the headline."
],
"bestPractices": [
"Keep one `section-title` per section, and let it own the section's `h2` so the page keeps one document outline.",
"Leave `framed` on when the header is a brick of its own in a page column; turn it off when a band composes the header inside a frame it already draws, so the rule and the padding are not drawn twice.",
"Keep one `section-title` per section, and let it own the section's `h2` so the page keeps one document outline. `size` changes the headline's step on the type scale, never its heading level — a page with a `large` and a `small` header still reads as two `h2`s.",
"Leave `size` at `medium` for an ordinary section opener. Reach for `large` only where the headline is the band's whole statement, and for `small` where the header opens a subsection inside a band that already has one.",
"Write the eyebrow as one or two words: it is set uppercase and prefixed with `//`, so a sentence in it reads as noise.",
"Reach for `horizontal` when the description is long enough to earn its own column — on a narrow viewport it stacks back under the headline.",
"Put the section's CTAs in the `actions` slot rather than in the body; the slot already stacks them fluid below `sm` and aligns them with the chosen `kind` above it.",
Expand Down
2 changes: 1 addition & 1 deletion packages/webkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@
"./card-box": "./src/components/content/card-box/card-box.vue",
"./card-pricing": "./src/components/content/card-pricing/card-pricing.vue",
"./hero-title": "./src/components/marketing/hero/hero-title/hero-title.vue",
"./section-title": "./src/components/content/section-title/section-title.vue",
"./section-title": "./src/components/marketing/section-title/section-title.vue",
"./item": "./src/components/content/item/index.ts",
"./item-root": "./src/components/content/item/item.vue",
"./item-group": "./src/components/content/item/item-group.vue",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import { composeStories } from '@storybook/vue3'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

import * as stories from '../../../../../../apps/storybook/src/stories/components/content/section-title/SectionTitle.stories'
import * as stories from '../../../../../../apps/storybook/src/stories/components/marketing/section-title/SectionTitle.stories'
import { expectNoA11yViolations } from '../../../test/axe'
import SectionTitle from './section-title.vue'

const { Default, Kinds, WithActions } = composeStories(stories)
const { Default, Kinds, Sizes, WithActions } = composeStories(stories)

const TESTID = 'content-section-title'
const TESTID = 'marketing-section-title'

const props = { title: 'Everything runs at the edge' }

Expand All @@ -24,8 +24,6 @@ describe('SectionTitle', () => {
const { getByTestId } = render(SectionTitle, { props })
const root = getByTestId(TESTID)

// flush="top" is subtracted from borders="y", so the header keeps only its bottom rule —
// the divider between the header and the section body.
expect(root).toHaveAttribute('data-flush', 'top')
expect(root).toHaveAttribute('data-borders', 'bottom')
})
Expand All @@ -45,6 +43,27 @@ describe('SectionTitle', () => {
expect(getByTestId(TESTID)).toHaveAttribute('data-kind', 'centered')
})

it.each(['small', 'medium', 'large'] as const)('reflects size="%s" on data-size', (size) => {
const { getByTestId } = render(SectionTitle, { props: { ...props, size } })

expect(getByTestId(TESTID)).toHaveAttribute('data-size', size)
})

it('defaults to the medium size', () => {
const { getByTestId } = render(SectionTitle, { props })

expect(getByTestId(TESTID)).toHaveAttribute('data-size', 'medium')
})

it.each(['small', 'medium', 'large'] as const)(
'keeps the headline an h2 at size="%s"',
(size) => {
const { getByRole } = render(SectionTitle, { props: { ...props, size } })

expect(getByRole('heading', { level: 2 })).toHaveTextContent('Everything runs at the edge')
}
)

it.each(['centered', 'left', 'horizontal'] as const)(
'keeps the headline before its description in DOM order when kind="%s"',
(kind) => {
Expand All @@ -57,8 +76,6 @@ describe('SectionTitle', () => {

expect(heading).toHaveTextContent('Everything runs at the edge')
expect(paragraph).toHaveTextContent('One platform.')
// Node.DOCUMENT_POSITION_FOLLOWING === 4: the description follows the headline, so the
// reading order matches the visual one in every layout — including the two-column one.
expect(heading.compareDocumentPosition(paragraph) & 4).toBe(4)
}
)
Expand Down Expand Up @@ -148,6 +165,16 @@ describe('SectionTitle', () => {
])
})

it('renders the Sizes story with one header per step', () => {
const { getAllByTestId } = render(Sizes())

expect(getAllByTestId(TESTID).map((el) => el.getAttribute('data-size'))).toEqual([
'small',
'medium',
'large'
])
})

it('renders the WithActions story with both CTAs', () => {
const { getAllByRole } = render(WithActions())

Expand All @@ -157,4 +184,40 @@ describe('SectionTitle', () => {
])
})
})

describe('framed', () => {
it('draws its own frame by default', () => {
const { getByTestId } = render(SectionTitle, { props: { title: 'Platform' } })
const root = getByTestId(TESTID)

expect(root).toHaveAttribute('data-framed')
expect(root).toHaveAttribute('data-borders')
})

it('renders as a plain block when framed is false', () => {
const { getByTestId } = render(SectionTitle, {
props: { title: 'Platform', framed: false }
})
const root = getByTestId(TESTID)

expect(root).not.toHaveAttribute('data-framed')
expect(root).not.toHaveAttribute('data-borders')
expect(root).not.toHaveAttribute('data-marks')
})

it('keeps its copy and its heading level unframed', () => {
const { getByRole, getByText } = render(SectionTitle, {
props: {
title: 'Platform',
eyebrow: 'Build',
description: 'One sentence.',
framed: false
}
})

expect(getByRole('heading', { level: 2, name: 'Platform' })).toBeInTheDocument()
expect(getByText('Build')).toBeInTheDocument()
expect(getByText('One sentence.')).toBeInTheDocument()
})
})
})
Loading