A deep dive into semantic HTML, ARIA landmark naming, heading hierarchy, reusable Vue components, and accessibility testing in GitLab.
Frontend accessibility problems are not always visible.
A component can look perfectly correct, follow the design system, and still introduce accessibility violations because its HTML structure communicates the wrong meaning to assistive technologies.
One example is using a heading element purely to achieve a particular font size or visual appearance.
A reusable sidebar component might render an <h4> because that heading level matches the intended design. However, when the same component is embedded inside pages with different document structures, it can introduce skipped heading levels and accessibility violations.
While contributing to GitLab's shared Vue components, I addressed this problem in GitLab Merge Request !269133, resolving Issue #639006.
The change focused on GitLab's PageNavigationMenu component, replacing a semantically incorrect heading with an accessible navigation label while preserving the existing visual appearance.
This article explores the underlying accessibility problem, semantic HTML, ARIA landmark relationships, Vue implementation details, and regression testing.
1. Understanding the Difference Between Visual and Semantic Hierarchy
HTML provides semantic elements that communicate the structure and meaning of a document.
Heading elements represent the hierarchy of content:
<h1>Page Title</h1> <h2>Installation</h2> <h3>Prerequisites</h3> <h2>Configuration</h2> This structure communicates relationships between sections.
Assistive technologies use headings to help users understand and navigate a page.
For example, screen reader users can navigate through headings to locate relevant sections without reading the entire document sequentially.
However, a common frontend implementation mistake is choosing a heading element based on its default visual appearance rather than its semantic purpose.
Consider:
<h4 class="gl-text-sm gl-text-subtle"> On this page </h4> The developer may only want a small, visually subtle label.
But <h4> does not mean small text.
It means a fourth-level heading in the document hierarchy.
These are two different concerns:
| Concern | Responsibility |
|---|---|
| Semantic hierarchy | HTML elements such as h1 through h6 |
| Visual presentation | CSS classes, typography, spacing, and colors |
Using a heading to achieve a visual style introduces an unnecessary semantic relationship.
This becomes particularly problematic in reusable components.
2. The Problem in GitLab's PageNavigationMenu
The affected component was:
app/assets/javascripts/vue_shared/components/page_navigation_menu.vue PageNavigationMenu is a shared Vue component responsible for displaying page navigation links.
Its purpose is to provide users with a convenient way to navigate between sections of a page.
The component includes a label such as:
On this page Before the fix, the relevant markup used a hardcoded <h4>:
<nav class="gl-sticky gl-top-5"> <h4 class="gl-mb-4 gl-text-sm gl-text-subtle"> {{ title }} </h4> <!-- Navigation items --> </nav> The issue was not that the text looked incorrect.
The issue was that the component introduced a heading into the document outline without knowing the surrounding page's heading structure.
2.1 Why reusable components make this harder
A shared component cannot safely assume that every page has the same document structure.
For example, one page might contain:
<h1>Pipeline configuration</h1> Another page might contain:
<h1>Resource components</h1> <h2>Configuration</h2> A third page might use a different heading structure entirely.
When the same navigation component inserts an <h4> into these different contexts, it can introduce skipped heading levels.
This creates a structural accessibility problem even though the component looks identical everywhere.
2.2 The accessibility audit
The issue was detected through heading-order accessibility validation.
The affected Storybook configuration also contained an accessibility rule suppression:
a11y: { config: { rules: [ { id: 'heading-order', enabled: false, }, ], }, }, Suppressing the rule did not correct the underlying markup.
It only prevented that particular accessibility check from reporting the violation.
The better engineering approach was to fix the semantic structure rather than retain an exception for an avoidable issue.
3. Understanding HTML Heading Order
HTML provides six heading levels:
<h1>Main heading</h1> <h2>Section heading</h2> <h3>Subsection heading</h3> <h4>Nested subsection</h4> <h5>Further subsection</h5> <h6>Lowest-level heading</h6> These elements represent the hierarchy of the document.
A typical structure progresses from a parent heading to its nested sections.
For example:
<main> <h1>Documentation</h1> <section> <h2>Getting Started</h2> <section> <h3>Installation</h3> </section> </section> </main> A heading-order accessibility check can identify suspicious jumps in heading levels.
For instance:
<h1>Documentation</h1> <h4>On this page</h4> The transition from <h1> to <h4> skips intermediate levels.
The important architectural point is that a navigation widget should not introduce a heading merely because its label needs to be visually prominent.
Its purpose is to identify a navigation region, not to create another section in the document's content hierarchy.
4. Choosing the Correct Semantic Element
The fix required separating the navigation label's semantic role from its visual styling.
The text:
On this page identifies the purpose of the navigation region.
It does not necessarily introduce a new section of the document.
A paragraph is therefore more appropriate for the visible label:
<p>On this page</p> However, simply replacing <h4> with <p> would leave another accessibility concern.
The navigation landmark should have an accessible name.
This is where aria-labelledby becomes important.
4.1 Understanding navigation landmarks
The HTML <nav> element represents a section of a page intended for navigation.
For example:
<nav> <a href="#installation">Installation</a> <a href="#configuration">Configuration</a> <a href="#deployment">Deployment</a> </nav> Assistive technologies recognize this as a navigation landmark.
However, pages can contain multiple navigation landmarks.
Consider:
<nav> <!-- Primary site navigation --> </nav> <nav> <!-- Documentation navigation --> </nav> <nav> <!-- On-page navigation --> </nav> Users need a way to distinguish these navigation regions.
Providing an accessible name helps identify their purpose.
4.2 Understanding aria-labelledby
The aria-labelledby attribute references another element whose text provides the accessible name.
For example:
<p id="page-nav-title"> On this page </p> <nav aria-labelledby="page-nav-title"> <!-- Navigation links --> </nav> Here:
- The paragraph contains the visible label.
- The paragraph's
idprovides a reference target. - The navigation element references that target through
aria-labelledby. - Assistive technologies can use the referenced text as the navigation landmark's accessible name.
This creates an explicit semantic relationship without introducing an artificial heading.
The result is a named navigation landmark with a visible label.
5. Implementing the Fix in Vue
The implementation was made in:
app/assets/javascripts/vue_shared/components/page_navigation_menu.vue The solution required three related changes:
- Generate a unique identifier for the navigation label.
- Associate the navigation landmark with that label using
aria-labelledby. - Replace the heading with a paragraph while preserving the original typography.
5.1 Generating a unique identifier
A reusable component may appear multiple times on the same page.
Hardcoding an identifier would risk creating duplicate HTML IDs.
For example:
<p id="page-nav-title">On this page</p> If two instances render this same ID, the document contains duplicate identifiers.
That makes the aria-labelledby relationship ambiguous.
To avoid this, the component uses Lodash's uniqueId utility:
import { uniqueId } from 'lodash-es'; The identifier is generated in the Vue component's data() function:
data() { return { titleId: uniqueId('page-nav-title-'), // ... }; }, Each component instance receives its own generated identifier.
For example, multiple instances could produce:
page-nav-title-1 page-nav-title-2 page-nav-title-3 This allows each navigation landmark to reference its corresponding title.
5.2 Connecting the label to the navigation landmark
The updated template:
<nav class="gl-sticky gl-top-5" :aria-labelledby="titleId" > <p :id="titleId" class="gl-mb-4 gl-text-sm gl-font-bold gl-text-subtle" > {{ title }} </p> <!-- Existing navigation content --> </nav> The important relationship is:
<nav :aria-labelledby="titleId"> and:
<p :id="titleId"> Both attributes reference the same Vue data property.
Vue binds the generated identifier dynamically, ensuring that the navigation landmark points to the correct label.
5.3 Why use aria-labelledby instead of aria-label?
Both attributes can provide an accessible name, but they work differently.
aria-label
Provides an accessible name directly as a string:
<nav aria-label="On this page"> aria-labelledby
References an existing element:
<p id="page-nav-title"> On this page </p> <nav aria-labelledby="page-nav-title"> In this component, the label already exists visibly in the interface.
Using aria-labelledby connects the accessible name to the actual visible text rather than duplicating the label in a separate attribute.
This also keeps the visible and accessible naming source aligned.
6. Preserving Visual Consistency After Changing the HTML Element
One subtle implementation detail emerged during review.
Replacing a heading with a paragraph changes the browser's default typography behavior.
GitLab's global styles apply bold font weight to heading elements through:
$gl-font-weight-bold A regular paragraph, on the other hand, normally uses a font weight of 400.
Therefore, simply changing:
<h4 class="gl-mb-4 gl-text-sm gl-text-subtle"> to:
<p class="gl-mb-4 gl-text-sm gl-text-subtle"> would change the appearance of the component.
Although the semantic structure would improve, the visual hierarchy would no longer match the original design.
6.1 Explicitly preserving typography
The fix added:
gl-font-bold The final paragraph classes became:
class="gl-mb-4 gl-text-sm gl-font-bold gl-text-subtle" This preserves the intended styling:
| CSS class | Purpose |
|---|---|
gl-mb-4 | Maintains bottom spacing |
gl-text-sm | Preserves the small text size |
gl-font-bold | Explicitly preserves bold typography |
gl-text-subtle | Preserves the subtle text color |
This is an important frontend engineering principle:
Semantic HTML and visual styling should be independent.
A component should not depend on heading-specific browser or global styles simply to achieve its intended appearance.
By explicitly declaring the typography, the component becomes semantically correct without introducing a visual regression.
7. Removing the Accessibility Suppression
After correcting the markup, the existing accessibility suppression in:
app/assets/javascripts/vue_shared/components/page_navigation_menu.stories.js was no longer necessary.
The heading-order rule suppression was removed.
This is an important part of the contribution.
Accessibility rule suppressions should not become permanent workarounds for incorrect HTML semantics.
A suppression can hide a real issue from future contributors and reduce the effectiveness of automated accessibility validation.
The correct sequence is:
- Identify the semantic issue.
- Correct the underlying markup.
- Verify the component against the accessibility rule.
- Remove the obsolete suppression.
This restores normal accessibility validation for the Storybook component.
8. Writing Regression Tests for Accessibility
Changing the HTML structure is not enough.
The expected accessibility relationship and visual styling should also be protected through tests.
I updated:
spec/frontend/vue_shared/components/page_navigation_menu_spec.js The tests were extended to verify three important properties.
8.1 Verify the navigation landmark's accessible name
The test checks that the navigation element has an aria-labelledby attribute referencing the title element's ID.
Conceptually:
const nav = wrapper.find('nav'); const title = wrapper.find('p'); expect(nav.attributes('aria-labelledby')).toBe( title.attributes('id'), ); This protects the relationship between the visible label and the navigation landmark.
8.2 Ensure the component does not render heading elements
The component should not introduce headings into the document hierarchy.
The regression test verifies that no heading elements are rendered inside the component:
expect(wrapper.findAll('h1, h2, h3, h4, h5, h6')).toHaveLength(0); This prevents the original implementation pattern from being accidentally reintroduced.
8.3 Verify the visual styling
The paragraph should retain the intended typography.
The test checks the required CSS classes:
expect(title.classes()).toContain('gl-font-bold'); expect(title.classes()).toContain('gl-text-sm'); expect(title.classes()).toContain('gl-text-subtle'); These assertions ensure that the semantic correction does not silently remove the intended visual styling.
8.4 Why test the relationship instead of only the markup?
A test that checks only for a paragraph would not guarantee that the navigation landmark has an accessible name.
Likewise, checking only for aria-labelledby would not guarantee that the referenced element exists or that the intended typography remains intact.
The three checks cover different responsibilities:
- Semantic structure.
- Accessible landmark naming.
- Visual presentation.
Together, they provide more meaningful regression protection than checking a single HTML element.
9. The Broader Engineering Lessons
This contribution illustrates several important principles for building accessible frontend systems.
9.1 Reusable components must not assume document hierarchy
A shared component may be rendered inside many different page structures.
Its internal markup should not introduce heading levels based on assumptions about its parent.
Document hierarchy belongs to the content structure, not to arbitrary visual components.
9.2 Landmark naming improves navigation
HTML landmark elements provide meaningful structural navigation.
Using aria-labelledby to associate a navigation region with its visible label helps assistive technology users distinguish its purpose.
9.3 Accessibility and design consistency are not competing requirements
A semantic HTML correction should not require sacrificing the original design.
Explicit typography classes allow the component to retain its visual appearance while using the appropriate HTML element.
9.4 Accessibility suppressions should be temporary
Disabling an automated accessibility rule can be appropriate in limited circumstances, but it should not replace correcting an underlying semantic problem.
When the cause is fixed, the suppression should be removed.
9.5 Test accessibility relationships, not just element existence
A component can contain valid-looking HTML and still expose an incomplete accessibility relationship.
Tests should verify how elements are connected, how landmarks are named, and whether the intended structure is preserved.
10. Final Summary
In GitLab MR !269133, I fixed a heading-order accessibility issue in the shared PageNavigationMenu Vue component.
The original implementation used a hardcoded <h4> for the visible navigation label.
Because the component was reused across pages with different heading hierarchies, this introduced unnecessary heading levels and required a Storybook accessibility-rule suppression.
The solution replaced the heading with a paragraph and connected it to the navigation landmark using aria-labelledby.
The implementation also:
- Generated a unique label ID using Lodash's
uniqueId. - Added
aria-labelledbyto the<nav>element. - Replaced
<h4>with<p>. - Added
gl-font-boldto preserve the original typography. - Removed the Storybook
heading-ordersuppression. - Updated frontend tests to validate the accessible naming relationship, absence of heading elements, and required CSS classes.
The broader lesson is that semantic HTML should describe the actual purpose of a component, while CSS should control its visual presentation.
A navigation label does not need to become a document heading simply to look like one.
By separating these responsibilities, reusable components can remain visually consistent, structurally correct, and more accessible across different application contexts.
References and Implementation
- Issue: gitlab-org/gitlab#639006
- Merge Request: gitlab-org/gitlab!269133
- Vue Component:
app/assets/javascripts/vue_shared/components/page_navigation_menu.vue - Storybook:
app/assets/javascripts/vue_shared/components/page_navigation_menu.stories.js - Frontend Tests:
spec/frontend/vue_shared/components/page_navigation_menu_spec.js
Author: Manish Kumar
GitLab: @manishpatel00
Contributing to GitLab's frontend architecture, accessibility, and open-source engineering.